""" 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), }