A8 多套交付包(packaging_task.py): - 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组 - 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容 - task74端到端验:3套各6图,独立agent7项交叉验证全过 M4 归档(tasks.py/exports.py/前端): - list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1) - 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM) - 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner - response.py加raise_param_error;独立agent验A1/A2/A9通过 R5 产品多图(product_images.py/020迁移/前端): - product_images表+5端点(上传/列/改场景/设主图/删图) - 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary - 前端ProductImageManager多图画廊 R6 账号config拆页(settings/): - 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin) - Key只显末4位不显余额(守红线) 核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
8.3 KiB
Python
170 lines
8.3 KiB
Python
"""
|
||
test_flywheel_wiring_p34.py — 飞轮 Point3+Point4 验证
|
||
Point3: pipeline 把 prompt_fragment 注入 user_prompt(prompt 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_prompt(COPY_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,
|
||
product_dict={"custom_prompt": "", "style_tone": ""},
|
||
)
|
||
|
||
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)
|