新功能:个性化推荐算法

This commit is contained in:
吕新雨
2026-02-02 16:47:37 +08:00
parent 936094211b
commit 6dc4e2b943
119 changed files with 7427 additions and 357 deletions

View File

@@ -0,0 +1,11 @@
"""
Reco Engine推荐引擎编排
该模块负责将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline
并输出统一结构items + meta可观测字段
"""
from app.features.personalized_reco.reco_engine.orchestrator import recommend
__all__ = ["recommend"]

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from app.features.personalized_reco.reco_engine.types import RecoEngineConfig, Scene
def get_default_engine_config(scene: Scene) -> RecoEngineConfig:
"""
获取推荐引擎默认配置(返回副本,避免被意外修改)。
"""
# V1三种场景目前共用一套默认值保留 scene 参数便于后续按场景拆分
base = RecoEngineConfig()
return RecoEngineConfig.model_validate(base.model_dump())

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Iterable, Optional
from app.features.personalized_reco.content_repository.types import ContentProfileDTO
from app.features.personalized_reco.reco_engine.types import HardFilterResult, RecoConstraints, Scene
def _user_stage_key(user_profile: object) -> str:
"""
从 user_profile.stage(one-hot) 提取用户阶段。
约定unknown 通常必填,但这里做防御。
"""
stage_obj = getattr(user_profile, "stage", None)
if stage_obj is None:
return "unknown"
if getattr(stage_obj, "expecting", 0) == 1:
return "expecting"
if getattr(stage_obj, "parenting", 0) == 1:
return "parenting"
return "unknown"
def _user_emotion_score(user_profile: object) -> Optional[float]:
v = getattr(user_profile, "emotion_score", None)
if v is None:
return None
try:
f = float(v)
except Exception:
return None
if f != f:
return None
return f
def _count_hits(counter: dict[str, int], hits: Iterable[str]) -> None:
for h in hits:
counter[str(h)] += 1
def hard_filter(
*,
scene: Scene,
user_profile: object,
candidates: list[ContentProfileDTO],
constraints: Optional[RecoConstraints] = None,
) -> HardFilterResult:
"""
Hard Filter硬过滤
V1仅实现硬规则集合不做软惩罚不做扩展 hard_rules
"""
cons = constraints or RecoConstraints()
exclude_author_ids = set([a for a in (cons.exclude_author_ids or []) if a is not None and str(a).strip() != ""])
exclude_template_ids = set([t for t in (cons.exclude_template_ids or []) if t is not None and str(t).strip() != ""])
exclude_content_ids = set([int(x) for x in (cons.exclude_content_ids or []) if x is not None])
u_stage = _user_stage_key(user_profile)
u_emotion = _user_emotion_score(user_profile)
emotion_low = u_emotion is not None and float(u_emotion) <= 0.2
kept: list[ContentProfileDTO] = []
removed_count = 0
# 统计:按命中 key 聚合计数risk_flags 直接用 flag 字符串;跨维度/约束用 rule:* / constraint:* 前缀)
hit_counts: dict[str, int] = defaultdict(int)
hits_by_content_id: dict[int, list[str]] = {}
for c in candidates or []:
cid = int(c.content_id)
hits: list[str] = []
# 约束:按 content_id/author_id/template_id 排除(视为硬过滤)
if cid in exclude_content_ids:
hits.append("constraint:exclude_content_id")
if c.author_id and c.author_id in exclude_author_ids:
hits.append("constraint:exclude_author_id")
if c.template_id and c.template_id in exclude_template_ids:
hits.append("constraint:exclude_template_id")
flags = set([str(x) for x in (c.risk_flags or []) if x is not None and str(x).strip() != ""])
# 全场景必挡
if "block_health_medical" in flags:
hits.append("block_health_medical")
# 与用户阶段相关
if u_stage == "unknown" and "unsafe_for_stage_unknown" in flags:
hits.append("unsafe_for_stage_unknown")
if u_stage == "parenting" and "unsafe_for_stage_parenting" in flags:
hits.append("unsafe_for_stage_parenting")
# 与用户情绪相关
if emotion_low and "unsafe_for_emotion_low" in flags:
hits.append("unsafe_for_emotion_low")
# 跨维度规则unknown stage + parenting_pressure 强命中 + 高个性化
if u_stage == "unknown":
try:
need_val = float(c.need_suitability.get("parenting_pressure", 0.0))
except Exception:
need_val = 0.0
if need_val >= 1.0 and float(getattr(c, "personalization_power", 0.0)) >= 1.0:
hits.append("rule:unknown_stage_parenting_pressure_power1")
if hits:
removed_count += 1
# 单条去重后再计数,避免同 key 重复
uniq_hits = sorted(set(hits))
hits_by_content_id[cid] = uniq_hits
_count_hits(hit_counts, uniq_hits)
continue
hits_by_content_id[cid] = []
kept.append(c)
return HardFilterResult(
kept_items=kept,
removed_count=int(removed_count),
risk_filtered_count_by_flag=dict(hit_counts),
hits_by_content_id=hits_by_content_id,
)

