Files
beige/backend/tests/test_ai_engine.py
yangqianqian 6a2632da70 baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:30:22 +08:00

254 lines
12 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.
"""
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