将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
12 KiB
Python
254 lines
12 KiB
Python
"""
|
||
AI 引擎单元测试(核心逻辑覆盖)
|
||
对照 JS 版逻辑验证 Python 重写正确性
|
||
"""
|
||
import sys
|
||
import os
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
|
||
import pytest
|
||
from app.services.ai_engine.text_scoring import score_copy, is_similar_copy, dedupe_copies
|
||
from app.services.ai_engine.banned_word_checker import check_and_fix, BannedWordEntry
|
||
from app.services.ai_engine.storyboard import get_narrative_roles, plan_image_set, clamp_count
|
||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context, collect_preference_event
|
||
from app.services.ai_engine.prompt_composer import (
|
||
compose_variants, compose_preference_context, compose_image_prompt, parse_model_output
|
||
)
|
||
|
||
# ── 测试用 product ─────────────────────────────────────────
|
||
PRODUCT = {
|
||
"name": "倍分子素颜霜",
|
||
"category": "美妆护肤",
|
||
"selling_points": ["轻薄不厚重", "水润自然", "不卡粉"],
|
||
"keywords": ["素颜霜", "日常通勤"],
|
||
"style_tone": "素人分享风",
|
||
"text_angles": ["痛点切入", "场景型", "避坑型"],
|
||
"custom_prompt": "",
|
||
"target_audience": "上班族",
|
||
}
|
||
|
||
GOOD_COPY = {
|
||
"title": "早八素颜也能有点气色!✨",
|
||
"content": "姐妹们,最近实测下来,轻薄不厚重是我最在意的。✅ 通勤路上随手一抹,水润自然有气色。不卡粉这点太加分了。整体就是那种上班族能马上代入的日常好物。",
|
||
"tags": ["#素颜霜", "#日常通勤", "#好物分享", "#真实测评"],
|
||
"angle": "场景型",
|
||
"buyingPoint": "轻薄不厚重,适合上班族",
|
||
"coverTitle": "早八素颜也能有点气色",
|
||
"imageBrief": "封面自然光上脸局部,内页质地推开+软性转化。",
|
||
"source": "ai",
|
||
}
|
||
|
||
|
||
# ── 五维打分 ───────────────────────────────────────────────
|
||
class TestScoreCopy:
|
||
def test_good_copy_passes(self):
|
||
result = score_copy(GOOD_COPY, PRODUCT)
|
||
assert result["passed"] is True
|
||
assert result["score"] >= 90
|
||
|
||
def test_banned_word_kills_compliance(self):
|
||
bad = {**GOOD_COPY, "title": "美白素颜霜强推!", "content": GOOD_COPY["content"] + "美白效果显著"}
|
||
result = score_copy(bad, PRODUCT)
|
||
# 含美白 → 合规0分 + 总分不过
|
||
assert result["passed"] is False
|
||
compliance = next(d for d in result["score_detail"] if d["item"] == "合规性")
|
||
assert compliance["score"] == 0
|
||
|
||
def test_score_detail_has_five_dims(self):
|
||
result = score_copy(GOOD_COPY, PRODUCT)
|
||
assert len(result["score_detail"]) == 5
|
||
items = {d["item"] for d in result["score_detail"]}
|
||
assert "标题吸引力" in items
|
||
assert "情绪共鸣" in items
|
||
assert "买点表达" in items
|
||
assert "关键词覆盖" in items
|
||
assert "合规性" in items
|
||
|
||
|
||
# ── 去重 ───────────────────────────────────────────────────
|
||
class TestDedup:
|
||
def test_identical_title_deduped(self):
|
||
a = {**GOOD_COPY}
|
||
b = {**GOOD_COPY, "content": "略有不同的内容,但标题一样"}
|
||
result = dedupe_copies([a, b])
|
||
assert len(result) == 1
|
||
|
||
def test_different_angle_both_kept(self):
|
||
a = {**GOOD_COPY, "angle": "痛点切入"}
|
||
b = {**GOOD_COPY, "title": "通勤懒人必囤!换季不再干脸", "angle": "避坑型"}
|
||
result = dedupe_copies([a, b])
|
||
assert len(result) == 2
|
||
|
||
def test_similar_body_deduped(self):
|
||
a = {**GOOD_COPY}
|
||
b = {**GOOD_COPY, "title": "不一样的标题", "content": GOOD_COPY["content"][:150] + "稍有变动"}
|
||
assert is_similar_copy(a, b) or len(dedupe_copies([a, b])) <= 2
|
||
|
||
|
||
# ── 违禁词三级 ──────────────────────────────────────────────
|
||
class TestBannedWordChecker:
|
||
def test_hard_block(self):
|
||
# "美白"已改为 auto_fix(提亮肤色感),hard_block 用速效/医用等词
|
||
result = check_and_fix("速效医用护肤品")
|
||
assert result.status == "hard_block"
|
||
|
||
def test_meibaibanned_now_auto_fix(self):
|
||
# Q5对齐:美白→auto_fix 提亮肤色感(不再 hard_block)
|
||
result = check_and_fix("这款美白效果很好")
|
||
assert result.status == "auto_fixed"
|
||
assert result.fixed_text is not None
|
||
assert "美白" not in result.fixed_text
|
||
|
||
def test_auto_fix(self):
|
||
result = check_and_fix("这款神器真的好用")
|
||
assert result.status == "auto_fixed"
|
||
assert result.fixed_text is not None
|
||
assert "神器" not in result.fixed_text
|
||
|
||
def test_soft_warn(self):
|
||
result = check_and_fix("这绝对是最好的护肤品")
|
||
assert result.status == "soft_warn"
|
||
|
||
def test_clean_text_passes(self):
|
||
result = check_and_fix("这款素颜霜轻薄水润,通勤必备。")
|
||
assert result.status == "pass"
|
||
|
||
def test_custom_entries_override(self):
|
||
entries = [BannedWordEntry("必备", "hard_block")]
|
||
result = check_and_fix("通勤必备好物", entries)
|
||
assert result.status == "hard_block"
|
||
|
||
|
||
# ── storyboard ─────────────────────────────────────────────
|
||
class TestStoryboard:
|
||
def test_clamp_count(self):
|
||
assert clamp_count(0) == 1
|
||
assert clamp_count(9) == 8
|
||
assert clamp_count(3) == 3
|
||
|
||
def test_narrative_roles_3(self):
|
||
roles = get_narrative_roles(3)
|
||
assert len(roles) == 3
|
||
assert roles[0]["role"] == "hook"
|
||
assert roles[-1]["role"] == "closer"
|
||
|
||
def test_narrative_roles_6(self):
|
||
roles = get_narrative_roles(6)
|
||
assert len(roles) == 6
|
||
# Q6对齐北哥套路:①hook ②product_closeup(单品特写) ③ingredient ④texture ⑤applied_proof ⑥closer
|
||
assert roles[0]["role"] == "hook"
|
||
assert roles[1]["role"] == "product_closeup"
|
||
assert roles[2]["role"] == "ingredient"
|
||
assert roles[4]["role"] == "applied_proof"
|
||
assert roles[5]["role"] == "closer"
|
||
|
||
def test_narrative_roles_8(self):
|
||
roles = get_narrative_roles(8)
|
||
assert len(roles) == 8
|
||
|
||
def test_plan_image_set_structure(self):
|
||
plan = plan_image_set(GOOD_COPY, PRODUCT, image_count=3)
|
||
assert "storyboard" in plan
|
||
assert "base_prompt" in plan
|
||
assert len(plan["storyboard"]) == 3
|
||
# 产品图锚点说明必须在 base_prompt 中
|
||
assert "不可修改" in plan["base_prompt"]
|
||
|
||
|
||
# ── 飞轮聚合 ───────────────────────────────────────────────
|
||
class TestPreferenceAggregator:
|
||
def test_cold_start_when_few_events(self):
|
||
result = aggregate_preference_context([], PRODUCT, workspace_id=1, product_id=1)
|
||
assert result["injected_count"] == 0
|
||
assert "冷启动" in result["recent_preference"]
|
||
|
||
def test_aggregate_top_angles(self):
|
||
events = [
|
||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""},
|
||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""},
|
||
{"signal_type": "approve", "workspace_id": 1, "product_id": 1, "angle_label": "场景型", "signal_weight": 5, "reason": ""},
|
||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "避坑型", "signal_weight": 3, "reason": ""},
|
||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""},
|
||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "避坑型", "signal_weight": 3, "reason": ""},
|
||
]
|
||
result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||
assert result["injected_count"] == 6
|
||
assert "痛点切入" in result["recent_preference"]
|
||
assert "偏好角度" in result["prompt_fragment"]
|
||
|
||
def test_reject_reason_in_prompt(self):
|
||
events = [
|
||
{"signal_type": "reject_with_reason", "workspace_id": 1, "product_id": 1,
|
||
"angle_label": "", "signal_weight": -3, "reason": "标题太硬广"},
|
||
*[{"signal_type": "text_select", "workspace_id": 1, "product_id": 1,
|
||
"angle_label": "痛点切入", "signal_weight": 3, "reason": ""} for _ in range(5)],
|
||
]
|
||
result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||
assert "标题太硬广" in result["prompt_fragment"]
|
||
|
||
def test_product_isolation(self):
|
||
"""不同 product_id 的事件不会混进来"""
|
||
events = [
|
||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 2,
|
||
"angle_label": "成分党", "signal_weight": 3, "reason": ""},
|
||
*[{"signal_type": "text_select", "workspace_id": 1, "product_id": 1,
|
||
"angle_label": "场景型", "signal_weight": 3, "reason": ""} for _ in range(5)],
|
||
]
|
||
result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||
assert "成分党" not in result["prompt_fragment"]
|
||
|
||
def test_collect_event_structure(self):
|
||
event = collect_preference_event("text_select", user_id=1, workspace_id=1, product_id=1, angle_label="痛点切入")
|
||
assert event["signal_weight"] == 3
|
||
assert event["data_ownership"] == "client_data"
|
||
|
||
|
||
# ── prompt_composer ────────────────────────────────────────
|
||
class TestPromptComposer:
|
||
def test_compose_variants_returns_two_strings(self):
|
||
sys_p, user_p = compose_variants(PRODUCT, count=5)
|
||
assert isinstance(sys_p, str) and len(sys_p) > 0
|
||
assert isinstance(user_p, str) and len(user_p) > 0
|
||
|
||
def test_compose_variants_includes_product_name(self):
|
||
_, user_p = compose_variants(PRODUCT, count=3)
|
||
assert "倍分子素颜霜" in user_p
|
||
|
||
def test_compose_variants_includes_count(self):
|
||
_, user_p = compose_variants(PRODUCT, count=7)
|
||
assert "7" in user_p
|
||
|
||
def test_compose_variants_flywheel_injected(self):
|
||
fragment = "偏好角度参考:场景型、痛点切入"
|
||
_, user_p = compose_variants(PRODUCT, count=3, flywheel_context=fragment)
|
||
assert "场景型" in user_p
|
||
|
||
def test_compose_preference_context_delegates(self):
|
||
# compose_preference_context 应委托给 aggregate_preference_context
|
||
events = [
|
||
{"signal_type": "text_select", "workspace_id": 1, "product_id": 1,
|
||
"angle_label": "场景型", "signal_weight": 3, "reason": ""}
|
||
for _ in range(6)
|
||
]
|
||
result = compose_preference_context(events, PRODUCT, workspace_id=1, product_id=1)
|
||
assert "injected_count" in result
|
||
assert result["injected_count"] == 6
|
||
assert "prompt_fragment" in result
|
||
|
||
def test_parse_model_output_valid_json(self):
|
||
raw = '[{"title":"标题1","content":"正文","tags":[],"angle":"场景型"}]'
|
||
parsed = parse_model_output(raw)
|
||
assert len(parsed) == 1 and parsed[0]["title"] == "标题1"
|
||
|
||
def test_parse_model_output_markdown_wrapped(self):
|
||
raw = "```json\n[{\"title\":\"a\",\"content\":\"b\",\"tags\":[]}]\n```"
|
||
parsed = parse_model_output(raw)
|
||
assert len(parsed) == 1
|
||
|
||
def test_compose_image_prompt_includes_role_and_product(self):
|
||
vs = {"style": "ins摆拍风", "color_palette": "米白+杏色", "base_prompt": "产品近景"}
|
||
prompt = compose_image_prompt("hook", vs, PRODUCT)
|
||
assert "hook" in prompt
|
||
assert "倍分子素颜霜" in prompt
|
||
assert "ins摆拍风" in prompt
|