View File

@@ -0,0 +1,396 @@
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime
from typing import Any, Optional
from app.features.personalized_reco.content_repository.interface import ContentRepository
from app.features.personalized_reco.content_repository.types import ContentProfileDTO, normalize_locale
from app.features.personalized_reco.observability.builder import RecoMetaBuilder
from app.features.personalized_reco.reco_engine.defaults import get_default_engine_config
from app.features.personalized_reco.reco_engine.hard_filter import hard_filter
from app.features.personalized_reco.reco_engine.types import RecoConstraints, RecoEngineConfig, RecoEngineResult, RecommendedItem, Scene
from app.features.personalized_reco.reco_engine.utils import (
clamp_personalization_power,
merge_exclude_ids,
normalize_or_default_locale,
)
from app.features.personalized_reco.rerank_freqcap.rerank import rerank_and_freqcap
from app.features.personalized_reco.rerank_freqcap.types import ScoredCandidate
from app.features.personalized_reco.scoring.defaults import get_default_config as get_default_score_config
from app.features.personalized_reco.scoring.score import score_content
logger = logging.getLogger(__name__)
def _safe_int(value: Any, *, default: int = 0) -> int:
try:
n = int(value)
except Exception:
return int(default)
return int(n)
def _light_score_summary(score_result: Any) -> dict[str, Any]:
"""
轻量 explanations只保留少量关键字段避免 payload 过大。
"""
bd = getattr(score_result, "breakdown", None)
if bd is None:
return {}
def _get(name: str) -> Optional[float]:
v = getattr(bd, name, None)
if v is None:
return None
try:
f = float(v)
except Exception:
return None
if f != f:
return None
return f
out: dict[str, Any] = {
"missing_fields": list(getattr(bd, "missing_fields", []) or []),
"S_core": _get("S_core"),
"S_personal": _get("S_personal"),
"P_uncertainty": _get("P_uncertainty"),
"P_risk": _get("P_risk"),
"P_widget_emotion_out_of_range": _get("P_widget_emotion_out_of_range"),
}
# 删除 None减少噪音
return {k: v for k, v in out.items() if v is not None and v != []}
def _apply_fallback_level_to_content(content: ContentProfileDTO, *, fallback_level: int) -> ContentProfileDTO:
"""
对内容做防御式一致性处理(与回退梯度一致)。
"""
p2 = clamp_personalization_power(content.personalization_power, fallback_level=fallback_level)
if p2 == content.personalization_power:
return content
return content.model_copy(update={"personalization_power": float(p2)})
def _merge_counter(dst: dict[str, int], src: dict[str, Any]) -> None:
for k, v in (src or {}).items():
try:
n = int(v)
except Exception:
n = 0
dst[str(k)] = int(dst.get(str(k), 0)) + max(0, int(n))
async def recommend(
*,
repo: ContentRepository,
scene: Scene,
user_profile: object,
already_recommended_ids: list[Any],
touched_or_viewed_ids: list[Any],
k: int,
now: datetime,
locale: Optional[str] = None,
constraints: Optional[RecoConstraints] = None,
config: Optional[RecoEngineConfig] = None,
) -> RecoEngineResult:
"""
Reco Engine 主入口:编排候选→过滤→打分→重排→回退,并输出 items + meta。
"""
cfg = config or get_default_engine_config(scene)
cons = constraints or RecoConstraints()
k_i = max(0, _safe_int(k, default=0))
meta_builder = RecoMetaBuilder(scene=scene, user_profile=user_profile, k=k_i, now=now)
if k_i <= 0:
meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0)
meta_builder.set_config_snapshot({"engine_note": "k<=0直接返回空结果"})
return RecoEngineResult(items=[], meta=meta_builder.build())
# locale默认 en严格校验仅支持 en/tc
raw_locale = normalize_or_default_locale(locale)
try:
effective_locale = normalize_locale(raw_locale)
except Exception as e:
meta_builder.set_config_snapshot({"error": str(e), "stage": "normalize_locale", "locale": raw_locale})
meta_builder.set_candidate_pool_size_raw(0).set_after_hard_filter(0).set_after_dedup(0).set_after_freqcap(0).set_served_k(0).set_fallback_level_final(0)
return RecoEngineResult(items=[], meta=meta_builder.build())
# 聚合统计(跨回退层级累加,确保 meta 单调性成立)
raw_total = 0
after_hard_total = 0
after_dedup_total = 0
after_freqcap_total = 0
risk_counts_total: dict[str, int] = defaultdict(int)
freqcap_counts_total: dict[str, int] = defaultdict(int)
fallback_trace: list[dict[str, Any]] = []
selected: list[ScoredCandidate] = []
selected_level_by_id: dict[int, int] = {}
last_fallback_level = 0
last_reason = None
for level in [0, 1, 2, 3]:
last_fallback_level = int(level)
k_remaining = max(0, k_i - len(selected))
if k_remaining <= 0:
break
# Feed允许不足且不补齐时拿到任何结果就停止
if scene == "feed" and cfg.feed_allow_partial and (not cfg.feed_fill_with_fallback) and len(selected) > 0:
break
# exclude_idsalready/touched + constraints.exclude + 已选内容(避免跨层重复)
exclude_ids = merge_exclude_ids(
already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected],
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
extra_exclude_content_ids=list(cons.exclude_content_ids or []),
)
multiplier = int(cfg.candidate_multiplier_feed if scene == "feed" else cfg.candidate_multiplier_push_widget)
base_limit = max(int(cfg.min_candidates_per_level), int(k_remaining) * max(1, int(multiplier)))
if cons.max_candidates_limit is not None and int(cons.max_candidates_limit) > 0:
limit = min(base_limit, int(cons.max_candidates_limit))
else:
limit = base_limit
# 1) Candidate
try:
cands = await repo.fetch_candidates(
scene=scene,
user_profile=user_profile,
fallback_level=int(level),
limit=int(limit),
locale=str(effective_locale),
exclude_content_ids=exclude_ids,
)
except Exception as e:
logger.exception("fetch_candidates 失败:%s", e)
last_reason = "error:fetch_candidates"
fallback_trace.append(
{
"level": int(level),
"raw": 0,
"after_hard": 0,
"after_dedup": 0,
"after_freqcap": 0,
"served_total": len(selected),
"error": str(e),
}
)
continue
raw_total += len(cands)
if not cands:
last_reason = "pool_empty"
fallback_trace.append(
{
"level": int(level),
"raw": 0,
"after_hard": 0,
"after_dedup": 0,
"after_freqcap": 0,
"served_total": len(selected),
"reason": "pool_empty",
}
)
continue
# 2) Hard Filter
hf = hard_filter(scene=scene, user_profile=user_profile, candidates=cands, constraints=cons)
kept = [x for x in hf.kept_items if isinstance(x, ContentProfileDTO)]
after_hard_total += len(kept)
_merge_counter(risk_counts_total, hf.risk_filtered_count_by_flag)
if not kept:
last_reason = "hard_filter_all"
fallback_trace.append(
{
"level": int(level),
"raw": len(cands),
"after_hard": 0,
"after_dedup": 0,
"after_freqcap": 0,
"served_total": len(selected),
"reason": "hard_filter_all",
}
)
continue
# 3) Soft Scoring
score_cfg = get_default_score_config(scene)
if scene == "push":
# Push强制启用不确定性惩罚与 spec 对齐)
score_cfg = score_cfg.model_copy(update={"enable_uncertainty_penalty": True})
scored: list[ScoredCandidate] = []
for c in kept:
c2 = _apply_fallback_level_to_content(c, fallback_level=int(level))
try:
s = score_content(scene=scene, user_profile=user_profile, content_profile=c2, config=score_cfg, pass_filters=True, now=now)
except Exception as e:
# 单条异常不影响整体
logger.exception("score_content 失败 content_id=%s%s", getattr(c2, "content_id", None), e)
continue
cid = int(c2.content_id)
hits = hf.hits_by_content_id.get(cid, [])
extra: dict[str, Any] = {
"text": c2.text,
"fallback_level_used": int(level),
}
if cfg.enable_explanations:
extra["hard_filter_hits"] = hits
extra["score_summary"] = _light_score_summary(s)
scored.append(
ScoredCandidate(
content_id=cid,
final_score=float(getattr(s, "final_score", 0.0)),
author_id=c2.author_id,
template_id=c2.template_id,
content_profile=c2,
extra=extra,
)
)
if not scored:
last_reason = "empty_after_scoring"
fallback_trace.append(
{
"level": int(level),
"raw": len(cands),
"after_hard": len(kept),
"after_dedup": 0,
"after_freqcap": 0,
"served_total": len(selected),
"reason": "empty_after_scoring",
}
)
continue
# 4) Rerank/Freqcap
try:
rer = rerank_and_freqcap(
scene=scene,
scored_candidates=scored,
already_recommended_ids=list(already_recommended_ids or []) + [int(x.content_id) for x in selected],
touched_or_viewed_ids=list(touched_or_viewed_ids or []),
k=int(k_remaining),
recent_author_ids=cons.recent_author_ids,
recent_template_ids=cons.recent_template_ids,
)
except Exception as e:
logger.exception("rerank_and_freqcap 失败:%s", e)
last_reason = "error:rerank_and_freqcap"
fallback_trace.append(
{
"level": int(level),
"raw": len(cands),
"after_hard": len(kept),
"after_dedup": 0,
"after_freqcap": 0,
"served_total": len(selected),
"error": str(e),
}
)
continue
after_dedup_total += int(rer.meta.candidate_pool_size_after_dedup)
after_freqcap_total += int(rer.meta.candidate_pool_size_after_freqcap)
_merge_counter(freqcap_counts_total, rer.meta.freqcap_filtered_counts)
served_level = list(rer.ranked_items or [])[:k_remaining]
if not served_level:
last_reason = "freqcap_all"
fallback_trace.append(
{
"level": int(level),
"raw": len(cands),
"after_hard": len(kept),
"after_dedup": int(rer.meta.candidate_pool_size_after_dedup),
"after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap),
"served_total": len(selected),
"reason": "freqcap_all",
}
)
continue
for it in served_level:
cid = int(it.content_id)
selected.append(it)
selected_level_by_id[cid] = int(level)
last_reason = None
fallback_trace.append(
{
"level": int(level),
"raw": len(cands),
"after_hard": len(kept),
"after_dedup": int(rer.meta.candidate_pool_size_after_dedup),
"after_freqcap": int(rer.meta.candidate_pool_size_after_freqcap),
"served_total": len(selected),
"served_added": len(served_level),
}
)
if len(selected) >= k_i:
break
# 组装输出 items按 selected 顺序)
items: list[RecommendedItem] = []
for c in selected[:k_i]:
cid = int(c.content_id)
text = ""
if isinstance(c.extra, dict):
text = str(c.extra.get("text") or "")
explanations = None
if cfg.enable_explanations and isinstance(c.extra, dict):
explanations = {
"fallback_level_used": c.extra.get("fallback_level_used"),
"hard_filter_hits": c.extra.get("hard_filter_hits"),
"score_summary": c.extra.get("score_summary"),
}
items.append(
RecommendedItem(
content_id=cid,
text=text,
final_score=float(c.final_score),
fallback_level_final=int(selected_level_by_id.get(cid, last_fallback_level)),
explanations=explanations,
)
)
served_k = len(items)
# meta使用聚合统计确保单调性约束成立raw>=after_hard>=after_dedup>=after_freqcap>=served_k
# 注意:聚合统计理论上可能出现 after_* > raw_total例如 repo 返回重复/异常),此处交由 builder 防御修正
meta_builder.set_candidate_pool_size_raw(int(raw_total))
meta_builder.set_after_hard_filter(int(after_hard_total), risk_filtered_count_by_flag=dict(risk_counts_total))
meta_builder.set_after_dedup(int(after_dedup_total))
meta_builder.set_after_freqcap(int(after_freqcap_total), freqcap_filtered_counts=dict(freqcap_counts_total))
meta_builder.set_served_k(int(served_k))
meta_builder.set_fallback_level_final(int(last_fallback_level), reason=last_reason)
meta_builder.set_config_snapshot(
{
"fallback_trace": fallback_trace,
"engine_config": cfg.model_dump(),
"constraints": cons.model_dump(),
"locale": effective_locale,
}
)
return RecoEngineResult(items=items, meta=meta_builder.build())

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
from app.features.personalized_reco.observability.types import RecoMeta
Scene = Literal["feed", "push", "widget"]
class RecoConstraints(BaseModel):
"""
推荐请求的可选约束(调用方可按需传入)。
"""
exclude_content_ids: list[int] = Field(default_factory=list)
exclude_author_ids: list[str] = Field(default_factory=list)
exclude_template_ids: list[str] = Field(default_factory=list)
# 候选池上限(用于资源保护)
max_candidates_limit: Optional[int] = None
# Push/Widget 作者/模板冷却窗口内的历史集合(增强频控输入)
# 说明若不提供Nonererank_freqcap 会记录缺失并跳过该维度过滤
recent_author_ids: Optional[list[str]] = None
recent_template_ids: Optional[list[str]] = None
class RecoEngineConfig(BaseModel):
"""
引擎级配置V1 可调参项)。
"""
# Feed 是否允许 served_k < k允许不足
feed_allow_partial: bool = True
# Feed 是否在不足时继续回退补齐
feed_fill_with_fallback: bool = True
# 候选拉取倍率limit = min(max_candidates_limit, k * multiplier)
candidate_multiplier_feed: int = 10
candidate_multiplier_push_widget: int = 30
# 每层回退的最大候选数量下限(避免 k=1 但候选过少)
min_candidates_per_level: int = 30
# explanations 默认开启(但应保持轻量)
enable_explanations: bool = True
class RecommendedItem(BaseModel):
"""
引擎最终下发的推荐项。
"""
content_id: int
text: str
final_score: float
fallback_level_final: int
# 解释信息:默认开启,但建议保持轻量(避免 payload 过大)
explanations: Optional[dict[str, Any]] = None
class RecoEngineResult(BaseModel):
"""
引擎输出容器items + meta。
"""
items: list[RecommendedItem] = Field(default_factory=list)
meta: RecoMeta
class HardFilterResult(BaseModel):
"""
Hard Filter 输出。
"""
kept_items: list[Any] = Field(default_factory=list)
removed_count: int = 0
risk_filtered_count_by_flag: dict[str, int] = Field(default_factory=dict)
# 每条内容的命中信息(仅用于 explanations默认可为空
hits_by_content_id: dict[int, list[str]] = Field(default_factory=dict)
class RecommendRequest(BaseModel):
"""
内部便捷结构(单测/集成时可用)。
"""
scene: Scene
user_profile: Any
already_recommended_ids: list[Any] = Field(default_factory=list)
touched_or_viewed_ids: list[Any] = Field(default_factory=list)
k: int = 1
now: datetime
locale: Optional[str] = None
constraints: Optional[RecoConstraints] = None
config: Optional[RecoEngineConfig] = None

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Any, Iterable, Optional
def normalize_int_id_list(mixed_ids: Iterable[Any]) -> list[int]:
"""
将混合类型的 id 列表归一化为 int 列表。
规则:
- int/可转 int 的 str -> int
- 其他None/空字符串/不可解析)忽略
"""
out: list[int] = []
for x in mixed_ids or []:
if x is None:
continue
if isinstance(x, bool):
# 避免 True/False 被当作 1/0
continue
try:
s = str(x).strip()
if s == "":
continue
out.append(int(s))
except Exception:
continue
return out
def merge_exclude_ids(
*,
already_recommended_ids: Iterable[Any],
touched_or_viewed_ids: Iterable[Any],
extra_exclude_content_ids: Optional[Iterable[int]] = None,
) -> list[int]:
"""
合并并去重排除 id保持首次出现顺序
"""
merged = list(normalize_int_id_list(list(already_recommended_ids or []) + list(touched_or_viewed_ids or [])))
if extra_exclude_content_ids:
merged += [int(x) for x in extra_exclude_content_ids if x is not None]
seen: set[int] = set()
out: list[int] = []
for cid in merged:
if cid in seen:
continue
seen.add(cid)
out.append(cid)
return out
def normalize_or_default_locale(locale: Optional[str]) -> str:
"""
locale 防御式归一化:
- 未传/空 -> 默认 "en"
- 其他 -> 原样返回,由下游 normalize_locale 做严格校验
"""
if locale is None:
return "en"
raw = str(locale).strip()
return raw or "en"
def clamp_personalization_power(power: Any, *, fallback_level: int) -> float:
"""
按回退层级对 personalization_power 做防御式约束。
- L0不改
- L1<= 0.5
- L2/L3= 0
"""
try:
p = float(power)
except Exception:
p = 0.0
if p != p:
p = 0.0
if int(fallback_level) >= 2:
return 0.0
if int(fallback_level) >= 1:
return min(p, 0.5)
return p