from __future__ import annotations from typing import Any, Iterable, Optional from app.features.personalized_reco.rerank_freqcap.defaults import get_default_config from app.features.personalized_reco.rerank_freqcap.types import RerankConfig, RerankMeta, RerankResult, ScoredCandidate, Scene from app.features.personalized_reco.rerank_freqcap.utils import as_finite_float, build_tags, clamp, jaccard, normalize_int_id_set def _sort_by_score_desc(cands: list[ScoredCandidate]) -> list[ScoredCandidate]: return sorted(cands, key=lambda x: as_finite_float(x.final_score, default=float("-inf")), reverse=True) def _dedup_by_seen_ids( cands: list[ScoredCandidate], *, seen_ids: set[int], ) -> tuple[list[ScoredCandidate], int]: kept: list[ScoredCandidate] = [] removed = 0 for c in cands: if int(c.content_id) in seen_ids: removed += 1 continue kept.append(c) return kept, removed def _apply_author_template_freqcap( cands: list[ScoredCandidate], *, recent_author_ids: Optional[Iterable[str]], recent_template_ids: Optional[Iterable[str]], ) -> tuple[list[ScoredCandidate], dict[str, int], list[str]]: """ V1 策略: - 若 recent_*_ids 未提供(None),不执行该维度过滤,但在 meta 记录缺失 - 若提供,则执行硬过滤 """ filtered_counts: dict[str, int] = {"author": 0, "template": 0} missing: list[str] = [] author_set: set[str] | None if recent_author_ids is None: author_set = None missing.append("author") else: author_set = set([a for a in recent_author_ids if a is not None and str(a).strip() != ""]) template_set: set[str] | None if recent_template_ids is None: template_set = None missing.append("template") else: template_set = set([t for t in recent_template_ids if t is not None and str(t).strip() != ""]) out: list[ScoredCandidate] = [] for c in cands: if author_set is not None and c.author_id and c.author_id in author_set: filtered_counts["author"] += 1 continue if template_set is not None and c.template_id and c.template_id in template_set: filtered_counts["template"] += 1 continue out.append(c) # 只返回真正生效的维度计数(避免 meta 噪音) effective_counts: dict[str, int] = {} if author_set is not None: effective_counts["author"] = int(filtered_counts["author"]) if template_set is not None: effective_counts["template"] = int(filtered_counts["template"]) missing_sorted = sorted(set(missing)) return out, effective_counts, missing_sorted def _sim(a: ScoredCandidate, b: ScoredCandidate, *, tags_a: set[str], tags_b: set[str]) -> float: # 离散特征版(V1 推荐),对齐 plan.md if int(a.content_id) == int(b.content_id): return 1.0 sim = 0.0 if a.template_id and b.template_id and a.template_id == b.template_id: sim += 0.6 if a.author_id and b.author_id and a.author_id == b.author_id: sim += 0.3 sim += 0.1 * jaccard(tags_a, tags_b) return clamp(sim, 0.0, 1.0) def _mmr_rerank( *, candidates: list[ScoredCandidate], k: int, lam: float, ) -> list[ScoredCandidate]: if k <= 0: return [] if not candidates: return [] lam_f = clamp(as_finite_float(lam, default=0.7), 0.0, 1.0) # 预计算 tags,避免重复构造 tags_map: dict[int, set[str]] = {} for c in candidates: tags_map[int(c.content_id)] = build_tags(c) remaining = _sort_by_score_desc(list(candidates)) selected: list[ScoredCandidate] = [] # Top1:最高分 selected.append(remaining.pop(0)) while remaining and len(selected) < k: best_idx = 0 best_val = float("-inf") for idx, c in enumerate(remaining): rel = as_finite_float(c.final_score, default=float("-inf")) tags_c = tags_map.get(int(c.content_id), set()) max_sim = 0.0 for s in selected: tags_s = tags_map.get(int(s.content_id), set()) max_sim = max(max_sim, _sim(c, s, tags_a=tags_c, tags_b=tags_s)) val = lam_f * float(rel) - (1.0 - lam_f) * float(max_sim) if val > best_val: best_val = val best_idx = idx selected.append(remaining.pop(best_idx)) return selected def rerank_and_freqcap( *, scene: Scene, scored_candidates: list[ScoredCandidate], already_recommended_ids: list[Any], touched_or_viewed_ids: list[Any], k: int, config: Optional[RerankConfig] = None, recent_author_ids: Optional[list[str]] = None, recent_template_ids: Optional[list[str]] = None, ) -> RerankResult: """ 主入口:对 scored_candidates 做去重/频控/重排,输出最终可下发序列。 V1 约定: - 冷却窗口“按天”由调用方保证输入集合已经裁剪到窗口内,本模块以“集合代表窗口内历史”为准 - Feed 默认只做 dedup + MMR;Push/Widget 做 dedup + freqcap + TopK """ cfg = config or get_default_config(scene) # seen_ids = already_recommended_ids ∪ touched_or_viewed_ids seen_ids = normalize_int_id_set(list(already_recommended_ids) + list(touched_or_viewed_ids)) # 先按分数降序,保证 Top1 与 TopK 一致 base_sorted = _sort_by_score_desc(list(scored_candidates)) after_dedup, removed_sentence = _dedup_by_seen_ids(base_sorted, seen_ids=seen_ids) candidate_pool_size_after_dedup = len(after_dedup) missing_history_fields: list[str] = [] freqcap_counts: dict[str, int] = {"sentence": int(removed_sentence)} after_freqcap = after_dedup # Push/Widget:作者/模板冷却(增强项) if scene in {"push", "widget"}: after_freqcap, dim_counts, missing = _apply_author_template_freqcap( after_freqcap, recent_author_ids=recent_author_ids, recent_template_ids=recent_template_ids, ) missing_history_fields = missing freqcap_counts.update(dim_counts) else: # Feed:不强制作者/模板冷却(V1 可选,这里默认跳过) missing_history_fields = [] candidate_pool_size_after_freqcap = len(after_freqcap) ranked: list[ScoredCandidate] if scene == "feed": # MMR 前截断,避免性能问题 top_n = int(cfg.top_n_for_mmr) if int(cfg.top_n_for_mmr) > 0 else len(after_freqcap) mmr_pool = after_freqcap[:top_n] ranked = _mmr_rerank(candidates=mmr_pool, k=int(k), lam=cfg.mmr_lambda) else: ranked = after_freqcap[: max(0, int(k))] meta = RerankMeta( candidate_pool_size_after_dedup=int(candidate_pool_size_after_dedup), candidate_pool_size_after_freqcap=int(candidate_pool_size_after_freqcap), missing_history_fields=missing_history_fields, freqcap_filtered_counts=freqcap_counts, ) return RerankResult(ranked_items=ranked, meta=meta)