架构从"扇出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>
198 lines
7.9 KiB
Python
198 lines
7.9 KiB
Python
"""
|
||
裂变引擎单元测试(第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<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_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"]
|