Files
beige/backend/tests/test_fission_engine.py
yangqianqian d85dcd401b 第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>
2026-06-18 11:17:37 +08:00

198 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
裂变引擎单元测试第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"]