Files
beige/backend/app/services/flywheel_service.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

122 lines
4.1 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.
"""
app/services/flywheel_service.py — 飞轮信号写入 + 偏好上下文聚合
preference_collector三信号入口选文案/选图/审核)写入 preference_events。
preference_aggregator查最近50条 → 最常选角度 + 打回原因近3条原文拼 prompt。
飞轮不暴露独立埋点端点,只由业务接口内部调用(契约红线)。
"""
import logging
from typing import Any
from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership, SignalType
from app.middleware.workspace_guard import CurrentUser
from app.models.flywheel import PreferenceEvent
from app.models.task import GenerationTask
logger = logging.getLogger(__name__)
# 实时聚合窗口最近50条事件
_AGGREGATION_WINDOW = 50
# 冷启动阈值不足5条信号用产品档案冷启动
_COLD_START_THRESHOLD = 5
def record_signal(
db: Session,
current_user: CurrentUser,
task: GenerationTask,
signal_type: str,
candidate_id: int | None = None,
angle_label: str | None = None,
reason: str | None = None,
) -> None:
"""
写入飞轮信号。
workspace_id + product_id 都必须有基石C + 按产品分开学)。
signal_weight 用枚举默认值,北哥可校准。
data_ownership 默认 client_data选择行为归客户
"""
weight = SIGNAL_WEIGHTS.get(signal_type, 0)
event = PreferenceEvent(
workspace_id=current_user.workspace_id,
product_id=task.product_id,
task_id=task.id,
user_id=current_user.user_id,
signal_type=signal_type,
signal_weight=weight,
candidate_id=candidate_id,
angle_label=angle_label,
reason=reason,
data_ownership=DataOwnership.CLIENT_DATA,
)
try:
db.add(event)
db.commit()
logger.info(
"Flywheel signal: type=%s weight=%s user=%s product=%s",
signal_type, weight, current_user.user_id, task.product_id,
)
except Exception:
db.rollback()
logger.error(
"Failed to write preference_event: type=%s user=%s",
signal_type, current_user.user_id,
)
raise
def get_preference_context(
db: Session, workspace_id: int, product_id: int
) -> dict[str, Any]:
"""
实时聚合偏好上下文最近50条 events
返回recent_preference摘要 + reject_reasons近3条 + injected_count。
不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt
按 workspace_id + product_id 严格过滤不串数据基石C
"""
recent = (
db.query(PreferenceEvent)
.filter(
PreferenceEvent.workspace_id == workspace_id,
PreferenceEvent.product_id == product_id,
)
.order_by(desc(PreferenceEvent.created_at))
.limit(_AGGREGATION_WINDOW)
.all()
)
if len(recent) < _COLD_START_THRESHOLD:
return {
"recent_preference": "信号不足,使用产品档案基线(冷启动)",
"reject_reasons": [],
"injected_count": len(recent),
}
# 统计最常被选中的角度
angle_counts: dict[str, int] = {}
for ev in recent:
if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE) and ev.angle_label:
angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1
top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3]
if top_angles:
pref_desc = "".join(f"{a}(已选{c}次)" for a, c in top_angles)
preference_summary = f"最近偏好:{pref_desc}"
else:
preference_summary = "暂无明显角度偏好"
# 取最近3条打回原因原文不做 AI 归纳契约§3
reject_reasons = [
ev.reason for ev in recent
if ev.signal_type == SignalType.REJECT_WITH_REASON and ev.reason
][:3]
return {
"recent_preference": preference_summary,
"reject_reasons": reject_reasons,
"injected_count": len(recent),
}