baseline: Clover 独立仓库首次基线提交

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

View File

56
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,56 @@
"""
conftest.py — 测试环境 stub
在任何模块 import 之前注入环境变量,让 Settings 校验通过。
只用于本地单元/集成测试,不影响生产。
"""
import os
import sys
import types
# ── 1. 注入最小可用配置(全假值,只让 Settings 构建通过)────────
_TEST_ENV = {
"FERNET_KEY": "test-fernet-key-at-least-32-chars-long!!",
"JWT_SECRET": "test-jwt-secret-at-least-32-chars-long!!",
"DATABASE_URL": "mysql+pymysql://test:test@localhost/test",
"MONGO_URI": "mongodb://localhost:27017/test",
"REDIS_URL": "redis://localhost:6379/0",
"APP_ENV": "test",
}
for k, v in _TEST_ENV.items():
os.environ.setdefault(k, v)
# ── 2. 清掉 lru_cache让 Settings 用刚注入的 env vars 重建 ─────
from app.core import config as _cfg
_cfg.get_settings.cache_clear()
# ── 3. Stub motorMongoDB client本地没装──────────────────────
if "motor" not in sys.modules:
motor_mod = types.ModuleType("motor")
motor_async = types.ModuleType("motor.motor_asyncio")
motor_async.AsyncIOMotorClient = object
sys.modules["motor"] = motor_mod
sys.modules["motor.motor_asyncio"] = motor_async
# ── 4. Stub database engineMySQL URL 但不连接)──────────────────
# database.py 顶层调 create_enginepytest 环境没有 MySQL。
# 只 stub engine 和 SessionLocal不影响任何逻辑测试。
import unittest.mock as _mock
import sqlalchemy
_fake_engine = _mock.MagicMock()
_fake_session_class = _mock.MagicMock()
_fake_session_class.return_value.__enter__ = _mock.MagicMock(return_value=_mock.MagicMock())
_fake_session_class.return_value.__exit__ = _mock.MagicMock(return_value=False)
# 只在 database 模块还未 import 时 patch若已 import 则直接替换属性
if "app.core.database" not in sys.modules:
with _mock.patch("sqlalchemy.create_engine", return_value=_fake_engine):
with _mock.patch("sqlalchemy.orm.sessionmaker", return_value=_fake_session_class):
import app.core.database as _db_mod
else:
_db_mod = sys.modules["app.core.database"]
# 确保 engine/SessionLocal 是 mock防止实际连接
_db_mod.engine = _fake_engine
_db_mod.SessionLocal = _fake_session_class

View File

