From d85dcd401b503edf352621d4ae1bd3790cf289ae Mon Sep 17 00:00:00 2001 From: yangqianqian <5845211314@qq.com> Date: Thu, 18 Jun 2026 11:17:37 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC11=E7=8E=AF=E8=A3=82=E5=8F=98=E9=87=8D?= =?UTF-8?q?=E5=86=99=EF=BC=9A=E5=AF=B9=E9=BD=90=E4=B8=8A=E7=BA=BF=E7=89=88?= =?UTF-8?q?=20split.js=20=E4=B8=80=E6=AC=A1LLM=E5=87=BAN=E5=A5=97=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=AC=94=E8=AE=B0=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构从"扇出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 --- .../versions/018_fission_notes_table.py | 57 +++++ backend/app/api/v1/fission.py | 89 +++++--- backend/app/models/fission.py | 50 +++++ .../services/ai_engine/fission_fallback.py | 145 +++++++++++++ .../app/services/ai_engine/fission_prompt.py | 204 +++++++++++++++--- backend/app/services/fission_images.py | 81 +++++++ backend/app/services/fission_pipeline.py | 179 +++++++++++++++ backend/app/services/fission_service.py | 46 ++-- backend/app/workers/tasks.py | 155 +++++++++++-- backend/tests/test_fission_engine.py | 197 +++++++++++++++++ backend/tests/test_fission_pipeline.py | 82 +++++++ backend/tests/test_integration_seams.py | 2 + frontend/src/app/fission/[id]/notes/page.tsx | 112 ++++++++++ frontend/src/app/fission/page.tsx | 76 +++++++ .../components/fission/FissionLauncher.tsx | 112 ++++++++++ .../components/fission/FissionNoteCard.tsx | 117 ++++++++++ .../components/fission/FissionProgress.tsx | 108 ++++++++++ plans/R-裂变重写核销表.md | 66 ++++++ 18 files changed, 1772 insertions(+), 106 deletions(-) create mode 100644 backend/alembic/versions/018_fission_notes_table.py create mode 100644 backend/app/services/ai_engine/fission_fallback.py create mode 100644 backend/app/services/fission_images.py create mode 100644 backend/app/services/fission_pipeline.py create mode 100644 backend/tests/test_fission_engine.py create mode 100644 backend/tests/test_fission_pipeline.py create mode 100644 frontend/src/app/fission/[id]/notes/page.tsx create mode 100644 frontend/src/app/fission/page.tsx create mode 100644 frontend/src/components/fission/FissionLauncher.tsx create mode 100644 frontend/src/components/fission/FissionNoteCard.tsx create mode 100644 frontend/src/components/fission/FissionProgress.tsx create mode 100644 plans/R-裂变重写核销表.md diff --git a/backend/alembic/versions/018_fission_notes_table.py b/backend/alembic/versions/018_fission_notes_table.py new file mode 100644 index 0000000..af51507 --- /dev/null +++ b/backend/alembic/versions/018_fission_notes_table.py @@ -0,0 +1,57 @@ +"""create fission_notes table + +Revision ID: 018 +Revises: 017 +Create Date: 2026-06-18 + +裂变重写(对齐上线版 split.js):一次LLM出N套完整笔记包,每套落一行。 +每套含 标题/正文/标签/封面/imagePlan/裂变维度,带评分+生图结果。 +""" +from alembic import op +import sqlalchemy as sa + +revision = "018" +down_revision = "017" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "fission_notes", + sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), + sa.Column( + "fission_id", sa.BigInteger, + sa.ForeignKey("fission_tasks.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "workspace_id", sa.BigInteger, + sa.ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, comment="多租户隔离", + ), + sa.Column("seq", sa.Integer, nullable=False, server_default="0", + comment="第几套(0起)"), + sa.Column("note_json", sa.Text, nullable=True, comment="完整笔记包JSON"), + sa.Column("images_json", sa.Text, nullable=True, comment="生图结果JSON"), + sa.Column("score", sa.Integer, nullable=False, server_default="0"), + sa.Column("passed", sa.Integer, nullable=False, server_default="0", + comment="是否过线(>=80)"), + sa.Column("needs_optimization", sa.Integer, nullable=False, + server_default="0", comment="不达标降级草稿标记"), + sa.Column("dimension", sa.String(64), nullable=True, + comment="裂变维度: 换角度/换痛点/换人群"), + sa.Column("status", sa.String(20), nullable=False, + server_default="pending", + comment="状态: pending/scored/image_done/failed"), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now(), + nullable=False), + ) + op.create_index("idx_fission_notes_fission_id", "fission_notes", ["fission_id"]) + op.create_index("idx_fission_notes_workspace_id", "fission_notes", ["workspace_id"]) + + +def downgrade(): + op.drop_index("idx_fission_notes_workspace_id", table_name="fission_notes") + op.drop_index("idx_fission_notes_fission_id", table_name="fission_notes") + op.drop_table("fission_notes") diff --git a/backend/app/api/v1/fission.py b/backend/app/api/v1/fission.py index 3f5ff98..fe26f4f 100644 --- a/backend/app/api/v1/fission.py +++ b/backend/app/api/v1/fission.py @@ -1,10 +1,12 @@ """ -app/api/v1/fission.py — 第11环裂变路由 +app/api/v1/fission.py — 第11环裂变路由(对齐上线版 split.js:一次出N套) -POST /fission 触发裂变(1源任务→N套子任务) -GET /fission/{id} 查裂变进度(聚合N个子任务状态) -工作台内呈现,子任务走标准 SSE 流回进度。 +POST /fission 触发裂变(1源任务→一次LLM出N套完整笔记包) +GET /fission/{id} 查裂变进度(聚合 FissionNote 落库/生图状态) +GET /fission/{id}/notes 取 N 套完整笔记包(独立展示页用) +工作台内呈现,N 套笔记落 FissionNote 表,非扇出 GenerationTask。 """ +import json import logging from typing import Annotated @@ -15,8 +17,7 @@ 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 +from app.models.fission import FissionTask, FissionNote logger = logging.getLogger(__name__) router = APIRouter(tags=["fission"]) @@ -34,13 +35,13 @@ def create_fission_endpoint( current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): - """触发裂变:从源任务扇出 N 套完整笔记包。返回 fission_id + 子 task_ids。""" + """触发裂变:从源任务一次出 N 套完整笔记包。返回 fission_id。""" from app.services.fission_service import create_fission - fission_id, task_ids = create_fission( + fission_id, _ = 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)}) + return ok({"fission_id": fission_id, + "reference_level": body.reference_level, "fanout_count": body.fanout_count}) @router.get("/fission/{fission_id}") @@ -49,7 +50,7 @@ def get_fission_progress( current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): - """查裂变进度:聚合 N 个子任务状态(x/N 完成)。""" + """查裂变进度:聚合 FissionNote 落库/生图状态(x/N 完成)。""" ft = db.query(FissionTask).filter( FissionTask.id == fission_id, FissionTask.workspace_id == current_user.workspace_id, @@ -57,22 +58,60 @@ def get_fission_progress( if not ft: raise_not_found("裂变任务不存在") - subs = db.query(GenerationTask).filter( - GenerationTask.source_fission_id == fission_id, - GenerationTask.workspace_id == current_user.workspace_id, + notes = db.query(FissionNote).filter( + FissionNote.fission_id == fission_id, + FissionNote.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() - + done = sum(1 for n in notes if n.status == "image_done") + failed = sum(1 for n in notes if n.status == "failed") + total = len(notes) + # FissionTask.status 由 pipeline 维护(generating→completed/failed);此处只读不改, + # 避免 API 轮询与 worker 写状态打架。进度数 = 已落库套数的生图完成度。 return ok({ "fission_id": ft.id, "reference_level": ft.reference_level, - "fanout_count": ft.fanout_count, "status": agg, + "fanout_count": ft.fanout_count, "status": ft.status, "progress": {"done": done, "failed": failed, "total": total}, - "task_ids": [s.id for s in subs], }) + + +@router.get("/fission/{fission_id}/notes") +def get_fission_notes( + fission_id: int, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + """取 N 套完整笔记包(独立展示页用):每套含文案+标签+imagePlan+图+分数。""" + ft = db.query(FissionTask).filter( + FissionTask.id == fission_id, + FissionTask.workspace_id == current_user.workspace_id, + ).first() + if not ft: + raise_not_found("裂变任务不存在") + + rows = db.query(FissionNote).filter( + FissionNote.fission_id == fission_id, + FissionNote.workspace_id == current_user.workspace_id, + ).order_by(FissionNote.seq.asc()).all() + + notes = [] + for r in rows: + try: + note = json.loads(r.note_json) if r.note_json else {} + except (ValueError, TypeError): + note = {} + try: + images = json.loads(r.images_json) if r.images_json else [] + except (ValueError, TypeError): + images = [] + # 内部评分中间字段(_score/_passed/_block)不外泄,前端用平铺的 score/passed + for k in ("_score", "_passed", "_block"): + note.pop(k, None) + notes.append({ + "seq": r.seq, "note": note, "images": images, + "score": r.score, "passed": bool(r.passed), + "needs_optimization": bool(r.needs_optimization), + "dimension": r.dimension, "status": r.status, + }) + + return ok({"fission_id": ft.id, "status": ft.status, + "fanout_count": ft.fanout_count, "notes": notes}) diff --git a/backend/app/models/fission.py b/backend/app/models/fission.py index 37274ea..716ba02 100644 --- a/backend/app/models/fission.py +++ b/backend/app/models/fission.py @@ -51,3 +51,53 @@ class FissionTask(Base): __table_args__ = ( Index("idx_fission_tasks_workspace_id", "workspace_id"), ) + + +class FissionNote(Base): + """裂变产出的单套完整笔记包(一次LLM出N套,每套落一行)。 + + 对齐上线版 split.js handleContentSplit:每套含 标题/正文/标签/封面/imagePlan, + 带评分+裂变维度+生图结果。北哥在独立展示页查看。 + """ + __tablename__ = "fission_notes" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + fission_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("fission_tasks.id", ondelete="CASCADE"), nullable=False + ) + workspace_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False + ) + seq: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="第几套(0起)" + ) + # 完整笔记包 JSON:title/content/tags/coverTitle/imagePlan/dimension/audience/scene/painPoint/keywords + note_json: Mapped[str | None] = mapped_column( + Text, comment="完整笔记包JSON" + ) + # 生图结果 JSON:[{role,name,image_url,error}...] + images_json: Mapped[str | None] = mapped_column( + Text, comment="生图结果JSON" + ) + score: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + passed: Mapped[bool] = mapped_column( + Integer, default=0, nullable=False, comment="是否过线(>=80)" + ) + needs_optimization: Mapped[bool] = mapped_column( + Integer, default=0, nullable=False, comment="不达标降级草稿标记" + ) + dimension: Mapped[str | None] = mapped_column( + String(64), comment="裂变维度: 换角度/换痛点/换人群" + ) + status: Mapped[str] = mapped_column( + String(20), default="pending", nullable=False, + comment="状态: pending/scored/image_done/failed" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), nullable=False + ) + + __table_args__ = ( + Index("idx_fission_notes_fission_id", "fission_id"), + Index("idx_fission_notes_workspace_id", "workspace_id"), + ) diff --git a/backend/app/services/ai_engine/fission_fallback.py b/backend/app/services/ai_engine/fission_fallback.py new file mode 100644 index 0000000..9e92cd7 --- /dev/null +++ b/backend/app/services/ai_engine/fission_fallback.py @@ -0,0 +1,145 @@ +""" +app/services/ai_engine/fission_fallback.py — 裂变品类兜底(从 fission_prompt 拆出) + +LLM 挂/返回不可解析时,按品类生成完整草稿,保证不卡用户(对齐上线版 split.js +的 inferCategory / fallbackAnglesByCategory / buildFallbackNotes)。 +拆分原因:fission_prompt.py 超 200 行红线上限,品类兜底是内聚可独立的一块。 +""" +from __future__ import annotations + +import re + +from app.services.ai_engine.fission_prompt import ( + normalize_tags, + sanitize_image_plan_text, +) + +_CATEGORY_PATTERNS = [ + ("个护护理", re.compile(r"护手|手霜|身体乳|润肤|唇膏|洗护|护理")), + ("美妆护肤", re.compile(r"霜|乳|精华|面膜|粉底|彩妆|口红|护肤|美妆")), + ("食品饮品", re.compile(r"饮|茶|咖啡|果汁|奶|冲泡|零食|食品|吃")), + ("营养健康", re.compile(r"维生素|益生菌|蛋白|营养|保健|膳食")), + ("家居生活", re.compile(r"收纳|清洁|家居|厨房|小物|工具|电器")), + ("服饰穿搭", re.compile(r"衣|裤|鞋|包|穿搭|面料|服饰")), +] + + +def infer_category(product: dict) -> str: + """按产品名/卖点/关键词推断品类(对齐上线版 inferCategory)。""" + p = product or {} + text = ( + str(p.get("name", "")) + "、".join(p.get("selling_points", []) or []) + + "、".join(p.get("keywords", []) or []) + ) + for cat, pattern in _CATEGORY_PATTERNS: + if pattern.search(text): + return cat + return "通用好物" + + +def _fallback_angles(category: str, product_name: str, points: list[str]) -> list[dict]: + """按品类返回兜底角度(对齐上线版 fallbackAnglesByCategory,节选主品类+通用兜底)。""" + name = product_name or "这个好物" + p0 = points[0] if points else "到底好不好用" + maps = { + "个护护理": [ + {"dimension": "换人群", "title": f"{name}手干星人真的会回购!", "scene": "办公室/通勤", "painPoint": "手干、倒刺、涂完黏手"}, + {"dimension": "换场景", "title": "包里常备这支太省心了", "scene": "随身护理", "painPoint": "出门临时干到难受"}, + {"dimension": "换测评", "title": "不黏手这点太加分了!", "scene": "手部质地测评", "painPoint": "摸键盘手机都怕黏"}, + {"dimension": "换痛点", "title": "换季手粗糙别硬扛", "scene": "换季护理", "painPoint": "洗完手紧绷粗糙"}, + {"dimension": "换选择理由", "title": "这支属于会推荐给同事", "scene": "办公室分享", "painPoint": "想要清爽又好用"}, + ], + "食品饮品": [ + {"dimension": "换场景", "title": "工位囤这个真的方便", "scene": "办公室饮用", "painPoint": "下午嘴馋又怕踩雷"}, + {"dimension": "换口感", "title": "第一口就知道没买错", "scene": "口感测评", "painPoint": "怕味道寡淡或太腻"}, + {"dimension": "换步骤", "title": "懒人冲泡也能很稳定", "scene": "快速准备", "painPoint": "想方便但不想牺牲口感"}, + {"dimension": "换囤货", "title": "这波囤在家里不心疼", "scene": "拆箱囤货", "painPoint": "高频喝/吃更看重性价比"}, + {"dimension": "换人群", "title": "打工人下午这口太需要了", "scene": "下午补给", "painPoint": "没精神但不想太复杂"}, + ], + } + return maps.get(category) or [ + {"dimension": "换人群", "title": f"{name}比想象中实用!", "scene": "真实使用", "painPoint": f"用户关心{p0}"}, + {"dimension": "换场景", "title": "这个场景下真的会用到", "scene": "日常场景", "painPoint": "买前不知道适不适合自己"}, + {"dimension": "换痛点", "title": "这个小问题终于被解决了", "scene": "痛点解决", "painPoint": "日常高频但容易被忽略的问题"}, + {"dimension": "换测评", "title": "细节近看才知道值不值", "scene": "细节测评", "painPoint": "怕宣传好看但实际一般"}, + {"dimension": "换转化", "title": "这波属于会推荐给朋友", "scene": "软性转化", "painPoint": "想要真实选择理由"}, + ] + + +_FALLBACK_OVERLAY = { + "hook": "这也太自然了", "pain_scene": "这个痛点太真实", + "applied_proof": "核心卖点看得见", "texture": "质地水润好推开", + "social_proof": "身边人都在问", "scenario": "出门随手带", + "tutorial": "三步快速出门", "closer": "这波真的会囤", + "product_closeup": "单品细节近看", "ingredient": "成分看得见", +} +_FALLBACK_TEXT = { + "applied_proof": "按当前品类生成核心证明页:用真实使用过程、细节近景、成分/口感/材质/质地等可感知证据证明卖点", + "texture": "展示产品质地、材质、口感、成分或使用细节,让用户看到卖点证据", + "closer": "拆箱、囤货角、通勤包或桌面场景,用省钱情报/暗号口吻做软性转化", +} + + +def build_fallback_image_plan(note: dict, image_count: int) -> list[dict]: + """LLM挂时按叙事角色兜底 imagePlan(对齐上线版 buildFallbackImagePlan)。""" + from app.services.ai_engine.storyboard import get_narrative_roles + existing = note.get("imagePlan") if isinstance(note.get("imagePlan"), list) else [] + plan = [] + for i, role in enumerate(get_narrative_roles(image_count)): + r = role.get("role", "") + ex = existing[i] if i < len(existing) else {} + plan.append({ + "role": r, + "title": sanitize_image_plan_text(ex.get("title") or role.get("name", ""), 12), + "overlayText": sanitize_image_plan_text( + ex.get("overlayText") or note.get("coverTitle") or note.get("title") + if r == "hook" else (ex.get("overlayText") or _FALLBACK_OVERLAY.get(r) or role.get("name", "")), 18), + "text": sanitize_image_plan_text( + ex.get("text") or _FALLBACK_TEXT.get(r) or role.get("focus", ""), 72), + }) + return plan + + +def build_fallback_notes( + source_note: dict, product: dict, note_count: int, image_count: int, +) -> list[dict]: + """LLM返回不可解析时的品类兜底完整草稿(对齐上线版 buildFallbackNotes)。""" + prod = product or {} + src = source_note or {} + name = prod.get("name", "") or "这款产品" + points = prod.get("selling_points", []) or ["使用方便", "真实可感知", "适合日常场景", "性价比高"] + audience = prod.get("target_audience", "") or "目标用户" + keywords = prod.get("keywords", []) or [] + category = infer_category(prod) + tags = normalize_tags( + src.get("tags", []), + keywords or [name, category, "真实测评", "好物分享"], + ) + angles = _fallback_angles(category, name, points) + kw = keywords or [x for x in [name, category, "真实测评", "好物分享"] if x] + out = [] + for i in range(note_count): + a = angles[i % len(angles)] + main = points[i % len(points)] + second = points[(i + 1) % len(points)] + title = a["title"] + note = { + "title": title, + "content": ( + f"姐妹们,这条先当真实测评草稿看。{name}主打{main},对{audience}来说," + f"最有用的不是堆参数,而是解决「{a['painPoint']}」这个真实场景。✅\n\n" + f"我会先看它在{a['scene']}里是不是真的方便,再看{second}有没有日常可感知的体验。✨ " + f"如果不是那种一眼硬广的表达,反而更像朋友顺手分享。\n\n" + f"如果你也在意{a['painPoint']},这类选择理由会更容易判断适不适合自己。🌿" + ), + "tags": tags, + "coverTitle": re.sub(r"[!!]", "", title), + "dimension": a["dimension"], + "audience": audience, + "scene": a["scene"], + "painPoint": a["painPoint"], + "keywords": kw, + } + note["imagePlan"] = build_fallback_image_plan(note, image_count) + out.append(note) + return out diff --git a/backend/app/services/ai_engine/fission_prompt.py b/backend/app/services/ai_engine/fission_prompt.py index 59e71c6..0756e89 100644 --- a/backend/app/services/ai_engine/fission_prompt.py +++ b/backend/app/services/ai_engine/fission_prompt.py @@ -1,43 +1,183 @@ """ -app/services/ai_engine/fission_prompt.py — 第11环裂变 三档参考度 prompt +app/services/ai_engine/fission_prompt.py — 第11环裂变 prompt(对齐上线版 split.js) -裂变=1爆款→N套完整笔记包。参考度档位控制"新套子贴原爆款多紧": - high=贴原爆款结构+卖点(最像) / mid=保卖点换叙事角度 / low=只借选题方向(最自由) +裂变=1爆款→一次LLM出N套完整笔记包。重写对齐产品包上线版 split.js: + - FISSION_SYSTEM 完整笔记包字段(含 dimension/audience/scene/painPoint/keywords/imagePlan) + - 参考度连续 30-85(reference_strategy_from_level),低≤40/高≥80/中 + - 违禁词清洗 sanitize_image_plan_text + - 品类兜底 build_fallback_notes / infer_category(LLM挂时不卡用户) -🔴 占位规则(倩倩姐2026-06-16:先移植打通再请北哥定档位)。 - 档位真实措辞待北哥定义后替换 _LEVEL_RULES,引擎链路不动。 +🔴 参考度入参保留 low/mid/high 三档(倩倩姐拍板),内部 _LEVEL_TO_INT 映射成数值。 +🔴 三档真实业务措辞待北哥定义;本次接口按数值做对。 +🔴 LLM 走 chat_complete(OpenAI兼容),FISSION_SYSTEM 作 messages[0].role=system 传。 """ +from __future__ import annotations -# 三档占位规则:注入文案引擎 flywheel_context,约束"参考原爆款多少" -_LEVEL_RULES = { - "high": ( - "【裂变参考度=高】请紧贴下面这篇原爆款的正文结构、开头钩子、卖点排布与情绪语气," - "近乎仿写——只替换表达措辞做到不查重,骨架与卖点角度都保留。" - ), - "mid": ( - "【裂变参考度=中】请保留下面这篇原爆款的核心卖点,但换一个全新的叙事角度/切入场景重写," - "结构可调整,让读者看不出是同一套路。" - ), - "low": ( - "【裂变参考度=低】请只借鉴下面这篇原爆款的选题方向与目标人群," - "文案结构、卖点呈现、叙事全部自由发挥,做出明显差异化的新笔记。" - ), -} +# 三档→数值映射(保留枚举入参,内部走连续值逻辑,对齐上线版 referenceStrategyFromLevel) +_LEVEL_TO_INT = {"low": 35, "mid": 60, "high": 82} -def build_fission_context(source_note: dict, reference_level: str) -> str: +def reference_strategy_from_level(level: str | int) -> dict: + """参考度策略(对齐上线版 referenceStrategyFromLevel)。 + + 入参支持枚举 low/mid/high 或数值;钳到 30-85。 + 返回 {level, level_label, prompt_rule, summary}。 """ - 返回注入 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}" + if isinstance(level, str): + value = _LEVEL_TO_INT.get(level, 60) + else: + try: + value = int(level) + except (TypeError, ValueError): + value = 60 + value = max(30, min(85, value)) + if value <= 40: + return { + "level": value, "level_label": "低参考", + "prompt_rule": "只参考爆款的内容结构和图文角色,不沿用原文表达、标题句式和具体画面。", + "summary": "参考爆款结构,不贴近原文表达。", + } + if value >= 80: + return { + "level": value, "level_label": "高参考", + "prompt_rule": "强参考爆款的标题节奏、痛点切入、图文递进和情绪强度,但必须替换人群、场景、表达和图片,不得抄袭。", + "summary": "强参考爆点、标题节奏和图文递进,但替换表达避免相似。", + } + return { + "level": value, "level_label": "中参考", + "prompt_rule": "参考爆款的结构、痛点表达方式和标题节奏,同时重写正文、标签和每张图片画面。", + "summary": "参考结构、痛点和标题节奏,输出新的完整笔记包。", + } def valid_level(level: str | None) -> str: - """校验档位,非法回落 mid。""" - return level if level in _LEVEL_RULES else "mid" + """校验三档枚举,非法回落 mid(保留旧接口兼容)。""" + return level if level in _LEVEL_TO_INT else "mid" + + +# 默认裂变维度(对齐上线版 dimensions) +DEFAULT_DIMENSIONS = ["换角度", "换痛点", "换人群"] + +FISSION_SYSTEM = """你是小红书完整图文笔记裂变专家。 + +你必须基于爆款参考生成"完整小红书笔记包",不是只生成文案,也不是只生成图片提示词。 + +完整笔记包必须包含:标题(可直接发布)、正文(180-260字种草口吻真实场景卖点转买点)、标签(5-8个)、点击钩子标题(第1张图大字)、imagePlan(每张图的图上文字+画面内容+排版)、dimension(裂变维度)、keywords、audience(适用人群)、scene(使用场景)、painPoint(切入痛点)。 + +裂变规则: +- 每套必须不重复,标题/正文/标签/图文结构都要变,图片重新配套不可一套图反复发 +- 保持种草安利+情绪共鸣风格 +- 正文自然出现2-5个小红书符号/emoji(✅✨🌿💧📦🔍🧡🥹‼️),放在痛点/实测/选择理由/软性转化处,不堆砌不每句塞 +- 标题可适度带符号,但不要所有标题同一种符号 +- 图片=可上传的独立3:4图文海报,不是App截图/笔记详情页截图 +- 图片禁止出现Like/评论/分享/底栏/头像/状态栏等社交App界面元素 +- 对比页只做质地/场景/肤感说明对比,禁用前后、before/after、变白、瑕疵消失、治疗前后 +- imagePlan只写短标题/短卖点/短画面要求,不塞长正文 +- 禁用词:美白、祛斑、速效、医用、药妆、变白、before、after、使用前后 +- 图文张数叙事:3张=点击→核心证明→软性转化;6张=点击→痛点→证明→质感→背书→软性转化;8张=点击→痛点→证明→质感→多场景→教程→背书→软性转化 +- 最后一张是软性转化,不做淘宝式硬广;用囤货/省钱情报/搜索暗示/评论暗号等原生分享口吻 + +返回纯JSON数组,每个元素含:title/content/tags(数组)/coverTitle/dimension/audience/scene/painPoint/keywords(数组)/imagePlan(数组,每项{role,title,overlayText,text})。 + +硬性格式要求: +- 只输出JSON,不要markdown代码块 +- 字符串内部不用英文双引号,引用词用中文书名号或中文引号 +- content是客户可直接发布的正文,不能写"配图建议/图片方向/imagePlan/内页规划"等内部提示 +- imagePlan数量必须等于用户要求的图片数量""" + + +import re + +# 违禁词清洗替换表(对齐上线版 sanitizeImagePlanText,有序应用) +_SANITIZE_RULES = [ + (re.compile(r"before\s*&\s*after", re.I), "质地与肤感说明"), + (re.compile(r"before\s*/?\s*after", re.I), "质地与肤感说明"), + (re.compile(r"\bbefore\b", re.I), "质地状态"), + (re.compile(r"\bafter\b", re.I), "上脸肤感"), + (re.compile(r"使用前后|用前用后|用前后|前后对比|使用前|使用后"), "质地/场景/肤感说明"), + (re.compile(r"功效对比|效果对比|改善对比"), "质地/场景说明对比"), + (re.compile(r"肤色变白|皮肤变白|变白|美白"), "自然光泽感"), + (re.compile(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑"), "妆感更服帖"), + (re.compile(r"治疗前后|治疗后|医美前后|治愈|修复受损"), "日常使用场景说明"), +] + + +def sanitize_image_plan_text(value: str = "", max_length: int = 56) -> str: + """清洗 imagePlan 文字里的违禁词(对齐上线版)。""" + text = str(value or "") + for pattern, repl in _SANITIZE_RULES: + text = pattern.sub(repl, text) + text = re.sub(r"\s+", " ", text).strip() + return text[:max_length] + + +def normalize_tags(tags=None, keywords=None) -> list[str]: + """标签归一化:补#前缀、去重、截8个(对齐上线版 normalizeTags)。""" + tags = tags or [] + keywords = keywords or [] + if not isinstance(tags, list): + tags = str(tags).split() + from_tags = [ + t if str(t).strip().startswith("#") else f"#{str(t).strip()}" + for t in tags if str(t).strip() + ] + from_kw = [ + k if str(k).startswith("#") else f"#{k}" + for k in list(keywords)[:5] if str(k).strip() + ] + seen, out = set(), [] + for t in from_tags + from_kw: + if t not in seen: + seen.add(t) + out.append(t) + return out[:8] + + +def build_fission_prompt( + source_note: dict, product: dict, reference_level: str, + note_count: int, image_count: int, dimensions: list[str] | None = None, +) -> str: + """组装裂变 user prompt(对齐上线版 handleContentSplit 的 prompt 变量拼装)。""" + src = source_note or {} + prod = product or {} + dims = dimensions or DEFAULT_DIMENSIONS + strategy = reference_strategy_from_level(reference_level) + title = src.get("title", "") + content = src.get("content") or src.get("text", "") + tags = src.get("tags", []) or [] + name = prod.get("name", "") or "未提供" + points = prod.get("selling_points", []) or [] + audience = prod.get("target_audience", "") or "未提供" + keywords = prod.get("keywords", []) or [] + kw_line = "、".join(keywords) if keywords else "、".join( + [x for x in [name, audience, "真实测评", "好物分享"] if x and x != "未提供"] + ) + return f"""爆款参考: +标题:{title} +正文:{content} +标签:{' '.join(tags)} + +产品:{name} +产品卖点:{'、'.join(points) or '未提供'} +目标人群:{audience} +关键词:{kw_line} +裂变维度:{'、'.join(dims)} +爆款参考度:{strategy['level_label']}。{strategy['prompt_rule']} +生成数量:{note_count}套 +每套图片数量:{image_count}张 + +请生成{note_count}套完整小红书图文笔记包。每套都必须含标题、正文、标签、点击钩子标题、{image_count}张图的imagePlan。 +imagePlan必须按叙事链路逐张递进。3张版第2张必须是按当前品类变化的核心证明页,不能和第1张重复构图,不得让第2张重复第1张标题。 +正文必须像真实小红书种草笔记一样自然带2-5个emoji;不要把图片规划/配图建议/内部审核建议写进正文。""" + + +def notes_array_from_parsed(parsed) -> list[dict]: + """从LLM解析结果里拎出笔记数组(对齐上线版 notesArrayFromParsed)。""" + if isinstance(parsed, list): + return parsed + if not isinstance(parsed, dict): + return [] + for key in ("notes", "variants", "data", "items", "result", "results"): + if isinstance(parsed.get(key), list): + return parsed[key] + return [parsed] if (parsed.get("title") or parsed.get("content")) else [] diff --git a/backend/app/services/fission_images.py b/backend/app/services/fission_images.py new file mode 100644 index 0000000..b4c3b15 --- /dev/null +++ b/backend/app/services/fission_images.py @@ -0,0 +1,81 @@ +""" +app/services/fission_images.py — 第11环裂变 逐套生图(从 fission_pipeline 拆出) + +复用单篇 generate_storyboard_images(codeproxy edits 带产品参考图),结果存 +FissionNote.images_json。拆分原因:fission_pipeline.py 超 200 行红线上限。 +🔴 生图引擎零改动;串行跑各套(套内 storyboard 已 asyncio.gather 并发)避免打爆中转站。 +""" +import logging + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def generate_fission_images( + db: Session, clients, ft, product: dict, image_count: int, note_ids: list[int], +) -> None: + """逐套生图,结果存 FissionNote.images_json。 + + 每套用该套自己的 note_json(含定制 imagePlan)当 note 喂生图引擎。 + 产品参考图沿用源产品同一张,与单篇一致。 + """ + import asyncio + import json as _json + import os + + from app.models.fission import FissionNote + from app.core.config import get_settings + from app.services.ai_engine.image_gen import generate_storyboard_images + from app.services.ai_engine.image_postprocessor import process_image + from app.workers.pipeline_io import _resolve_image_path + + reference_images = None + _img_path = _resolve_image_path(product.get("image_path", "")) + if _img_path and os.path.isfile(_img_path): + try: + with open(_img_path, "rb") as f: + reference_images = [f.read()] + except Exception as e: # noqa: BLE001 + logger.warning("裂变参考图读取失败,无参考图模式: %s", e) + + upload_base = get_settings().UPLOAD_ABS_ROOT + for nid in note_ids: + fn = db.query(FissionNote).filter(FissionNote.id == nid).first() + if not fn: + continue + try: + note = _json.loads(fn.note_json) if fn.note_json else {} + except (ValueError, TypeError): + note = {} + try: + results = asyncio.run(generate_storyboard_images( + client=clients, note=note, product=product, + image_count=image_count, reference_images=reference_images, + )) + except Exception as exc: # noqa: BLE001 + logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc) + fn.status = "failed"; db.commit() + continue + + img_dir = os.path.join(upload_base, str(ft.workspace_id), f"fission_{ft.id}", str(fn.seq)) + os.makedirs(img_dir, exist_ok=True) + images: list[dict] = [] + for i, r in enumerate(results): + if r.get("error") or not r.get("image_bytes"): + images.append({"role": r.get("role", ""), "error": str(r.get("error", "生图失败"))[:64]}) + continue + try: + processed = process_image(r["image_bytes"], aspect_ratio="3:4", resample_strength=1) + except Exception: # noqa: BLE001 + processed = r["image_bytes"] + fname = f"{i + 1:02d}_{r['role']}.jpg" + with open(os.path.join(img_dir, fname), "wb") as f: + f.write(processed) + images.append({ + "role": r["role"], "seq": i + 1, + "url": f"/uploads/{ft.workspace_id}/fission_{ft.id}/{fn.seq}/{fname}", + }) + fn.images_json = _json.dumps(images, ensure_ascii=False) + fn.status = "image_done" + db.commit() diff --git a/backend/app/services/fission_pipeline.py b/backend/app/services/fission_pipeline.py new file mode 100644 index 0000000..ecd7c07 --- /dev/null +++ b/backend/app/services/fission_pipeline.py @@ -0,0 +1,179 @@ +""" +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} diff --git a/backend/app/services/fission_service.py b/backend/app/services/fission_service.py index 8818611..bae6116 100644 --- a/backend/app/services/fission_service.py +++ b/backend/app/services/fission_service.py @@ -1,9 +1,10 @@ """ -app/services/fission_service.py — 第11环裂变 扇出 service +app/services/fission_service.py — 第11环裂变 service 入口(对齐上线版 split.js) -1爆款 → N套完整笔记包。每套=一个独立 GenerationTask(回填 source_fission_id), -复用主管道 run_generation_pipeline 生成文案+配图。 -🔴 占位档位规则见 fission_prompt.py,真实措辞待北哥定义(倩倩姐2026-06-16先打通)。 +裂变 = 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 @@ -15,11 +16,11 @@ 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 +from app.services.task_service import _check_user_has_key logger = logging.getLogger(__name__) -MAX_FANOUT = 5 # 每用户并发上限5(红线),裂变扇出数受同等约束 +MAX_FANOUT = 5 # 每用户并发上限5(红线);裂变 N 套直出受同等约束 def create_fission( @@ -27,15 +28,16 @@ def create_fission( source_task_id: int, reference_level: str, fanout_count: int, ) -> tuple[int, list[int]]: """ - 从一个源任务裂变出 N 套子任务。 - 返回 (fission_id, [子task_id,...])。 + 从一个源任务建裂变任务,丢 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, @@ -45,7 +47,7 @@ def create_fission( source_note = {"title": src.theme or "", "content": _extract_source_copy(db, src)} - # 建 fission_task 记录 + # 建 fission_task 记录(带 source_task_id 供 pipeline 取产品/张数) ft = FissionTask( workspace_id=current_user.workspace_id, source_note=json.dumps(source_note, ensure_ascii=False), @@ -53,27 +55,17 @@ def create_fission( ) 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) + # 丢 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 fanout=%s tasks=%s", - ft.id, level, n, task_ids) - return ft.id, task_ids + 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) diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 27331c7..5b968d6 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -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 dict(title/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 Session(Celery 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() diff --git a/backend/tests/test_fission_engine.py b/backend/tests/test_fission_engine.py new file mode 100644 index 0000000..10b54f4 --- /dev/null +++ b/backend/tests/test_fission_engine.py @@ -0,0 +1,197 @@ +""" +裂变引擎单元测试(第11环·一次LLM出N套架构) +覆盖:参考强度映射 / 标签归一 / imagePlan文字清洗 / prompt组装 / + 模型输出解析 N 套 / 品类兜底 build_fallback_notes / infer_category。 +纯函数为主,不连 DB/LLM(conftest 已 stub 环境)。 +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import json +import pytest +from app.services.ai_engine.fission_prompt import ( + reference_strategy_from_level, valid_level, normalize_tags, + sanitize_image_plan_text, build_fission_prompt, notes_array_from_parsed, +) +from app.services.ai_engine.fission_fallback import ( + infer_category, build_fallback_notes, build_fallback_image_plan, +) + +PRODUCT = { + "name": "倍分子素颜霜", + "category": "美妆护肤", + "selling_points": ["轻薄不厚重", "水润自然", "不卡粉"], + "keywords": ["素颜霜", "日常通勤"], + "style_tone": "素人分享风", + "brand_keyword": "倍分子", +} +SOURCE_NOTE = { + "title": "素颜霜真实测评", + "content": "用了两周,底子真的透,不卡粉,姐妹们冲。", + "tags": ["素颜霜", "护肤"], +} + + +# ── 参考强度映射 ──────────────────────────────────────────── +def test_reference_level_three_tiers_map_to_int(): + """low/mid/high 三档保留,内部映射数值(倩倩姐拍板)。""" + low = reference_strategy_from_level("low") + mid = reference_strategy_from_level("mid") + high = reference_strategy_from_level("high") + # 返回 {level, level_label, prompt_rule, summary};level 数值 low= 2 + + +# ── 集成:编排层 _parse_fission_response(mock LLM 固定 JSON)── +from app.services.fission_pipeline import _parse_fission_response + +_LLM_JSON = json.dumps([ + {"title": "套1标题", "content": "套1正文" * 30, "tags": ["素颜霜"], + "coverTitle": "钩子1", "dimension": "换角度", "keywords": ["通勤"], + "imagePlan": [{"role": "cover", "title": "t", "overlayText": "o", "text": "x"}, + {"role": "proof", "title": "t2", "overlayText": "o2", "text": "x2"}, + {"role": "cta", "title": "t3", "overlayText": "o3", "text": "x3"}]}, + {"title": "套2标题", "content": "套2正文" * 30, "tags": ["护肤"], + "coverTitle": "钩子2", "dimension": "换痛点", "keywords": ["熬夜"], + "imagePlan": [{"role": "cover"}, {"role": "proof"}, {"role": "cta"}]}, +], ensure_ascii=False) + + +def test_parse_response_returns_all_notes(): + notes = _parse_fission_response(_LLM_JSON, SOURCE_NOTE, PRODUCT, 2, 3) + assert len(notes) == 2 + assert notes[0]["title"] == "套1标题" + # tags 经归一化补 # + assert all(t.startswith("#") for t in notes[0]["tags"]) + + +def test_parse_response_corrects_imageplan_count(): + # imagePlan 数不等于 image_count 时,被兜底重建成正确张数 + notes = _parse_fission_response(_LLM_JSON, SOURCE_NOTE, PRODUCT, 2, 6) + for n in notes: + assert len(n["imagePlan"]) == 6 + + +def test_parse_response_markdown_wrapped(): + # 模型常把 JSON 包在 ```json ``` 里,需容错 + wrapped = f"```json\n{_LLM_JSON}\n```" + notes = _parse_fission_response(wrapped, SOURCE_NOTE, PRODUCT, 2, 3) + assert len(notes) == 2 + + +def test_parse_response_garbage_falls_back_not_crash(): + # 完全不可解析 → 品类兜底,绝不抛异常/不返回空 + notes = _parse_fission_response("彻底不是JSON的东西", SOURCE_NOTE, PRODUCT, 3, 6) + assert len(notes) == 3 + for n in notes: + assert len(n["imagePlan"]) == 6 + assert n["title"] diff --git a/backend/tests/test_fission_pipeline.py b/backend/tests/test_fission_pipeline.py new file mode 100644 index 0000000..2bd8752 --- /dev/null +++ b/backend/tests/test_fission_pipeline.py @@ -0,0 +1,82 @@ +""" +裂变编排层 _score_notes 测试(合格线@80 + 违禁词硬拦截逻辑) +mock llm_score_copy,验证 _passed = passed AND not _block 的安全关键规则。 +排序/降级落库逻辑(needs_optimization=0 if passed else 1)在 execute_fission_pipeline, +本文件只锁评分写回的 _score/_block/_passed 三字段语义。 +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import unittest.mock as mock +from app.services.fission_pipeline import _score_notes + +SOURCE = {"title": "源", "content": "源正文"} + + +def _patch_scorer(monkeypatch, results): + """按调用顺序返回 results 里的评分 dict。""" + calls = {"i": 0} + + async def fake(client, copy, source, banned=None, pass_score=80): + r = results[calls["i"] % len(results)] + calls["i"] += 1 + return r + + monkeypatch.setattr("app.services.ai_engine.llm_scorer.llm_score_copy", fake) + + +def test_score_high_no_banned_passes(monkeypatch): + _patch_scorer(monkeypatch, [ + {"score": 88, "passed": True, "banned_words_found": []}, + ]) + notes = [{"title": "a", "content": "c", "tags": []}] + _score_notes(mock.MagicMock(), notes, SOURCE, []) + assert notes[0]["_score"] == 88 + assert notes[0]["_block"] is False + assert notes[0]["_passed"] is True + + +def test_score_low_marks_not_passed(monkeypatch): + # 低于合格线 → 不达标,但分数照写(落库时标 needs_optimization 不丢弃) + _patch_scorer(monkeypatch, [ + {"score": 62, "passed": False, "banned_words_found": []}, + ]) + notes = [{"title": "a", "content": "c", "tags": []}] + _score_notes(mock.MagicMock(), notes, SOURCE, []) + assert notes[0]["_score"] == 62 + assert notes[0]["_passed"] is False + + +def test_score_banned_word_hard_blocks_even_if_passed(monkeypatch): + # 违禁词硬拦截:即便分数达标 passed=True,命中违禁词也强制 _passed=False + _patch_scorer(monkeypatch, [ + {"score": 95, "passed": True, "banned_words_found": ["美白"]}, + ]) + notes = [{"title": "美白神器", "content": "c", "tags": []}] + _score_notes(mock.MagicMock(), notes, SOURCE, ["美白"]) + assert notes[0]["_block"] is True + assert notes[0]["_passed"] is False + + +def test_score_exception_records_zero(monkeypatch): + # 评分接口抛异常 → 记0分不达标,不让整批裂变崩 + async def boom(*a, **k): + raise RuntimeError("中转站429") + monkeypatch.setattr("app.services.ai_engine.llm_scorer.llm_score_copy", boom) + notes = [{"title": "a", "content": "c", "tags": []}] + _score_notes(mock.MagicMock(), notes, SOURCE, []) + assert notes[0]["_score"] == 0 + assert notes[0]["_passed"] is False + + +def test_score_multiple_notes_each_scored(monkeypatch): + _patch_scorer(monkeypatch, [ + {"score": 90, "passed": True, "banned_words_found": []}, + {"score": 50, "passed": False, "banned_words_found": []}, + ]) + notes = [{"title": "a", "content": "c", "tags": []}, + {"title": "b", "content": "d", "tags": []}] + _score_notes(mock.MagicMock(), notes, SOURCE, []) + assert notes[0]["_passed"] is True + assert notes[1]["_passed"] is False diff --git a/backend/tests/test_integration_seams.py b/backend/tests/test_integration_seams.py index 0ef8570..d80fdca 100644 --- a/backend/tests/test_integration_seams.py +++ b/backend/tests/test_integration_seams.py @@ -31,6 +31,8 @@ class TestAiePipelineStepsContract: style_tone = "素人分享风" text_angles = '["痛点切入","场景型"]' custom_prompt = "" + brand_keyword = "倍分子" # S3: 品牌词随产品固定,植入文案+图片文字层 + target_audience = "" # 012: 人群透传进 storyboard/文案 prompt image_path = None # 前端图片上传完成前为空 return P() diff --git a/frontend/src/app/fission/[id]/notes/page.tsx b/frontend/src/app/fission/[id]/notes/page.tsx new file mode 100644 index 0000000..b69c00d --- /dev/null +++ b/frontend/src/app/fission/[id]/notes/page.tsx @@ -0,0 +1,112 @@ +'use client'; +/** + * /fission/[id]/notes — N 套裂变笔记包独立展示页(F2,新架构) + * 一次 LLM 出的 N 套完整笔记包:标题/正文/标签/封面钩子/imagePlan/生成图/分数。 + * 数据源 GET /api/v1/fission/{id}/notes(FissionNote 落库结果)。 + */ +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { api } from '@/lib/api'; +import { FissionNoteCard } from '@/components/fission/FissionNoteCard'; + +export interface FissionImagePlan { + role?: string; + title?: string; + overlayText?: string; + text?: string; +} +export interface FissionNoteData { + title?: string; + content?: string; + tags?: string[]; + coverTitle?: string; + dimension?: string; + audience?: string; + scene?: string; + painPoint?: string; + keywords?: string[]; + imagePlan?: FissionImagePlan[]; +} +export interface FissionImage { + role: string; + seq?: number; + url?: string; + error?: string; +} +export interface FissionNoteItem { + seq: number; + note: FissionNoteData; + images: FissionImage[]; + score: number | null; + passed: boolean; + needs_optimization: boolean; + dimension: string | null; + status: string; +} +interface NotesResponse { + fission_id: number; + status: string; + fanout_count: number; + notes: FissionNoteItem[]; +} + +export default function FissionNotesPage() { + const params = useParams(); + const fissionId = Number(params?.id); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + const fetchNotes = useCallback(async () => { + setError(null); + try { + const res = await api.get(`/api/v1/fission/${fissionId}/notes`); + setData(res); + } catch { + setError('加载裂变笔记包失败'); + } + }, [fissionId]); + + useEffect(() => { if (fissionId) fetchNotes(); }, [fissionId, fetchNotes]); + + return ( +
+
+
+

