""" 偏好飞轮聚合(preference_aggregator) 扒自:Clover架构方案.md §偏好飞轮怎么转 + PRD §8 三层继承:L1 公司品牌基线 > L2 矩阵号人设(二期)> L3 个人手感 聚合最近 FLYWHEEL_LOOKBACK 条 events → prompt 片段注入文案生成 关键: - 按 product_id 分开学(素颜霜偏好不串精华) - 信号不足 FLYWHEEL_COLD_START 条时,用产品档案冷启动 - 返回结构对齐 API契约 GET /tasks/{id}/preference/context """ from __future__ import annotations import logging from collections import Counter from typing import Any from .constants import FLYWHEEL_LOOKBACK, FLYWHEEL_COLD_START logger = logging.getLogger(__name__) def aggregate_preference_context( events: list[dict], product: dict, workspace_id: int, product_id: int, ) -> dict: """ 输入:最近 preference_events 行(已按 workspace_id+product_id 过滤) 输出:{recent_preference, reject_reasons, injected_count, prompt_fragment} prompt_fragment 直接注入文案生成 prompt """ # 按 product_id 过滤(防串货) relevant = [ e for e in events if e.get("workspace_id") == workspace_id and e.get("product_id") == product_id ][:FLYWHEEL_LOOKBACK] injected_count = len(relevant) if injected_count < FLYWHEEL_COLD_START: # 冷启动:用产品档案静态基线 return _cold_start(product, injected_count) # ── 统计最常选角度(text_select + approve 信号) angle_counts: Counter = Counter() reject_reasons: list[str] = [] for e in relevant: sig_type = e.get("signal_type", "") angle = str(e.get("angle_label", "")).strip() weight = int(e.get("signal_weight", 1)) if sig_type in ("text_select", "approve") and angle: angle_counts[angle] += weight elif sig_type == "reject_with_reason": reason = str(e.get("reason", "")).strip() if reason: reject_reasons.append(reason) # 取权重最高的角度 top_angles = [a for a, _ in angle_counts.most_common(3)] # 取最近3条打回原因 recent_rejects = reject_reasons[-3:] if reject_reasons else [] # ── 拼 prompt 片段(三层继承:L1>L2>L3,一期只跑L1+L3) prompt_fragment = _build_prompt_fragment(top_angles, recent_rejects, product) # ── 人类可读摘要(前端"本次已注入"显示) if top_angles: pref_summary = f"最近偏好角度:{'、'.join(top_angles)}(已选{injected_count}次信号)" else: pref_summary = f"已注入{injected_count}条偏好信号" return { "recent_preference": pref_summary, "reject_reasons": recent_rejects, "injected_count": injected_count, "prompt_fragment": prompt_fragment, # 注入 generate_text_variants extra_rules } def _cold_start(product: dict, injected_count: int) -> dict: """信号不足时用产品档案基线""" angles = product.get("text_angles") or [] style = product.get("style_tone", "素人分享风") fragment = "" if angles: fragment = f"优先覆盖以下文案角度:{'、'.join(angles[:3])}。风格调性:{style}。" return { "recent_preference": f"冷启动(历史信号{injected_count}条,不足{FLYWHEEL_COLD_START}条),使用产品档案基线", "reject_reasons": [], "injected_count": injected_count, "prompt_fragment": fragment, } def _build_prompt_fragment( top_angles: list[str], reject_reasons: list[str], product: dict, ) -> str: """ 组装注入文案 prompt 的片段 越积累越精准:1次=全靠基线;10次=知道偏好角度;30次=措辞从"供参考"升为明确指令 """ lines: list[str] = [] if top_angles: lines.append(f"【偏好角度参考】历史选择偏好:{'、'.join(top_angles)},请优先采用这些角度方向。") if reject_reasons: formatted = ";".join(f"「{r}」" for r in reject_reasons) lines.append(f"【打回原因参考】以下问题请主动规避:{formatted}。") # L1 品牌基线(产品档案 custom_prompt) custom = (product.get("custom_prompt") or "").strip() if custom: lines.append(f"【品牌基线】{custom}") return "\n".join(lines) def collect_preference_event( signal_type: str, user_id: int, workspace_id: int, product_id: int, angle_label: str = "", reason: str = "", weights: dict[str, int] | None = None, ) -> dict: """ 构造 preference_event 行(由业务接口内部调用,不暴露给前端) 返回待插 DB 的字段 dict """ from .constants import FLYWHEEL_WEIGHTS w_map = weights or FLYWHEEL_WEIGHTS weight = w_map.get(signal_type, 0) return { "signal_type": signal_type, "signal_weight": weight, "user_id": user_id, "workspace_id": workspace_id, "product_id": product_id, "angle_label": angle_label, "reason": reason, "data_ownership": "client_data", # 原始行为信号归客户(PRD §3 data_ownership) }