""" 裂变引擎单元测试(第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"]