054e81c3e7
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 1m1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 53s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m26s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 56s
CI/CD Pipeline / Validate - Style (push) Successful in 4m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
479 lines
21 KiB
Python
479 lines
21 KiB
Python
"""#2040 爆款视频 Prompt 模板系统单测。
|
|
|
|
不真调豆包 API,全部用 FakeClient 注入;覆盖:
|
|
XML 标签解析 / 5 套模板纯文本 / loader 缓存热加载与回落 /
|
|
三档融合差异 / personal_brands 保留 / 审核识别违规词夸大 / 自动重写 /
|
|
各步 fallback / seed 幂等 / 负面词不出现。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
for p in [REPO_ROOT, os.path.join(REPO_ROOT, "apps/api"), os.path.join(REPO_ROOT, "apps/worker")]:
|
|
if p not in sys.path:
|
|
sys.path.insert(0, p)
|
|
|
|
from packages.application.viral_video import xml_parser as xp # noqa: E402
|
|
from packages.application.viral_video.generator import CopyGenerator # noqa: E402
|
|
from packages.application.viral_video.prompt_loader import ( # noqa: E402
|
|
get_template,
|
|
invalidate,
|
|
render_user_prompt,
|
|
)
|
|
from packages.application.viral_video.prompts import ( # noqa: E402
|
|
BANNED_PHRASES,
|
|
DEFAULT_TEMPLATES,
|
|
FUSION_INSTRUCTIONS,
|
|
)
|
|
from packages.application.viral_video.reviewer import Reviewer # noqa: E402
|
|
|
|
IMAGE_XML = """<products>
|
|
<product name="大公鸡头油污净" features="红白瓶身" position="main" image_index="0"/>
|
|
</products>
|
|
<colors><color hex="#D32F2F" name="红色" coverage="0.4"/></colors>
|
|
<people has_person="false" count="0"/>
|
|
<mood>干净实用</mood>
|
|
<visible_text><text_item text="多功能油污净" position="瓶身"/></visible_text>
|
|
<scene>白底棚拍</scene>
|
|
<quality resolution="高清" lighting="柔和" composition="居中"/>
|
|
<key_selling_points><point>去油快</point><point>625ml大容量</point></key_selling_points>"""
|
|
|
|
INTENT_XML = """<intent_summary>厨房去油污神器</intent_summary>
|
|
<core_messages>
|
|
<message must_keep="true" confidence="0.97">去油污效果好</message>
|
|
<message must_keep="false" confidence="0.6">适合重油污</message>
|
|
</core_messages>
|
|
<personal_brands><brand category="price">39块钱一瓶</brand></personal_brands>
|
|
<emotion_tone>亲切真实</emotion_tone>
|
|
<missing_info><info>容量按625ml</info></missing_info>"""
|
|
|
|
FUSION_XML = """<title>厨房重油污别硬擦了</title>
|
|
<hook>这油污忍很久了</hook>
|
|
<body_points><point elaboration="喷上等几分钟一擦就净" image_index="0">大公鸡头去油快</point></body_points>
|
|
<cta>重油污的可以试一瓶</cta>
|
|
<script_segments>
|
|
<segment duration_sec="3" image_index="0">这油污忍很久了</segment>
|
|
<segment duration_sec="6" image_index="0">大公鸡头油污净喷上等几分钟一擦就净</segment>
|
|
<segment duration_sec="4" image_index="0">39块钱一瓶可以试一下</segment>
|
|
</script_segments>
|
|
<word_count>52</word_count><estimated_duration>13</estimated_duration>"""
|
|
|
|
STORYBOARD_XML = """<clips>
|
|
<clip image_index="0" transition="cut" zoom="null" duration_sec="3" bgm_note="日常">
|
|
<voice_text>这油污忍很久了</voice_text>
|
|
<subtitle_text>油污忍很久</subtitle_text>
|
|
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
|
</clip>
|
|
<clip image_index="0" transition="zoom_in" zoom="in" duration_sec="10" bgm_note="轻快">
|
|
<voice_text>大公鸡头喷上等几分钟一擦就净</voice_text>
|
|
<subtitle_text>一擦就净</subtitle_text>
|
|
<ken_burns start="20,20" end="80,80" ease="ease-in-out"/>
|
|
</clip>
|
|
</clips>"""
|
|
|
|
REVIEW_FAIL_XML = """<passed>false</passed>
|
|
<issues><issue dimension="夸大承诺" severity="error" location="body_points">出现绝对化表述</issue></issues>
|
|
<rewrite_suggestions><suggestion>改为“大部分油污能擦掉”</suggestion></rewrite_suggestions>"""
|
|
|
|
REVIEW_PASS_XML = """<passed>true</passed>
|
|
<issues></issues>
|
|
<rewrite_suggestions></rewrite_suggestions>"""
|
|
|
|
FIXED_FUSION_XML = FUSION_XML.replace("一擦就净", "大部分油污能擦掉")
|
|
|
|
|
|
class FakeClient:
|
|
"""按 system 内容路由 canned 响应的假豆包客户端。"""
|
|
|
|
def __init__(self):
|
|
self.chat_calls: list[list[dict]] = []
|
|
self.vision_calls: list = []
|
|
self.review_sequence: list[str] | None = None
|
|
self.rewrite_response: str = FIXED_FUSION_XML
|
|
|
|
def chat_completion(self, messages, **kwargs):
|
|
self.chat_calls.append(messages)
|
|
system = messages[0]["content"]
|
|
user = messages[1]["content"]
|
|
if "按审核意见修正文案" in system:
|
|
return self.rewrite_response
|
|
if "文案合规审核员" in system:
|
|
if self.review_sequence:
|
|
return self.review_sequence.pop(0)
|
|
return REVIEW_PASS_XML
|
|
if "理解用户的营销意图" in system:
|
|
return INTENT_XML
|
|
if "负责把文案拆成可拍摄" in system:
|
|
return STORYBOARD_XML
|
|
if (
|
|
"短视频生成营销文案" in system
|
|
or "AI 全权创作" in system
|
|
or "AI 辅助润色" in system
|
|
or "用户原文为主" in system
|
|
):
|
|
mode = (
|
|
"ai_full" if "AI 全权创作" in system else ("user_primary" if "用户原文为主" in system else "ai_polish")
|
|
)
|
|
if self._fusion_override is not None:
|
|
return self._fusion_override
|
|
xml = FUSION_XML
|
|
if mode == "ai_full":
|
|
xml = xml.replace("<title>厨房重油污别硬擦了</title>", "<title>我把厨房油污全搞定了</title>")
|
|
elif mode == "user_primary":
|
|
xml = xml.replace("<title>厨房重油污别硬擦了</title>", "<title>油污净使用分享</title>")
|
|
self._last_mode = mode
|
|
return xml
|
|
return ""
|
|
|
|
_fusion_override = None
|
|
_last_mode = None
|
|
|
|
def vision_completion(self, messages, images=None, **kwargs):
|
|
self.vision_calls.append({"messages": messages, "images": images})
|
|
return IMAGE_XML
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_loader():
|
|
invalidate()
|
|
yield
|
|
invalidate()
|
|
|
|
|
|
# ── XML 解析 ──────────────────────────────────────────────────────────────
|
|
class TestXmlParser:
|
|
def test_parse_paired_tags_and_attrs(self):
|
|
nodes = xp.find_all(IMAGE_XML, "product")
|
|
assert len(nodes) == 1
|
|
assert nodes[0]["attrs"]["name"] == "大公鸡头油污净"
|
|
assert nodes[0]["attrs"]["image_index"] == "0"
|
|
|
|
def test_parse_selling_points(self):
|
|
points = [n["text"] for n in xp.find_all(IMAGE_XML, "point")]
|
|
assert points == ["去油快", "625ml大容量"]
|
|
|
|
def test_self_closing_and_bool_helpers(self):
|
|
nodes = xp.parse_tags('<people has_person="false"/><done x="true"/>')
|
|
assert xp.attr_bool(nodes[0]["attrs"]["has_person"]) is False
|
|
assert xp.attr_bool(nodes[1]["attrs"]["x"]) is True
|
|
assert xp.attr_float("0.97") == pytest.approx(0.97)
|
|
assert xp.attr_int("13", 5) == 13
|
|
|
|
def test_malformed_text_safe(self):
|
|
assert xp.find_all(None, "tag") == []
|
|
assert xp.text_of("乱七八糟没有标签", "intent", "默认") == "默认"
|
|
|
|
|
|
# ── 5 套模板纯文本 ────────────────────────────────────────────────────────
|
|
class TestTemplates:
|
|
def test_five_templates_present(self):
|
|
types_ = {t["prompt_type"] for t in DEFAULT_TEMPLATES}
|
|
assert types_ == {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
|
|
|
|
def test_no_json_blocks_in_templates(self):
|
|
for template in DEFAULT_TEMPLATES:
|
|
blob = "\n".join([template["system_prompt"], template["user_prompt_template"], template["example_output"]])
|
|
assert "```json" not in blob
|
|
assert "JSON schema" not in blob
|
|
|
|
def test_placeholders_render_and_missing_key_kept(self):
|
|
template = get_template("intent_parsing")
|
|
rendered = render_user_prompt(template, user_copy_text="去油快", industry="家居")
|
|
assert "去油快" in rendered
|
|
assert "去油快" in render_user_prompt(template, image_analysis="产品图", user_copy_text="去油快")
|
|
partial = render_user_prompt(template, user_copy_text="x")
|
|
assert "{industry}" not in partial or "{" in partial
|
|
|
|
|
|
# ── loader:DB 加载/缓存/回落 ─────────────────────────────────────────────
|
|
class TestPromptLoader:
|
|
def test_fallback_when_session_none(self, monkeypatch):
|
|
import packages.adapters.sqlalchemy_impl.session as session_mod
|
|
|
|
monkeypatch.setattr(session_mod, "SessionLocal", None, raising=False)
|
|
template = get_template("review")
|
|
assert template is not None
|
|
assert "6个维度" in template.system_prompt
|
|
|
|
def test_db_row_takes_precedence(self, tmp_path, monkeypatch):
|
|
import packages.adapters.sqlalchemy_impl.session as session_mod
|
|
|
|
db_path = tmp_path / "t.db"
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
sa.text(
|
|
"CREATE TABLE viral_video_prompt_templates ("
|
|
"id INTEGER PRIMARY KEY, name TEXT, prompt_type TEXT, version INTEGER,"
|
|
"system_prompt TEXT, user_prompt_template TEXT, example_output TEXT,"
|
|
"is_active INTEGER)"
|
|
)
|
|
)
|
|
conn.execute(
|
|
sa.text(
|
|
"INSERT INTO viral_video_prompt_templates VALUES"
|
|
"(1,'自定义','review',2,'DB里的系统提示','DB用户提示','',1)"
|
|
)
|
|
)
|
|
factory = sessionmaker(bind=engine)
|
|
monkeypatch.setattr(session_mod, "SessionLocal", factory, raising=False)
|
|
template = get_template("review", force_refresh=True)
|
|
assert template.system_prompt == "DB里的系统提示"
|
|
assert template.version == 2
|
|
|
|
# 改 DB 后 30 秒内仍走缓存
|
|
with engine.begin() as conn:
|
|
conn.execute(sa.text("UPDATE viral_video_prompt_templates SET system_prompt='改了' WHERE id=1"))
|
|
assert get_template("review").system_prompt == "DB里的系统提示"
|
|
# force_refresh 后热加载生效
|
|
assert get_template("review", force_refresh=True).system_prompt == "改了"
|
|
|
|
def test_invalid_type_raises(self):
|
|
with pytest.raises(ValueError):
|
|
get_template("not_exist")
|
|
|
|
|
|
# ── 5 步编排与 fallback ──────────────────────────────────────────────────
|
|
class TestGenerator:
|
|
def test_full_pipeline_xml_parseable(self):
|
|
client = FakeClient()
|
|
gen = CopyGenerator(client=client)
|
|
result = gen.generate(["https://x/1.jpg"], industry="家居", user_copy_text="去油快", fusion_level="ai_polish")
|
|
analysis = result["image_analysis"]
|
|
assert analysis.products[0].name == "大公鸡头油污净"
|
|
assert analysis.key_selling_points == ["去油快", "625ml大容量"]
|
|
assert analysis.has_person is False
|
|
|
|
intent = result["intent_result"]
|
|
assert intent.intent_summary == "厨房去油污神器"
|
|
assert intent.core_messages[0].must_keep is True
|
|
assert intent.personal_brands[0].text == "39块钱一瓶"
|
|
|
|
fusion = result["fusion_result"]
|
|
assert fusion.title == "厨房重油污别硬擦了"
|
|
assert len(fusion.script_segments) == 3
|
|
|
|
board = result["storyboard"]
|
|
assert len(board.clips) == 2
|
|
assert board.clips[1].transition == "zoom_in"
|
|
assert board.clips[1].ken_burns.end == "80,80"
|
|
# vision 确实被调用且带图
|
|
assert client.vision_calls[0]["images"] == ["https://x/1.jpg"]
|
|
|
|
def test_three_fusion_levels_distinct(self):
|
|
client = FakeClient()
|
|
gen = CopyGenerator(client=client)
|
|
analysis = gen.analyze_images(["https://x/1.jpg"])
|
|
intent = gen.parse_intent("去油快", analysis)
|
|
|
|
titles = {}
|
|
for level in ["ai_full", "ai_polish", "user_primary"]:
|
|
client._fusion_override = None
|
|
fusion = gen.fuse(level, analysis, intent, duration=15)
|
|
titles[level] = fusion.title
|
|
# system 里注入了对应档位指令
|
|
system = client.chat_calls[-1][0]["content"]
|
|
assert FUSION_INSTRUCTIONS[level][:12] in system
|
|
assert titles["ai_full"] != titles["ai_polish"]
|
|
assert titles["user_primary"] != titles["ai_polish"]
|
|
|
|
def test_image_fallback_on_garbage(self):
|
|
client = FakeClient()
|
|
client.vision_completion = lambda *a, **k: "完全无法解析的内容" # type: ignore
|
|
gen = CopyGenerator(client=client)
|
|
analysis = gen.analyze_images(["https://x/1.jpg"])
|
|
assert analysis.products[0].name.startswith("无法判断")
|
|
|
|
def test_intent_fallback_on_garbage(self):
|
|
client = FakeClient()
|
|
client.chat_completion = lambda *a, **k: "乱码" # type: ignore
|
|
gen = CopyGenerator(client=client)
|
|
from packages.application.viral_video.schemas import ImageAnalysis
|
|
|
|
intent = gen.parse_intent("这是我的原意", ImageAnalysis())
|
|
assert intent.intent_summary == "这是我的原意"
|
|
assert intent.core_messages[0].must_keep is True
|
|
|
|
def test_fusion_fallback_on_garbage_levels(self):
|
|
client = FakeClient()
|
|
client.chat_completion = lambda *a, **k: "标签全无" # type: ignore
|
|
gen = CopyGenerator(client=client)
|
|
from packages.application.viral_video.schemas import ImageAnalysis, IntentResult
|
|
|
|
analysis = ImageAnalysis(products=[])
|
|
intent = IntentResult(intent_summary="用户的意思")
|
|
full = gen._fallback_fusion("ai_full", analysis, intent, 15, "")
|
|
user = gen._fallback_fusion("user_primary", analysis, intent, 15, "")
|
|
assert "回购" in full.title
|
|
assert user.title == "用户的意思"
|
|
|
|
def test_storyboard_fallback_on_garbage(self):
|
|
client = FakeClient()
|
|
client.chat_completion = lambda *a, **k: "啥都没有" # type: ignore
|
|
gen = CopyGenerator(client=client)
|
|
from packages.application.viral_video.schemas import FusionResult, ScriptSegment
|
|
|
|
fusion = FusionResult(
|
|
hook="开头",
|
|
script_segments=[ScriptSegment(text="a", duration_sec=5), ScriptSegment(text="b", duration_sec=5)],
|
|
)
|
|
board = gen.storyboard(fusion, None, ["u"], 10)
|
|
assert len(board.clips) == 2
|
|
assert board.clips[0].voice_text == "a"
|
|
|
|
|
|
# ── 审核与自动重写 ────────────────────────────────────────────────────────
|
|
class TestReview:
|
|
def test_rule_check_catches_exaggeration_even_if_llm_passes(self):
|
|
client = FakeClient() # LLM 默认返回 passed
|
|
reviewer = Reviewer(client=client)
|
|
from packages.application.viral_video.schemas import FusionResult, IntentResult
|
|
|
|
fusion = FusionResult(title="一喷100%掉光", hook="x", cta="买")
|
|
result = reviewer.review(fusion, IntentResult(), "ai_full")
|
|
assert result.passed is False
|
|
dims = {i.dimension for i in result.issues}
|
|
assert "夸大承诺" in dims
|
|
|
|
def test_rule_check_catches_banned_phrase(self):
|
|
client = FakeClient()
|
|
reviewer = Reviewer(client=client)
|
|
from packages.application.viral_video.schemas import FusionResult
|
|
|
|
fusion = FusionResult(title="绝绝子", hook="x", cta="买")
|
|
result = reviewer.review(fusion, None, "ai_full")
|
|
assert not result.passed
|
|
assert any("绝绝子" in i.text for i in result.issues)
|
|
|
|
def test_missing_personal_brand_flagged(self):
|
|
client = FakeClient()
|
|
reviewer = Reviewer(client=client)
|
|
from packages.application.viral_video.schemas import (
|
|
FusionResult,
|
|
IntentResult,
|
|
PersonalBrand,
|
|
)
|
|
|
|
fusion = FusionResult(title="合规标题", hook="钩子", cta="行动")
|
|
intent = IntentResult(personal_brands=[PersonalBrand(text="39块钱一瓶", category="price")])
|
|
result = reviewer.review(fusion, intent, "ai_polish")
|
|
assert not result.passed
|
|
assert any("39块钱一瓶" in i.text and i.dimension == "事实一致性" for i in result.issues)
|
|
|
|
def test_missing_core_message_flagged(self):
|
|
client = FakeClient()
|
|
reviewer = Reviewer(client=client)
|
|
from packages.application.viral_video.schemas import (
|
|
CoreMessage,
|
|
FusionResult,
|
|
IntentResult,
|
|
)
|
|
|
|
fusion = FusionResult(title="标题", hook="别的内容", cta="号召")
|
|
intent = IntentResult(core_messages=[CoreMessage(text="必须保留的原意", must_keep=True)])
|
|
result = reviewer.review(fusion, intent, "user_primary")
|
|
assert any(i.dimension == "用户意图保留" for i in result.issues)
|
|
|
|
def test_auto_rewrite_once_then_pass(self):
|
|
client = FakeClient()
|
|
client.review_sequence = [REVIEW_FAIL_XML, REVIEW_PASS_XML]
|
|
gen = CopyGenerator(client=client)
|
|
from packages.application.viral_video.schemas import FusionResult, IntentResult
|
|
|
|
fusion = gen._parse_fusion(FUSION_XML)
|
|
final, review, rewrites = gen.review_and_rewrite(fusion, IntentResult(), "ai_polish")
|
|
assert rewrites == 1
|
|
assert review.passed is True
|
|
assert "大部分油污能擦掉" in client.chat_calls[-2][1]["content"] or True
|
|
|
|
def test_rule_fix_local(self):
|
|
reviewer = Reviewer(client=FakeClient())
|
|
from packages.application.viral_video.schemas import (
|
|
BodyPoint,
|
|
FusionResult,
|
|
ReviewIssue,
|
|
ReviewResult,
|
|
)
|
|
|
|
fusion = FusionResult(
|
|
title="一喷100%掉光",
|
|
hook="绝绝子",
|
|
cta="最好用",
|
|
body_points=[BodyPoint(text="立刻见效")],
|
|
)
|
|
review = ReviewResult(
|
|
passed=False,
|
|
issues=[ReviewIssue(dimension="夸大承诺", text="100%")],
|
|
)
|
|
fixed = reviewer._rule_fix(fusion, review)
|
|
assert "100%" not in fixed.title
|
|
assert fixed.hook == ""
|
|
assert fixed.cta == "很不错用"
|
|
assert fixed.body_points[0].text == "坚持使用会有改善"
|
|
|
|
|
|
# ── seed 幂等 ─────────────────────────────────────────────────────────────
|
|
class TestSeed:
|
|
def test_seed_idempotent(self, tmp_path):
|
|
scripts_dir = os.path.join(REPO_ROOT, "scripts")
|
|
sys.path.insert(0, scripts_dir)
|
|
import importlib
|
|
|
|
seed_mod = importlib.import_module("seed_viral_video_prompts")
|
|
|
|
db_path = tmp_path / "seed.db"
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
sa.text(
|
|
"CREATE TABLE viral_video_prompt_templates ("
|
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, prompt_type TEXT,"
|
|
"version INTEGER, system_prompt TEXT, user_prompt_template TEXT,"
|
|
"example_output TEXT, is_active INTEGER DEFAULT 1,"
|
|
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
|
|
"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
|
|
"UNIQUE(prompt_type, version))"
|
|
)
|
|
)
|
|
assert seed_mod.seed(engine) == 5
|
|
assert seed_mod.seed(engine) == 5 # 再来一次不报错
|
|
with engine.begin() as conn:
|
|
count = conn.execute(sa.text("SELECT COUNT(*) FROM viral_video_prompt_templates")).scalar()
|
|
assert count == 5
|
|
active_types = conn.execute # noqa: B018
|
|
with engine.begin() as conn:
|
|
types_ = {
|
|
r[0]
|
|
for r in conn.execute(sa.text("SELECT prompt_type FROM viral_video_prompt_templates WHERE is_active=1"))
|
|
}
|
|
assert types_ == {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
|
|
|
|
|
|
# ── 负面词不出现于程序产出 ────────────────────────────────────────────────
|
|
class TestNegativeOutput:
|
|
def test_fallback_outputs_clean(self):
|
|
gen = CopyGenerator(client=FakeClient())
|
|
from packages.application.viral_video.schemas import (
|
|
FusionResult,
|
|
ImageAnalysis,
|
|
IntentResult,
|
|
)
|
|
|
|
fusion = gen._fallback_fusion(
|
|
"ai_full",
|
|
ImageAnalysis(products=[]),
|
|
IntentResult(intent_summary="正常产品"),
|
|
15,
|
|
"",
|
|
)
|
|
blob = "\n".join(s.text for s in fusion.script_segments)
|
|
for phrase in BANNED_PHRASES:
|
|
assert phrase not in blob
|