第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

@@ -0,0 +1,197 @@
"""
裂变引擎单元测试第11环·一次LLM出N套架构
覆盖:参考强度映射 / 标签归一 / imagePlan文字清洗 / prompt组装 /
模型输出解析 N 套 / 品类兜底 build_fallback_notes / infer_category。
纯函数为主,不连 DB/LLMconftest 已 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<mid<high
assert low["level"] < mid["level"] < high["level"]
assert low["level_label"] == "低参考"
assert high["level_label"] == "高参考"
def test_valid_level_defaults_and_passthrough():
assert valid_level("low") == "low"
assert valid_level("mid") == "mid"
assert valid_level("high") == "high"
# 非法值兜底成 mid默认档
assert valid_level("garbage") == "mid"
assert valid_level(None) == "mid"
# ── 标签归一 ────────────────────────────────────────────────
def test_normalize_tags_dedupe_and_merge_keywords():
tags = normalize_tags(["素颜霜", "素颜霜", "护肤"], ["通勤", "护肤"])
# 归一化补 # 前缀
assert "#素颜霜" in tags
# 去重:素颜霜只出现一次
assert tags.count("#素颜霜") == 1
# keywords 合并进来
assert "#通勤" in tags
# 截断 8 个上限
assert len(tags) <= 8
def test_normalize_tags_handles_none_and_str():
assert normalize_tags(None, None) == []
assert isinstance(normalize_tags("单串 标签", None), list)
# ── imagePlan 文字清洗 ──────────────────────────────────────
def test_sanitize_image_plan_text_truncates():
long = "美白祛斑特效" * 20
out = sanitize_image_plan_text(long, max_length=56)
assert len(out) <= 56
def test_sanitize_image_plan_text_empty():
assert sanitize_image_plan_text("") == ""
# ── prompt 组装 ─────────────────────────────────────────────
def test_build_fission_prompt_includes_counts_and_strategy():
prompt = build_fission_prompt(SOURCE_NOTE, PRODUCT, "high", note_count=3, image_count=6)
assert "3套" in prompt
assert "6张" in prompt
# 高参考策略文案带入
assert "高参考" in prompt
# 源爆款标题带入
assert "素颜霜真实测评" in prompt
# ── 模型输出解析 N 套 ───────────────────────────────────────
def test_notes_array_from_list():
parsed = [{"title": "a"}, {"title": "b"}]
assert len(notes_array_from_parsed(parsed)) == 2
def test_notes_array_from_wrapped_dict():
# 模型可能把数组包在 notes/variants 等键下
assert len(notes_array_from_parsed({"notes": [{"title": "x"}]})) == 1
assert len(notes_array_from_parsed({"variants": [{"title": "x"}, {"title": "y"}]})) == 2
def test_notes_array_from_single_note_dict():
# 单套笔记直接当对象返回
assert len(notes_array_from_parsed({"title": "单套", "content": "正文"})) == 1
def test_notes_array_from_garbage():
assert notes_array_from_parsed("not json") == []
assert notes_array_from_parsed({}) == []
# ── 品类推断 ────────────────────────────────────────────────
def test_infer_category_returns_str():
cat = infer_category(PRODUCT)
assert isinstance(cat, str) and cat
# ── 品类兜底完整草稿 ────────────────────────────────────────
def test_build_fallback_notes_count_and_fields():
notes = build_fallback_notes(SOURCE_NOTE, PRODUCT, note_count=3, image_count=6)
assert len(notes) == 3
for n in notes:
# 完整笔记包字段齐全
for field in ("title", "content", "tags", "coverTitle", "dimension",
"audience", "scene", "painPoint", "keywords", "imagePlan"):
assert field in n
# imagePlan 数量必须等于 image_count
assert len(n["imagePlan"]) == 6
def test_build_fallback_image_plan_matches_count():
note = {"title": "t", "content": "c"}
for count in (3, 6, 8):
plan = build_fallback_image_plan(note, count)
assert len(plan) == count
for p in plan:
assert "role" in p
def test_build_fallback_notes_dedupes_titles():
# 多套兜底标题不应全部雷同(裂变要差异化)
notes = build_fallback_notes(SOURCE_NOTE, PRODUCT, note_count=3, image_count=3)
titles = [n["title"] for n in notes]
assert len(set(titles)) >= 2
# ── 集成:编排层 _parse_fission_responsemock 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"]