+ 裂变 #{fissionId} · {data?.fanout_count ?? ''} 套笔记包 +

+

+ 一个爆款裂变出的多套差异化完整笔记,可分发给不同达人。 +

+
+ + 返回裂变 + +
+ + {error && ( +
+ {error} + +
+ )} + + {!data && !error && ( +
加载笔记包…
+ )} + + {data && data.notes.length === 0 && ( +
+ 这批裂变还没有产出笔记,可能仍在生成或已失败。 +
+ )} + +
+ {data?.notes.map((item) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/app/fission/page.tsx b/frontend/src/app/fission/page.tsx new file mode 100644 index 0000000..f7c515d --- /dev/null +++ b/frontend/src/app/fission/page.tsx @@ -0,0 +1,76 @@ +'use client'; +/** + * /fission — 独立裂变页(第11环,倩倩姐2026-06-15拍板"新建独立裂变页") + * 选一个已通过/已归档的爆款源任务 → 设定参考强度+扇出套数 → 一键裂变成 N 套完整笔记包。 + * 后端:POST /api/v1/fission,GET /api/v1/fission/{id} 查聚合进度。 + */ +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { TaskListItem, PaginatedResponse } from '@/types'; +import { getErrorAction } from '@/types/errors'; +import { FissionLauncher } from '@/components/fission/FissionLauncher'; +import { FissionProgress } from '@/components/fission/FissionProgress'; + +// 裂变源 = 已验证的爆款,只从通过/归档里选 +const SOURCE_STATUSES = ['approved', 'archived']; + +export default function FissionPage() { + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeFissionId, setActiveFissionId] = useState(null); + + const fetchSources = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ page: '1', page_size: '50' }); + SOURCE_STATUSES.forEach((s) => params.append('status', s)); + const res = await api.get>(`/api/v1/tasks?${params}`); + setSources(res.items); + } catch (e: unknown) { + const ae = e as { code?: number }; + setError(getErrorAction(ae.code ?? 50001).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchSources(); }, [fetchSources]); + + return ( +
+
+

裂变扩散

+

+ 选一个验证过的爆款笔记,一键裂变成多套差异化的完整笔记包,交给不同达人分发。 +

+
+ + {error && ( +
+ {error} + +
+ )} + + {activeFissionId ? ( + { setActiveFissionId(null); fetchSources(); }} + /> + ) : loading ? ( +
加载可裂变的爆款…
+ ) : sources.length === 0 ? ( +
+ 还没有通过审核的笔记可裂变。先去开新任务并通过审核,再回来裂变。 +
+ ) : ( + setActiveFissionId(fissionId)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/fission/FissionLauncher.tsx b/frontend/src/components/fission/FissionLauncher.tsx new file mode 100644 index 0000000..d44f513 --- /dev/null +++ b/frontend/src/components/fission/FissionLauncher.tsx @@ -0,0 +1,112 @@ +'use client'; +/** + * FissionLauncher — 裂变发起面板 + * 选源爆款 + 参考强度(low/mid/high) + 扇出套数(1~5) → POST /fission,回调 fission_id。 + */ +import { useState } from 'react'; +import { api } from '@/lib/api'; +import { TaskListItem } from '@/types'; +import { getErrorAction } from '@/types/errors'; +import { clsx } from 'clsx'; + +interface Props { + sources: TaskListItem[]; + onLaunched: (fissionId: number) => void; +} + +const LEVELS: { key: string; label: string; desc: string }[] = [ + { key: 'low', label: '低', desc: '只借结构,文案视觉大改' }, + { key: 'mid', label: '中', desc: '保留核心卖点,角度换新' }, + { key: 'high', label: '高', desc: '贴近原作,仅做差异微调' }, +]; + +export function FissionLauncher({ sources, onLaunched }: Props) { + const [sourceId, setSourceId] = useState(sources[0]?.id ?? null); + const [level, setLevel] = useState('mid'); + const [fanout, setFanout] = useState(3); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function launch() { + if (!sourceId) { setError('请选择一个源爆款'); return; } + setSubmitting(true); + setError(null); + try { + const res = await api.post<{ fission_id: number }>('/api/v1/fission', { + source_task_id: sourceId, reference_level: level, fanout_count: fanout, + }); + onLaunched(res.fission_id); + } catch (e: unknown) { + const ae = e as { code?: number; message?: string }; + setError(ae.message || getErrorAction(ae.code ?? 50001).message); + } finally { + setSubmitting(false); + } + } + + return ( +
+ {/* 选源爆款 */} +
+ + +
+ + {/* 参考强度 */} +
+ +
+ {LEVELS.map((l) => ( + + ))} +
+
+ + {/* 扇出套数 */} +
+ + setFanout(Number(e.target.value))} + className="w-full accent-brand-orange" + /> +
+ + {error &&
{error}
} + + +
+ ); +} diff --git a/frontend/src/components/fission/FissionNoteCard.tsx b/frontend/src/components/fission/FissionNoteCard.tsx new file mode 100644 index 0000000..09b2c3c --- /dev/null +++ b/frontend/src/components/fission/FissionNoteCard.tsx @@ -0,0 +1,117 @@ +'use client'; +/** + * FissionNoteCard — 单套裂变笔记包卡片(F2 子组件) + * 展示:维度/分数徽章 + 标题 + 正文 + 标签 + 生成图 + imagePlan 排版说明。 + */ +import type { FissionNoteItem } from '@/app/fission/[id]/notes/page'; + +interface Props { + item: FissionNoteItem; +} + +// 图片 url 后端给 /uploads/... 相对路径,前端同源直接用 +function imgSrc(url?: string): string | null { + if (!url) return null; + return url.startsWith('/uploads/') ? url : `/uploads/${url.replace(/^.*\/uploads\//, '')}`; +} + +export function FissionNoteCard({ item }: Props) { + const { note, images, score, passed, needs_optimization, dimension, seq } = item; + const okImages = images.filter((im) => im.url && !im.error); + const failedImages = images.filter((im) => im.error); + + return ( +
+ {/* 头部:序号 + 维度 + 分数 */} +
+
+ 第 {seq} 套 + {(dimension || note.dimension) && ( + + {dimension || note.dimension} + + )} +
+
+ {score != null && ( + + {score} 分 + + )} + {needs_optimization && ( + + 建议优化 + + )} +
+
+ + {/* 标题 + 封面钩子 */} + {note.coverTitle && ( +

钩子:{note.coverTitle}

+ )} + {note.title && ( +

{note.title}

+ )} + + {/* 正文 */} + {note.content && ( +

+ {note.content} +

+ )} + + {/* 标签 */} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.map((t, i) => ( + #{t} + ))} +
+ )} + + {/* 生成图 */} + {okImages.length > 0 && ( +
+ {okImages.map((im) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {im.role} + {im.role} +
+ ))} +
+ )} + {failedImages.length > 0 && ( +

+ {failedImages.length} 张配图生成失败,可在交付前重试生图。 +

+ )} + + {/* imagePlan 排版说明(运营按此排版/叠字) */} + {note.imagePlan && note.imagePlan.length > 0 && ( +
+ + 图片排版说明({note.imagePlan.length} 张) + +
    + {note.imagePlan.map((p, i) => ( +
  1. + {p.role || `图${i + 1}`} + {p.title && <> · 标题:{p.title}} + {p.overlayText && <> · 叠字:{p.overlayText}} + {p.text && <> · 画面:{p.text}} +
  2. + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/fission/FissionProgress.tsx b/frontend/src/components/fission/FissionProgress.tsx new file mode 100644 index 0000000..6475d60 --- /dev/null +++ b/frontend/src/components/fission/FissionProgress.tsx @@ -0,0 +1,108 @@ +'use client'; +/** + * FissionProgress — 裂变进度(轮询 GET /fission/{id} 聚合 x/N 完成) + * 新架构:一次 LLM 出 N 套完整笔记包落 FissionNote,完成后进独立展示页看 N 套。 + */ +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { api } from '@/lib/api'; + +interface FissionStatus { + fission_id: number; + reference_level: string; + fanout_count: number; + status: 'generating' | 'completed' | 'failed'; + progress: { done: number; failed: number; total: number }; +} + +interface Props { + fissionId: number; + onReset: () => void; +} + +export function FissionProgress({ fissionId, onReset }: Props) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + const poll = useCallback(async () => { + try { + const res = await api.get(`/api/v1/fission/${fissionId}`); + setData(res); + return res.status; + } catch { + setError('查询裂变进度失败'); + return 'failed'; + } + }, [fissionId]); + + useEffect(() => { + let timer: ReturnType; + let stopped = false; + const tick = async () => { + const status = await poll(); + if (!stopped && status === 'generating') timer = setTimeout(tick, 3000); + }; + tick(); + return () => { stopped = true; clearTimeout(timer); }; + }, [poll]); + + if (error) { + return ( +
+ {error} + +
+ ); + } + + if (!data) return
启动裂变…
; + + const { progress, status } = data; + const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0; + + return ( +
+
+

+ 裂变 #{data.fission_id} · {data.fanout_count} 套 +

+ + {progress.done}/{progress.total || data.fanout_count} 完成 + {progress.failed > 0 && · {progress.failed} 失败} + +
+ +
+
+
+ + {status === 'generating' && ( +

正在一次性生成 {data.fanout_count} 套差异化笔记包,请稍候…

+ )} + {status === 'failed' && ( +
+ 这批裂变全部生成失败,可点下方「再裂变一个」重试,或换一个源爆款。 +
+ )} + {status === 'completed' && ( + + 查看 {data.fanout_count} 套笔记包 → + + )} + + +
+ ); +} diff --git a/plans/R-裂变重写核销表.md b/plans/R-裂变重写核销表.md new file mode 100644 index 0000000..5af554b --- /dev/null +++ b/plans/R-裂变重写核销表.md @@ -0,0 +1,66 @@ +# 裂变重写核销表(对齐上线版 split.js:一次出N套完整包) + +> 倩倩姐 2026-06 拍板:①架构=一次LLM出N套完整笔记包 ②N套存FissionNote表+独立展示页 ③参考度保留三档枚举,内部映射数值(low=35/mid=60/high=82)。 +> 断点可续;做完一项物理勾一项;报✅必附真实证据。 + +## 🔴 规则清单(开工前每人逐条确认,违反即返工) + +| # | 红线/约束 | 后果 | +|---|---|---| +| R1 | QUALITY_PASS_SCORE=80 任何地方不得改 | 返工 | +| R2 | 生图引擎零改动:不碰 image_gen.py/storyboard.py/pipeline_io.run_image_generation | 返工 | +| R3 | 模型最强档:文字 claude-opus-4-8,回落 gpt-5.5(codeproxy),绝不降 sonnet | 返工 | +| R4 | 新建文件目标≤100行上限200行;单次编辑≤100行(超先建框架再Edit) | — | +| R5 | 裂变三档真实措辞待北哥定义,本次用占位但接口按"内部数值"做对 | — | +| R6 | 差异化质量验收(北哥过目N套真差异)搁置,不在本次范围 | — | +| R7 | LLM 走 chat_complete(OpenAI兼容 messages[system,user]),不是上线版/v1/messages原生端点 | 风险1 | +| R8 | FISSION_SYSTEM 作 messages[0].role=system 传,不是顶级system字段 | 风险1 | +| R9 | llm_score_copy 是 async,Celery同步环境用 asyncio.run 不能直接await | 风险 | +| R10 | 参考度入参保留 low/mid/high,内部 _LEVEL_TO_INT 映射,DB列不动不加迁移 | 已拍板 | + +## 数据结构(已拍板) +- **新建 FissionNote 表**(018迁移):每套笔记一行,存 文案JSON+score+passed+imagePlan+images+status。 +- reference_level DB列保持 String(low/mid/high),**不改**。 +- FissionTask 状态机:pending→generating_text→text_done→generating_images→done/failed +- FissionNote 状态:pending→scored→image_done→failed + +## 文件改动清单与核销 + +### 后端 +| # | 文件 | 改什么 | 编辑轮次 | 状态 | +|---|------|--------|---------|------| +| B1 | models/fission.py | 追加 FissionNote class(~50行) | 1次 | ✅ 已加(103行,含idx) | +| B2 | alembic/versions/018_fission_notes_table.py | 新建迁移 create_table+index(~40行) | 新建 | ✅ 已建(rev018←017) | +| B3 | ai_engine/fission_prompt.py | 全量重写:_LEVEL_TO_INT+reference_strategy_from_level+FISSION_SYSTEM+build_fission_prompt+sanitize_image_plan_text+normalize_tags+notes_array_from_parsed | 4次 | ✅ 183行;品类兜底超200行拆至 fission_fallback.py(145行) | +| B4 | services/fission_service.py | 重写:create_fission改丢Celery+编排(一次LLM出N套→评分→排序→落FissionNote→喂生图) | 4次 | ✅ 超200行拆3文件:service入口76行/fission_pipeline.py编排179行/fission_images.py生图81行 | +| B5 | workers/tasks.py | 追加 run_fission_pipeline Celery task(~35行);删旧裂变扇出注入(build_fission_context已删) | 1次 | ✅ 已注册(celery inspect registered见run_fission_pipeline);实测task5 worker日志"run_fission_pipeline start: fission_id=5 src=76"真触发 | +| B6 | api/v1/fission.py | 进度改读FissionNote聚合+新增 GET /fission/{id}/notes | 2次 | ✅ 117行,py_compile过,清掉GenerationTask import,进度按FissionNote.status聚合,notes端点剥_score/_passed内部字段+workspace隔离 | + +### 前端 +| # | 文件 | 改什么 | 状态 | +|---|------|--------|------| +| F1 | components/fission/FissionProgress.tsx | 进度数据源改读FissionNote结构 | ✅ status对齐generating/completed/failed,去掉子任务入口,完成跳/fission/{id}/notes展示页 | +| F2 | app/fission/[id]/notes 展示页(或组件) | 新建N套笔记展示页(标题/正文/标签/封面/imagePlan/图) | ✅ page.tsx(116行框架+类型)+FissionNoteCard.tsx(分数徽章/文案/标签/图网格/imagePlan),tsc全过 | +| F3 | FissionLauncher.tsx | 三档保留,零改动(确认即可) | ✅ low/mid/high三档+POST契约(reference_level/fanout_count/回调fission_id)与新后端一致,零改动 | + +## 生图触发方案(已定) +方案一:裂变Celery任务内对每套FissionNote串行调 generate_storyboard_images(现有接口零改动),结果存 FissionNote.images_json。评分用 asyncio 并发限2(参考image并发模式)。 + +## 验证方案 +- TDD: tests/test_fission_engine.py — reference_strategy_from_level/sanitize/normalize_tags/build_fallback_notes/infer_category/评分每套独立@80 | ✅ 19 passed(197行) +- 集成: mock LLM固定JSON → parse N套→各评分→合格套排序→不合格降草稿不丢弃 | ✅ test_fission_engine(parse 4例:全套/imagePlan校正/markdown包裹/兜底不崩) + test_fission_pipeline.py(_score_notes 5例:合格线/违禁词硬拦/异常记0/多套) 全过 +- 端到端: POST /fission→状态done; 一次LLM拿N套完整字段; imagePlan数=image_count; score<80标needs_optimization非丢弃; 兜底不报500; 生图走现有接口 | ✅ **真实容器实测task5(fanout=2,mid,src=76)2026-06-18跑通**: POST返回fission_id=5→worker一次apiports/chat拿2套完整(title/content/tags/imagePlan)→seq0=85分passed/seq1=79分needs_optimization=1非丢弃(R1守住)→生图走codeproxy/v1/images/edits(R2现有接口零改动)→落FissionNote→task=completed→GET/fission/5/notes返2套完整数据内部字段(_score/_passed)零泄漏;图1024×1536(对齐大卫去AI化未强缩);apiports503→回落codeproxy gpt-5.5(R3铁律真生效,日志"强档兜底非降级");seq1 hook图codeproxy重试3次失败被捕获记error不阻塞不崩任务(健壮性正确) +- 交叉审: 另起独立agent重核, 不信第一遍 | ⏳ 进行中 +- 全量回归: tests/ 104 passed(顺手补 test_integration_seams mock product 缺 brand_keyword/target_audience 字段,非裂变引入的预存在失败) + +## 风险点 +1. FISSION_SYSTEM端点格式(chat_complete非/v1/messages) — 最易踩 +2. max_tokens按note_count缩放,实测超时加FISSION_MAX_TOKENS env +3. FissionNote前端展示(已定独立展示页F2) +4. build_fallback_notes品类映射表完整移植(~80行,单独一次编辑) +5. 评分并发vs串行(asyncio.gather限2) +6. FissionProgress进度数据源同步改(F1) + +## 搁置/待北哥(攒批) +- 裂变三档/参考度真实业务措辞 +- N套真差异化质量验收(北哥过目)