新功能:个性化推荐算法

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,20 @@
"""
个性化推荐Observability 子模块(可观测性与打点载荷)
说明:
- 只负责统一 `RecoMeta` 结构与构建builder不负责埋点 SDK/落库/上报实现。
- `RecoMeta` 需要同时被 `reco-engine` 与 `integration-api-worker` 使用。
"""
from .builder import RecoMetaBuilder
from .types import MissingFields, RecoMeta
from .utils import compute_empty_reason, compute_missing_fields
__all__ = [
"MissingFields",
"RecoMeta",
"RecoMetaBuilder",
"compute_empty_reason",
"compute_missing_fields",
]

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional
from app.features.personalized_reco.observability.types import MissingFields, RecoMeta, Scene
from app.features.personalized_reco.observability.utils import compute_empty_reason, compute_missing_fields
logger = logging.getLogger(__name__)
def _non_negative_int(value: Any, *, default: int = 0) -> int:
try:
n = int(value)
except Exception:
return int(default)
return max(0, int(n))
class RecoMetaBuilder:
"""
在推荐 pipeline 中逐阶段填充 RecoMeta避免“散落字段/散落日志”。
说明V1
- set 调用允许任意顺序build 时会做防御式兜底与单调性修正
- 单调性约束raw >= after_hard_filter >= after_dedup >= after_freqcap >= served_k
"""
def __init__(self, *, scene: Scene, user_profile: object, k: int, now: Optional[datetime] = None) -> None:
self.scene: Scene = scene
self.user_profile = user_profile
self.k = _non_negative_int(k, default=0)
self.now = now
self._raw: Optional[int] = None
self._after_hard: Optional[int] = None
self._after_dedup: Optional[int] = None
self._after_freqcap: Optional[int] = None
self._served_k: Optional[int] = None
self._fallback_level_final: Optional[int] = None
self._risk_filtered_count_by_flag: dict[str, int] = {}
self._freqcap_filtered_counts: dict[str, int] = {}
self._config_snapshot: dict[str, Any] = {}
def set_candidate_pool_size_raw(self, n: Any) -> "RecoMetaBuilder":
self._raw = _non_negative_int(n)
return self
def set_after_hard_filter(self, n: Any, *, risk_filtered_count_by_flag: Optional[dict[str, Any]] = None) -> "RecoMetaBuilder":
self._after_hard = _non_negative_int(n)
if risk_filtered_count_by_flag:
self._risk_filtered_count_by_flag = {str(k): _non_negative_int(v) for k, v in risk_filtered_count_by_flag.items()}
return self
def set_after_dedup(self, n: Any) -> "RecoMetaBuilder":
self._after_dedup = _non_negative_int(n)
return self
def set_after_freqcap(self, n: Any, *, freqcap_filtered_counts: Optional[dict[str, Any]] = None) -> "RecoMetaBuilder":
self._after_freqcap = _non_negative_int(n)
if freqcap_filtered_counts:
self._freqcap_filtered_counts = {str(k): _non_negative_int(v) for k, v in freqcap_filtered_counts.items()}
return self
def set_fallback_level_final(self, level: Any, *, reason: Optional[str] = None) -> "RecoMetaBuilder":
# reason 预留V1 先不入 meta可放入 config_snapshot 或后续字段)
self._fallback_level_final = _non_negative_int(level, default=0)
if reason:
self._config_snapshot.setdefault("fallback_trigger_reason", str(reason))
return self
def set_served_k(self, n: Any) -> "RecoMetaBuilder":
self._served_k = _non_negative_int(n)
return self
def set_config_snapshot(self, snapshot: dict[str, Any]) -> "RecoMetaBuilder":
self._config_snapshot = dict(snapshot or {})
return self
def build(self) -> RecoMeta:
missing: MissingFields = compute_missing_fields(self.user_profile)
conf_u = getattr(self.user_profile, "profile_confidence", 1.0)
try:
conf_u_f = float(conf_u)
except Exception:
conf_u_f = 1.0
if conf_u_f != conf_u_f:
conf_u_f = 1.0
raw = self._raw if self._raw is not None else 0
after_hard = self._after_hard if self._after_hard is not None else raw
after_dedup = self._after_dedup if self._after_dedup is not None else after_hard
after_freqcap = self._after_freqcap if self._after_freqcap is not None else after_dedup
served_k = self._served_k if self._served_k is not None else 0
# 防御式单调性修正(以最保守值输出)
if after_hard > raw:
logger.debug("after_hard_filter(%s) > raw(%s),已修正为 raw", after_hard, raw)
after_hard = raw
if after_dedup > after_hard:
logger.debug("after_dedup(%s) > after_hard_filter(%s),已修正为 after_hard_filter", after_dedup, after_hard)
after_dedup = after_hard
if after_freqcap > after_dedup:
logger.debug("after_freqcap(%s) > after_dedup(%s),已修正为 after_dedup", after_freqcap, after_dedup)
after_freqcap = after_dedup
if served_k > after_freqcap:
logger.debug("served_k(%s) > after_freqcap(%s),已修正为 after_freqcap", served_k, after_freqcap)
served_k = after_freqcap
fallback_level_final = self._fallback_level_final if self._fallback_level_final is not None else 0
empty_reason = compute_empty_reason(
served_k=served_k,
candidate_pool_size_raw=raw,
candidate_pool_size_after_hard_filter=after_hard,
candidate_pool_size_after_freqcap=after_freqcap,
)
return RecoMeta(
scene=self.scene,
candidate_pool_size_raw=int(raw),
candidate_pool_size_after_hard_filter=int(after_hard),
candidate_pool_size_after_dedup=int(after_dedup),
candidate_pool_size_after_freqcap=int(after_freqcap),
fallback_level_final=int(fallback_level_final),
served_k=int(served_k),
empty_reason=empty_reason,
conf_U=float(conf_u_f),
missing_fields=missing,
risk_filtered_count_by_flag=dict(self._risk_filtered_count_by_flag),
freqcap_filtered_counts=dict(self._freqcap_filtered_counts),
config_snapshot=dict(self._config_snapshot),
)

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
Scene = Literal["feed", "push", "widget"]
EmptyReason = Literal["hard_filter_all", "freqcap_all", "pool_empty", "unknown"]
class MissingFields(BaseModel):
"""
画像字段缺失情况(布尔结构)。
"""
need: bool = False
context: bool = False
emotion: bool = False
class RecoMeta(BaseModel):
"""
推荐模块统一可观测载荷(返回给调用方;调用方负责上报/落库/打点)。
"""
scene: Scene
candidate_pool_size_raw: int = 0
candidate_pool_size_after_hard_filter: int = 0
candidate_pool_size_after_dedup: int = 0
candidate_pool_size_after_freqcap: int = 0
fallback_level_final: int = 0
served_k: int = 0
# served_k=0 时必填served_k>0 时建议为 None
empty_reason: Optional[EmptyReason] = None
conf_U: float = 1.0
missing_fields: MissingFields = Field(default_factory=MissingFields)
# 可选Hard Filter 风险命中统计(按 flag 聚合)
risk_filtered_count_by_flag: dict[str, int] = Field(default_factory=dict)
# 可选Freqcap 过滤统计sentence/author/template
freqcap_filtered_counts: dict[str, int] = Field(default_factory=dict)
# 可选调参快照V1 可先只在内部事件使用)
config_snapshot: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from typing import Optional
from app.features.personalized_reco.observability.types import EmptyReason, MissingFields
def compute_missing_fields(user_profile: object) -> MissingFields:
"""
判定用户画像缺失字段(对齐算法规则 V1.2 口径)。
规则:
- needuser_profile.need 为空对象 {} 或不存在
- contextuser_profile.context 为空对象 {} 或不存在
- emotionuser_profile.emotion_score 为 None 或不存在
"""
need = getattr(user_profile, "need", None)
context = getattr(user_profile, "context", None)
emotion_score = getattr(user_profile, "emotion_score", None)
need_missing = not bool(need)
context_missing = not bool(context)
emotion_missing = emotion_score is None
return MissingFields(need=need_missing, context=context_missing, emotion=emotion_missing)
def compute_empty_reason(
*,
served_k: int,
candidate_pool_size_raw: int,
candidate_pool_size_after_hard_filter: int,
candidate_pool_size_after_freqcap: int,
) -> Optional[EmptyReason]:
"""
判定 empty_reasonserved_k=0 必填)。
规则(对齐 plan
- served_k>0 -> None
- raw==0 -> pool_empty
- raw>0 且 after_hard_filter==0 -> hard_filter_all
- after_freqcap==0 -> freqcap_all
- 其他 -> unknown
"""
if int(served_k) > 0:
return None
raw = int(candidate_pool_size_raw)
after_hard = int(candidate_pool_size_after_hard_filter)
after_freqcap = int(candidate_pool_size_after_freqcap)
if raw == 0:
return "pool_empty"
if raw > 0 and after_hard == 0:
return "hard_filter_all"
if after_freqcap == 0:
return "freqcap_all"
return "unknown"