@@ -0,0 +1,253 @@
"""
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

View File

@@ -0,0 +1,141 @@
"""
gemini_factory + package_exporter 单元测试
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import json
import tempfile
import zipfile
from pathlib import Path
import pytest
from app.services.ai_engine.gemini_factory import (
build_ai_clients, _extract_image_bytes, _extract_gemini_image
)
from app.services.ai_engine.package_exporter import build_delivery_package
# ── gemini_factory ─────────────────────────────────────────
class TestGeminiFactory:
def test_build_clients_returns_ai_clients(self, monkeypatch):
monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com")
clients = build_ai_clients(plain_key="test_key_1234")
assert clients is not None
assert clients._model == clients._model_text
def test_model_property_uses_env(self, monkeypatch):
monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com")
monkeypatch.setenv("MODEL_TEXT", "gpt-5.5")
clients = build_ai_clients(plain_key="test_key_1234")
assert clients._model_text == "gpt-5.5"
def test_extract_image_bytes_b64(self):
import base64
fake_png = b"\x89PNG fake"
b64 = base64.b64encode(fake_png).decode()
result = _extract_image_bytes({"data": [{"b64_json": b64}]})
assert result == fake_png
def test_extract_image_bytes_empty_raises(self):
with pytest.raises(ValueError):
_extract_image_bytes({"data": []})
def test_extract_gemini_image_success(self):
import base64
fake_img = b"\xff\xd8\xff fake jpeg"
b64 = base64.b64encode(fake_img).decode()
resp = {"candidates": [{"content": {"parts": [{"inlineData": {"data": b64}}]}}]}
result = _extract_gemini_image(resp)
assert result == fake_img
def test_extract_gemini_image_no_image_raises(self):
resp = {"candidates": [{"content": {"parts": [{"text": "no image"}]}}]}
with pytest.raises(ValueError):
_extract_gemini_image(resp)
def test_key_not_in_repr(self, monkeypatch):
monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com")
clients = build_ai_clients(plain_key="super_secret_key_xyz")
# repr 不泄露 key
assert "super_secret_key_xyz" not in repr(clients)
# ── package_exporter ──────────────────────────────────────
class TestPackageExporter:
def _make_notes(self):
return [{
"title": "早八素颜也能有气色",
"content": "姐妹们实测下来,轻薄不厚重是关键。✅ 通勤必备。",
"tags": ["#素颜霜", "#日常通勤"],
"images": [
{"seq": 1, "role": "hook", "data": b"\xff\xd8\xff fake_jpg_1"},
{"seq": 2, "role": "proof", "data": b"\xff\xd8\xff fake_jpg_2"},
],
"banned_word_status": "pass",
}]
def test_creates_zip(self, tmp_path):
zip_path = build_delivery_package(
workspace_id=1, task_id=99,
notes=self._make_notes(),
base_path=str(tmp_path),
)
assert Path(zip_path).exists()
assert zip_path.endswith(".zip")
def test_zip_contains_required_files(self, tmp_path):
zip_path = build_delivery_package(
workspace_id=1, task_id=99,
notes=self._make_notes(),
base_path=str(tmp_path),
)
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
# 应含文案.txt、发布清单、合规说明
assert any("文案.txt" in n for n in names)
assert any("发布清单" in n for n in names)
assert any("合规说明" in n for n in names)
def test_images_named_by_seq(self, tmp_path):
zip_path = build_delivery_package(
workspace_id=1, task_id=99,
notes=self._make_notes(),
base_path=str(tmp_path),
)
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
# 图片按 seq 序号命名
assert any("01_hook.jpg" in n for n in names)
assert any("02_proof.jpg" in n for n in names)
def test_copy_txt_contains_title_and_tags(self, tmp_path):
zip_path = build_delivery_package(
workspace_id=1, task_id=99,
notes=self._make_notes(),
base_path=str(tmp_path),
)
with zipfile.ZipFile(zip_path) as zf:
copy_txt = zf.read("note_01/文案.txt").decode("utf-8")
assert "早八素颜也能有气色" in copy_txt
assert "#素颜霜" in copy_txt
def test_compliance_note_shows_status(self, tmp_path):
zip_path = build_delivery_package(
workspace_id=1, task_id=99,
notes=self._make_notes(),
base_path=str(tmp_path),
)
with zipfile.ZipFile(zip_path) as zf:
compliance = zf.read("✅合规说明.txt").decode("utf-8")
assert "✅通过" in compliance
def test_no_images_still_creates_package(self, tmp_path):
notes = [{**self._make_notes()[0], "images": []}]
zip_path = build_delivery_package(
workspace_id=1, task_id=100,
notes=notes,
base_path=str(tmp_path),
)
assert Path(zip_path).exists()

View File

