|
|
@@ -0,0 +1,206 @@
|
|
|
+"""Compute daily Top 10 channel-content contributions for every category node."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import argparse
|
|
|
+import logging
|
|
|
+from collections import defaultdict
|
|
|
+from datetime import timedelta
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+from supply_infra.db.repositories.global_category_channel_content_rank_repo import (
|
|
|
+ GlobalCategoryChannelContentRankRepository,
|
|
|
+)
|
|
|
+from supply_infra.db.session import get_session
|
|
|
+from supply_infra.pipeline.dates import china_now, validate_biz_dt
|
|
|
+from supply_infra.scheduler.cli_result import run_cli
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+_TOP_N = 10
|
|
|
+_METRICS = ("read_rate", "avg_read_rate", "like_rate")
|
|
|
+
|
|
|
+
|
|
|
+def _resolve_biz_dt(biz_dt: str | None) -> str:
|
|
|
+ if biz_dt:
|
|
|
+ return validate_biz_dt(biz_dt)
|
|
|
+ return (china_now() - timedelta(days=1)).strftime("%Y%m%d")
|
|
|
+
|
|
|
+
|
|
|
+def _postorder(topology: dict[int, int | None]) -> list[int]:
|
|
|
+ children: dict[int, list[int]] = defaultdict(list)
|
|
|
+ for stable_id, parent_id in topology.items():
|
|
|
+ if parent_id is not None and parent_id in topology:
|
|
|
+ children[parent_id].append(stable_id)
|
|
|
+ for child_ids in children.values():
|
|
|
+ child_ids.sort()
|
|
|
+
|
|
|
+ order: list[int] = []
|
|
|
+ visiting: set[int] = set()
|
|
|
+ visited: set[int] = set()
|
|
|
+
|
|
|
+ def visit(stable_id: int) -> None:
|
|
|
+ if stable_id in visited:
|
|
|
+ return
|
|
|
+ if stable_id in visiting:
|
|
|
+ raise RuntimeError(f"Category cycle detected at stable_id={stable_id}")
|
|
|
+ visiting.add(stable_id)
|
|
|
+ for child_id in children.get(stable_id, []):
|
|
|
+ visit(child_id)
|
|
|
+ visiting.remove(stable_id)
|
|
|
+ visited.add(stable_id)
|
|
|
+ order.append(stable_id)
|
|
|
+
|
|
|
+ for stable_id in sorted(topology):
|
|
|
+ visit(stable_id)
|
|
|
+ return order
|
|
|
+
|
|
|
+
|
|
|
+def _is_better_candidate(
|
|
|
+ candidate: dict[str, float | int],
|
|
|
+ current: dict[str, float | int] | None,
|
|
|
+) -> bool:
|
|
|
+ """Prefer the highest contribution, then the smallest source ID for stable ties."""
|
|
|
+ if current is None:
|
|
|
+ return True
|
|
|
+ candidate_contribution = float(candidate["contribution"])
|
|
|
+ current_contribution = float(current["contribution"])
|
|
|
+ if candidate_contribution != current_contribution:
|
|
|
+ return candidate_contribution > current_contribution
|
|
|
+ return int(candidate["source_element_id"]) < int(current["source_element_id"])
|
|
|
+
|
|
|
+
|
|
|
+def _build_top_rows(
|
|
|
+ *,
|
|
|
+ biz_dt: str,
|
|
|
+ stable_id: int,
|
|
|
+ content_by_id: dict[str, dict[str, float | int]],
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
+ rows: list[dict[str, Any]] = []
|
|
|
+ for metric in _METRICS:
|
|
|
+ ranked = sorted(
|
|
|
+ content_by_id.items(),
|
|
|
+ key=lambda item: (
|
|
|
+ -(float(item[1][metric]) * float(item[1]["contribution"])),
|
|
|
+ item[0],
|
|
|
+ ),
|
|
|
+ )[:_TOP_N]
|
|
|
+ node_weighted_sum = sum(
|
|
|
+ float(values[metric]) * float(values["contribution"])
|
|
|
+ for values in content_by_id.values()
|
|
|
+ )
|
|
|
+ for index, (channel_content_id, values) in enumerate(ranked, start=1):
|
|
|
+ contribution = float(values["contribution"])
|
|
|
+ metric_value = float(values[metric])
|
|
|
+ weighted_score = metric_value * contribution
|
|
|
+ rows.append(
|
|
|
+ {
|
|
|
+ "biz_dt": biz_dt,
|
|
|
+ "stable_id": stable_id,
|
|
|
+ "metric_type": metric,
|
|
|
+ "rank_no": index,
|
|
|
+ "channel_content_id": channel_content_id,
|
|
|
+ "source_element_id": int(values["source_element_id"]),
|
|
|
+ "source_category_stable_id": int(values["source_category_stable_id"]),
|
|
|
+ "contribution": contribution,
|
|
|
+ "metric_value": metric_value,
|
|
|
+ "weighted_score": weighted_score,
|
|
|
+ "weighted_share": (
|
|
|
+ weighted_score / node_weighted_sum if node_weighted_sum else 0.0
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return rows
|
|
|
+
|
|
|
+
|
|
|
+def compute_global_category_channel_content_rankings(
|
|
|
+ biz_dt: str | None = None,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ resolved_dt = _resolve_biz_dt(biz_dt)
|
|
|
+ with get_session() as session:
|
|
|
+ repository = GlobalCategoryChannelContentRankRepository(session)
|
|
|
+ topology_rows = repository.load_daily_topology(resolved_dt)
|
|
|
+ direct_rows = repository.load_direct_content_metrics(resolved_dt)
|
|
|
+
|
|
|
+ if not topology_rows:
|
|
|
+ raise RuntimeError(f"No category weight snapshot for biz_dt={resolved_dt}")
|
|
|
+ incomplete = [int(row["stable_id"]) for row in topology_rows if row["status"] != "completed"]
|
|
|
+ if incomplete:
|
|
|
+ raise RuntimeError(
|
|
|
+ f"Category weights are incomplete for biz_dt={resolved_dt}: {incomplete[:10]}"
|
|
|
+ )
|
|
|
+
|
|
|
+ topology = {
|
|
|
+ int(row["stable_id"]): (
|
|
|
+ int(row["parent_stable_id"]) if row["parent_stable_id"] is not None else None
|
|
|
+ )
|
|
|
+ for row in topology_rows
|
|
|
+ }
|
|
|
+ direct_by_node: dict[int, dict[str, dict[str, float | int]]] = defaultdict(dict)
|
|
|
+ for source in direct_rows:
|
|
|
+ stable_id = int(source["stable_id"])
|
|
|
+ if stable_id not in topology:
|
|
|
+ continue
|
|
|
+ channel_content_id = str(source["channel_content_id"])
|
|
|
+ candidate: dict[str, float | int] = {
|
|
|
+ "source_element_id": int(source["source_element_id"]),
|
|
|
+ "source_category_stable_id": stable_id,
|
|
|
+ "contribution": float(source["contribution"]),
|
|
|
+ **{metric: float(source[metric]) for metric in _METRICS},
|
|
|
+ }
|
|
|
+ current = direct_by_node[stable_id].get(channel_content_id)
|
|
|
+ if _is_better_candidate(candidate, current):
|
|
|
+ direct_by_node[stable_id][channel_content_id] = candidate
|
|
|
+
|
|
|
+ candidates_by_node = dict(direct_by_node)
|
|
|
+ result_rows: list[dict[str, Any]] = []
|
|
|
+ nodes_with_content = 0
|
|
|
+ for stable_id in _postorder(topology):
|
|
|
+ content_by_id = candidates_by_node.pop(stable_id, {})
|
|
|
+ if content_by_id:
|
|
|
+ nodes_with_content += 1
|
|
|
+ result_rows.extend(
|
|
|
+ _build_top_rows(
|
|
|
+ biz_dt=resolved_dt,
|
|
|
+ stable_id=stable_id,
|
|
|
+ content_by_id=content_by_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ parent_id = topology[stable_id]
|
|
|
+ if parent_id is None or parent_id not in topology:
|
|
|
+ continue
|
|
|
+ parent_content = candidates_by_node.setdefault(parent_id, {})
|
|
|
+ for channel_content_id, candidate in content_by_id.items():
|
|
|
+ current = parent_content.get(channel_content_id)
|
|
|
+ if _is_better_candidate(candidate, current):
|
|
|
+ parent_content[channel_content_id] = candidate
|
|
|
+
|
|
|
+ with get_session() as session:
|
|
|
+ inserted = GlobalCategoryChannelContentRankRepository(session).replace_day(
|
|
|
+ resolved_dt, result_rows
|
|
|
+ )
|
|
|
+
|
|
|
+ result = {
|
|
|
+ "success": True,
|
|
|
+ "biz_dt": resolved_dt,
|
|
|
+ "top_n": _TOP_N,
|
|
|
+ "nodes": len(topology),
|
|
|
+ "nodes_with_content": nodes_with_content,
|
|
|
+ "source_rows": len(direct_rows),
|
|
|
+ "inserted": inserted,
|
|
|
+ }
|
|
|
+ logger.info("Global category channel-content rankings completed: %s", result)
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ parser = argparse.ArgumentParser(
|
|
|
+ description="Compute daily category channel-content Top 10 rankings"
|
|
|
+ )
|
|
|
+ parser.add_argument("biz_dt", nargs="?", help="Business date YYYYMMDD")
|
|
|
+ args = parser.parse_args()
|
|
|
+ run_cli(
|
|
|
+ lambda: compute_global_category_channel_content_rankings(args.biz_dt),
|
|
|
+ label="compute_global_category_channel_content_rankings",
|
|
|
+ )
|