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>
148 lines
5.3 KiB
Python
148 lines
5.3 KiB
Python
"""
|
||
偏好飞轮聚合(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") or "").strip()
|
||
weight = int(e.get("signal_weight", 1))
|
||
|
||
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
|
||
# image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2)
|
||
if sig_type in ("text_select", "approve", "text_edit", "image_select") and angle:
|
||
angle_counts[angle] += weight
|
||
elif sig_type == "reject_with_reason":
|
||
reason = str(e.get("reason") or "").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)
|
||
}
|