@@ -0,0 +1,135 @@
"""
test_flywheel_wiring.py — 飞轮 Point1+Point2 验证
Point1: 5种信号正确写入 preference_events选文案/选图/审核 approve/reject/regenerate
Point2: aggregator 返回明确 prompt_fragment含偏好角度、打回原因workspace 严格过滤)
无 Docker — 全程纯 Python 单元测试
Point3+Point4 见 test_flywheel_wiring_p34.py
"""
from unittest.mock import MagicMock
SIGNAL_WEIGHTS_MAP = {
"text_select": 3,
"image_select": 3,
"approve": 5,
"reject_with_reason": -3,
"regenerate": -1,
}
_PRODUCT = {
"name": "素颜霜",
"category": "护肤",
"selling_points": ["保湿", "天然"],
"style_tone": "素人分享风",
"text_angles": ["成分党", "懒人必备"],
"custom_prompt": "请保持真实素人口吻",
}
_EVENTS_ENOUGH = [
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
"angle_label": "成分党", "signal_weight": 3, "reason": ""},
{"signal_type": "approve", "workspace_id": 1, "product_id": 10,
"angle_label": "成分党", "signal_weight": 5, "reason": ""},
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
"angle_label": "懒人必备", "signal_weight": 3, "reason": ""},
{"signal_type": "reject_with_reason","workspace_id": 1, "product_id": 10,
"angle_label": "", "signal_weight": -3, "reason": "太像广告"},
{"signal_type": "regenerate", "workspace_id": 1, "product_id": 10,
"angle_label": "", "signal_weight": -1, "reason": ""},
]
# ══════════════════════════════════════════════════════════════
# Point1: 五种信号写入结构验证
# ══════════════════════════════════════════════════════════════
class TestPoint1SignalTypes:
"""验证 5 种信号都有对应 weight且字段完整。"""
def test_all_signal_types_have_weights(self):
from app.constants.enums import SIGNAL_WEIGHTS
required = {"text_select", "image_select", "approve", "reject_with_reason", "regenerate"}
missing = required - set(SIGNAL_WEIGHTS.keys())
assert not missing, f"SIGNAL_WEIGHTS 缺少: {missing}"
def test_positive_signals_weight_positive(self):
from app.constants.enums import SIGNAL_WEIGHTS
for sig in ("text_select", "image_select", "approve"):
assert SIGNAL_WEIGHTS[sig] > 0, f"{sig} weight 应 > 0实际={SIGNAL_WEIGHTS[sig]}"
def test_negative_signals_weight_negative(self):
from app.constants.enums import SIGNAL_WEIGHTS
for sig in ("reject_with_reason", "regenerate"):
assert SIGNAL_WEIGHTS[sig] < 0, f"{sig} weight 应 < 0实际={SIGNAL_WEIGHTS[sig]}"
def test_collect_preference_event_returns_complete_fields(self):
from app.services.ai_engine.preference_aggregator import collect_preference_event
event = collect_preference_event(
signal_type="text_select",
user_id=7,
workspace_id=1,
product_id=10,
angle_label="成分党",
)
required_keys = {"signal_type", "signal_weight", "user_id",
"workspace_id", "product_id", "angle_label",
"reason", "data_ownership"}
assert required_keys.issubset(event.keys()), f"缺字段: {required_keys - event.keys()}"
def test_collect_event_weight_matches_signal(self):
from app.services.ai_engine.preference_aggregator import collect_preference_event
ev = collect_preference_event("approve", 1, 1, 10)
assert ev["signal_weight"] == SIGNAL_WEIGHTS_MAP["approve"]
# ══════════════════════════════════════════════════════════════
# Point2: aggregator 返回 prompt_fragment + workspace 严格过滤
# ══════════════════════════════════════════════════════════════
class TestPoint2AggregatorOutput:
"""验证 aggregate_preference_context 返回结构与 workspace/product 隔离。"""
def test_returns_required_keys(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
for k in ("prompt_fragment", "recent_preference", "reject_reasons", "injected_count"):
assert k in ctx, f"返回值缺字段: {k}"
def test_prompt_fragment_not_empty_with_signals(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
assert ctx["prompt_fragment"].strip(), "有足够信号时 prompt_fragment 不应为空"
def test_prompt_fragment_contains_top_angle(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
assert "成分党" in ctx["prompt_fragment"] # 权重最高角度
def test_reject_reasons_in_fragment(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
assert "太像广告" in ctx["prompt_fragment"]
def test_workspace_isolation_filters_other_workspace(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
mixed = _EVENTS_ENOUGH + [
{"signal_type": "text_select", "workspace_id": 2, "product_id": 10,
"angle_label": "workspace2专有角度", "signal_weight": 100, "reason": ""},
]
ctx = aggregate_preference_context(mixed, _PRODUCT, 1, 10)
assert "workspace2专有角度" not in ctx["prompt_fragment"]
def test_product_isolation_filters_other_product(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
mixed = _EVENTS_ENOUGH + [
{"signal_type": "text_select", "workspace_id": 1, "product_id": 99,
"angle_label": "product99专有角度", "signal_weight": 100, "reason": ""},
]
ctx = aggregate_preference_context(mixed, _PRODUCT, 1, 10)
assert "product99专有角度" not in ctx["prompt_fragment"]
def test_cold_start_when_insufficient_signals(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
from app.services.ai_engine.constants import FLYWHEEL_COLD_START
few = _EVENTS_ENOUGH[:max(0, FLYWHEEL_COLD_START - 1)]
ctx = aggregate_preference_context(few, _PRODUCT, 1, 10)
assert "recent_preference" in ctx
assert ctx["injected_count"] == len(few)

View File

@@ -0,0 +1,166 @@
"""
test_flywheel_wiring_p34.py — 飞轮 Point3+Point4 验证
Point3: pipeline 把 prompt_fragment 注入 user_promptprompt trace 含片段)
Point4: UI 层 flywheel_injected SSE 事件含 recent_preference + reject_reasons
无 Docker — 全程纯 Python 单元测试
Point1+Point2 见 test_flywheel_wiring.py
"""
from unittest.mock import MagicMock
_PRODUCT = {
"name": "素颜霜",
"category": "护肤",
"selling_points": ["保湿", "天然"],
"style_tone": "素人分享风",
"text_angles": ["成分党", "懒人必备"],
"custom_prompt": "请保持真实素人口吻",
}
_EVENTS_ENOUGH = [
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
"angle_label": "成分党", "signal_weight": 3, "reason": ""},
{"signal_type": "approve", "workspace_id": 1, "product_id": 10,
"angle_label": "成分党", "signal_weight": 5, "reason": ""},
{"signal_type": "text_select", "workspace_id": 1, "product_id": 10,
"angle_label": "懒人必备", "signal_weight": 3, "reason": ""},
{"signal_type": "reject_with_reason","workspace_id": 1, "product_id": 10,
"angle_label": "", "signal_weight": -3, "reason": "太像广告"},
{"signal_type": "regenerate", "workspace_id": 1, "product_id": 10,
"angle_label": "", "signal_weight": -1, "reason": ""},
]
# ══════════════════════════════════════════════════════════════
# Point3: pipeline 把 prompt_fragment 注入 user_prompt
# ══════════════════════════════════════════════════════════════
class TestPoint3PromptInjection:
"""验证 compose_variants 把 flywheel_context 正确注入 user_prompt。"""
def test_compose_variants_appends_flywheel_to_user_prompt(self):
from app.services.ai_engine.prompt_composer import compose_variants
fragment = "【偏好角度参考】历史选择偏好:成分党"
_, user_prompt = compose_variants(_PRODUCT, 3, flywheel_context=fragment)
assert fragment in user_prompt, "flywheel_context 应出现在 user_prompt 中"
def test_compose_variants_flywheel_not_in_system_prompt(self):
"""飞轮片段不应覆盖 system_promptCOPY_SYSTEM 质量护栏不能被干扰)"""
from app.services.ai_engine.prompt_composer import compose_variants
fragment = "飞轮注入内容UNIQUE123"
system_prompt, _ = compose_variants(_PRODUCT, 2, flywheel_context=fragment)
assert fragment not in system_prompt, "飞轮片段不应出现在 system_prompt"
def test_compose_variants_empty_flywheel_still_valid(self):
"""无飞轮片段时 compose_variants 仍正常返回 (system, user)"""
from app.services.ai_engine.prompt_composer import compose_variants
system_p, user_p = compose_variants(_PRODUCT, 3, flywheel_context="")
assert isinstance(system_p, str) and len(system_p) > 0
assert isinstance(user_p, str) and len(user_p) > 0
def test_pipeline_fragment_contains_top_angle(self):
"""pipeline 重现ORM events → aggregate → fragment 含最高权重角度"""
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
def make_event(sig, angle, weight, reason):
e = MagicMock()
e.signal_type = sig
e.workspace_id = 1
e.product_id = 10
e.angle_label = angle
e.signal_weight = weight
e.reason = reason
return e
mock_events = [
make_event("text_select", "成分党", 3, ""),
make_event("approve", "成分党", 5, ""),
make_event("reject_with_reason", "", -3, "太像广告"),
make_event("text_select", "懒人必备", 3, ""),
make_event("regenerate", "", -1, ""),
]
events_dicts = [
{"signal_type": e.signal_type, "workspace_id": e.workspace_id,
"product_id": e.product_id, "angle_label": e.angle_label or "",
"signal_weight": e.signal_weight, "reason": e.reason or ""}
for e in mock_events
]
ctx = aggregate_preference_context(events_dicts, _PRODUCT, 1, 10)
fragment = ctx.get("prompt_fragment", "")
assert isinstance(fragment, str)
assert "成分党" in fragment
# ══════════════════════════════════════════════════════════════
# Point4: UI 层 flywheel_injected SSE 事件结构验证
# ══════════════════════════════════════════════════════════════
class TestPoint4SseFlywheelEvent:
"""验证 flywheel_injected SSE 事件包含 UI 展示所需字段。"""
# FE useSse.ts flywheel_injected 事件期望字段
FE_EXPECTED_FIELDS = {"recent_preference", "reject_reasons"}
def test_aggregator_output_matches_sse_fields(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
missing = self.FE_EXPECTED_FIELDS - ctx.keys()
assert not missing, f"SSE flywheel_injected 缺少字段: {missing}"
def test_recent_preference_is_nonempty_string(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
assert isinstance(ctx["recent_preference"], str) and ctx["recent_preference"].strip()
def test_reject_reasons_is_list_of_strings(self):
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10)
assert isinstance(ctx["reject_reasons"], list)
for r in ctx["reject_reasons"]:
assert isinstance(r, str)
def test_reject_reasons_max_3(self):
"""打回原因最多返回3条FE 卡片布局限制)"""
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
many_rejects = [
{"signal_type": "reject_with_reason", "workspace_id": 1,
"product_id": 10, "angle_label": "", "signal_weight": -3,
"reason": f"原因{i}"}
for i in range(10)
] + _EVENTS_ENOUGH
ctx = aggregate_preference_context(many_rejects, _PRODUCT, 1, 10)
assert len(ctx["reject_reasons"]) <= 3
def test_flywheel_service_get_context_returns_sse_compatible_shape(self):
"""flywheel_service.get_preference_context 返回结构与 FE PreferenceContext DTO 一致"""
from app.services.flywheel_service import get_preference_context
from app.constants.enums import SignalType
def make_orm(sig, angle, reason):
e = MagicMock()
e.signal_type = sig
e.angle_label = angle
e.reason = reason
return e
orm_events = [
make_orm(SignalType.TEXT_SELECT, "成分党", ""),
make_orm(SignalType.TEXT_SELECT, "成分党", ""),
make_orm(SignalType.APPROVE, "成分党", ""),
make_orm(SignalType.REJECT_WITH_REASON, "", "太像广告"),
make_orm(SignalType.TEXT_SELECT, "懒人必备", ""),
]
mock_db = MagicMock()
mq = MagicMock()
mock_db.query.return_value = mq
mq.filter.return_value = mq
mq.order_by.return_value = mq
mq.limit.return_value = mq
mq.all.return_value = orm_events
result = get_preference_context(mock_db, workspace_id=1, product_id=10)
fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"}
missing = fe_dto_keys - result.keys()
assert not missing, f"FE PreferenceContext DTO 缺字段: {missing}"
assert isinstance(result["recent_preference"], str)
assert isinstance(result["reject_reasons"], list)
assert isinstance(result["injected_count"], int)

View File

@@ -0,0 +1,241 @@
"""
test_integration_seams.py — 汇合联调接缝验证(无 Docker不依赖 Redis/DB
验证三条核心接缝:
1. AIE→BE 接缝pipeline_steps / pipeline_io 数据流契约
2. BE→FE 接缝SSE 事件字段与 generationStore 期望字段对齐
3. FE→BE 接缝API 请求 DTO 与 BE 路由入参对齐
原则:纯 Python/结构断言,不联网,不起服务。
联调真实运行需 docker compose up + 真实 API Key。
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import json
# ═══════════════════════════════════════════════════════════════
# Seam 1 — AIE→BE: pipeline_steps 数据流
# ═══════════════════════════════════════════════════════════════
class TestAiePipelineStepsContract:
"""pipeline_steps.build_product_dict 产出符合 AI 引擎 dict 契约"""
def _make_mock_product(self):
"""模拟 ORM Product 对象duck typing"""
class P:
name = "倍分子素颜霜"
category = "美妆护肤"
selling_points = '["轻薄不厚重","水润自然"]'
style_tone = "素人分享风"
text_angles = '["痛点切入","场景型"]'
custom_prompt = ""
image_path = None # 前端图片上传完成前为空
return P()
def test_build_product_dict_keys(self):
from app.workers.pipeline_steps import build_product_dict
d = build_product_dict(self._make_mock_product())
for key in ("name", "category", "selling_points", "style_tone",
"text_angles", "custom_prompt", "image_path"):
assert key in d, f"build_product_dict 缺字段: {key}"
def test_build_product_dict_lists_parsed(self):
from app.workers.pipeline_steps import build_product_dict
d = build_product_dict(self._make_mock_product())
assert isinstance(d["selling_points"], list)
assert isinstance(d["text_angles"], list)
def test_build_product_dict_no_key_field(self):
"""绝不含任何 key/token 相关字段基石B"""
from app.workers.pipeline_steps import build_product_dict
d = build_product_dict(self._make_mock_product())
for forbidden in ("api_key", "key", "token", "secret", "encrypted"):
assert forbidden not in d, f"product_dict 不应含 {forbidden}"
# ═══════════════════════════════════════════════════════════════
# Seam 2 — BE→FE: SSE 事件字段对齐
# ═══════════════════════════════════════════════════════════════
class TestSseEventFieldAlignment:
"""BE 推的 SSE 事件字段与 FE useSse handleEvent 期望的字段一一对应"""
# FE useSse.ts 中 handleEvent 使用的字段(从 FE 代码提取)
EXPECTED_FIELDS = {
"task_started": {"total_text", "total_image"},
"text_progress": {"done", "total"},
"text_candidate": {"candidate_id", "angle_label", "content", "score"},
"image_progress": {"done", "total"},
"image_candidate": {"candidate_id", "strategy", "url", "role"},
"batch_failed": {"batch", "reason", "retryable"},
"flywheel_injected": {"recent_preference", "reject_reasons"},
}
# BE pipeline_io 实际推的事件字段(从代码提取)
ACTUAL_FIELDS = {
"task_started": {
"task_id": 1, "total_text": 5, "total_image": 3,
},
"text_progress": {
"done": 1, "total": 5,
},
"text_candidate": {
"candidate_id": 1, "angle_label": "场景型",
"content": "测试正文", "score": 88,
},
"image_progress": {
"done": 1, "total": 3,
},
"image_candidate": {
"candidate_id": 1, "strategy": "A", "url": "/uploads/ws/1/1/01_hook.jpg",
"role": "hook",
},
"batch_failed": {
"batch": "hook", "reason": "502", "retryable": True,
},
"flywheel_injected": {
"recent_preference": "最近偏好角度:场景型",
"reject_reasons": [],
},
}
def test_all_fe_required_fields_present(self):
"""FE 期望的每个字段都在 BE 实际推的 data 中"""
for event_type, required_fields in self.EXPECTED_FIELDS.items():
actual = self.ACTUAL_FIELDS.get(event_type, {})
for field in required_fields:
assert field in actual, (
f"SSE 事件 [{event_type}] 缺字段 [{field}]FE 会拿到 undefined"
)
def test_no_extra_pii_in_sse_events(self):
"""SSE 事件不含 key/token/password 等敏感字段"""
for event_type, data in self.ACTUAL_FIELDS.items():
for field in data:
assert "key" not in field.lower() and "token" not in field.lower(), (
f"SSE 事件 [{event_type}] 含敏感字段 [{field}]"
)
# ═══════════════════════════════════════════════════════════════
# Seam 3 — FE→BE: CreateTaskRequest DTO 对齐
# ═══════════════════════════════════════════════════════════════
class TestCreateTaskRequestContract:
"""FE tasks/new/page.tsx 发的请求体与 BE CreateTaskRequest 契约对齐(独立 DTO 验证,不依赖 motor"""
@pytest.fixture(autouse=True)
def _stub_motor(self, monkeypatch):
"""database.py 顶层 import motor 在测试环境可能缺失mock 掉避免 ModuleNotFoundError"""
import sys
import types
if "motor" not in sys.modules:
motor_mod = types.ModuleType("motor")
motor_async = types.ModuleType("motor.motor_asyncio")
motor_async.AsyncIOMotorClient = object
sys.modules["motor"] = motor_mod
sys.modules["motor.motor_asyncio"] = motor_async
def _get_dto(self):
from app.api.v1.tasks import CreateTaskRequest
return CreateTaskRequest
def test_create_task_required_fields(self):
Req = self._get_dto()
req = Req(product_id=1, theme="今日主题", text_count=8, image_count=5, track="ai")
assert req.product_id == 1
assert req.theme == "今日主题"
assert req.track == "ai"
def test_count_validation(self):
import pydantic
Req = self._get_dto()
with pytest.raises(pydantic.ValidationError):
Req(product_id=1, text_count=0, track="ai") # 0 < 1
with pytest.raises(pydantic.ValidationError):
Req(product_id=1, text_count=21, track="ai") # 21 > 20
def test_track_validation(self):
import pydantic
Req = self._get_dto()
with pytest.raises(pydantic.ValidationError):
Req(product_id=1, track="auto") # 非法 track
def test_import_track_no_key_check(self):
"""轨B import 字段校验通过(业务层 key 检查在 service不在 DTO"""
Req = self._get_dto()
req = Req(product_id=1, track="import")
assert req.track == "import"
# ═══════════════════════════════════════════════════════════════
# Seam 4 — SSE ticket 流程
# ═══════════════════════════════════════════════════════════════
class TestSseTicket:
"""ticket 签发/消费/防重放逻辑(用 mock redis"""
def _make_mock_redis(self):
store: dict = {}
class MockRedis:
def setex(self, key, ttl, value):
store[key] = value
def getdel(self, key):
return store.pop(key, None)
return MockRedis(), store
def test_issue_and_consume(self):
from app.core.sse_ticket import issue_ticket, consume_ticket
r, _ = self._make_mock_redis()
ticket = issue_ticket(r, task_id=42, workspace_id=7)
result = consume_ticket(r, ticket)
assert result == (42, 7)
def test_consume_once_only(self):
from app.core.sse_ticket import issue_ticket, consume_ticket
r, _ = self._make_mock_redis()
ticket = issue_ticket(r, task_id=1, workspace_id=1)
consume_ticket(r, ticket) # 第一次消费
result = consume_ticket(r, ticket) # 第二次 → None
assert result is None
def test_invalid_ticket_returns_none(self):
from app.core.sse_ticket import consume_ticket
r, _ = self._make_mock_redis()
assert consume_ticket(r, "fake-ticket") is None
def test_empty_ticket_returns_none(self):
from app.core.sse_ticket import consume_ticket
r, _ = self._make_mock_redis()
assert consume_ticket(r, "") is None
# ═══════════════════════════════════════════════════════════════
# Seam 5 — SSE sse_utils 格式化
# ═══════════════════════════════════════════════════════════════
class TestSseFormat:
"""format_sse 输出符合 text/event-stream 规范FE EventSource 能解析"""
def test_format_contains_event_and_data(self):
from app.utils.sse_utils import format_sse
out = format_sse("text_candidate", {"candidate_id": 1, "score": 92}, seq=3)
assert "event: text_candidate" in out
assert "data:" in out
assert "id: 3" in out
def test_format_ends_with_double_newline(self):
from app.utils.sse_utils import format_sse
out = format_sse("task_done", {"status": "pending_selection"})
assert out.endswith("\n\n")
def test_data_is_valid_json(self):
from app.utils.sse_utils import format_sse
out = format_sse("heartbeat", {"ts": 1234567.0})
data_line = [l for l in out.split("\n") if l.startswith("data:")][0]
json.loads(data_line[len("data:"):].strip()) # 不应抛异常