""" 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