Files
beige/backend/app/services/flywheel_service.py
yangqianqian 7f419f4c8b 存量积累:生图素人感约束+评图分+幂等防重跑+审核回路
- 015-017迁移:image_candidate 文案复审/AI视觉分/重生标记
- constants C7素人感约束(反电商摆拍对齐真实笔记)+C3叠字口子
- celery visibility_timeout=2h 防长任务被误判重投重复烧钱(task75教训)
- image_scorer 评图分(只筛选+展示,真实信号才进飞轮权重)
- storyboard/image_gen/pipeline_io 生图存量
- task_actions/tasks/task_service/flywheel 审核回路+飞轮存量

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

122 lines
4.2 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),
}
# 统计最常被选中的角度text_edit 改稿=最强真实信号,按权重计入,倩倩姐2026-06-16
angle_counts: dict[str, int] = {}
for ev in recent:
if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE, SignalType.TEXT_EDIT) 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),
}