新功能:个性化推荐算法
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Reco Engine(推荐引擎编排)。
|
||||
|
||||
该模块负责将候选拉取、硬过滤、软打分、重排/频控、回退梯度串成一个稳定 Pipeline,
|
||||
并输出统一结构:items + meta(可观测字段)。
|
||||
"""
|
||||
|
||||
from app.features.personalized_reco.reco_engine.orchestrator import recommend
|
||||
|
||||
__all__ = ["recommend"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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())
|
||||
|
||||
128
server/app/features/personalized_reco/reco_engine/hard_filter.py
Normal file
128
server/app/features/personalized_reco/reco_engine/hard_filter.py
Normal 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,
|
||||
)
|
||||
|
||||
@@ -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_ids:already/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())
|
||||
|
||||
101
server/app/features/personalized_reco/reco_engine/types.py
Normal file
101
server/app/features/personalized_reco/reco_engine/types.py
Normal 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 作者/模板冷却窗口内的历史集合(增强频控输入)
|
||||
# 说明:若不提供(None),rerank_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
|
||||
|
||||
90
server/app/features/personalized_reco/reco_engine/utils.py
Normal file
90
server/app/features/personalized_reco/reco_engine/utils.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user