growth_category_tree.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. """Build the growth heat map from global_category_v2 and its daily weights."""
  2. from __future__ import annotations
  3. from typing import Any
  4. from sqlalchemy import select
  5. from supply_infra.db.models.global_category_content_weight import (
  6. GlobalCategoryContentWeight,
  7. )
  8. from supply_infra.db.models.global_v2 import GlobalCategoryV2
  9. from supply_infra.db.repositories.global_category_channel_content_rank_repo import (
  10. GlobalCategoryChannelContentRankRepository,
  11. )
  12. from supply_infra.db.repositories.global_category_content_weight_repo import (
  13. GlobalCategoryContentWeightRepository,
  14. )
  15. from supply_infra.db.session import get_session
  16. GROWTH_DIM_META: list[dict[str, str]] = [
  17. {"key": "read_rate", "label": "阅读率"},
  18. {"key": "avg_read_rate", "label": "平均阅读率"},
  19. {"key": "like_rate", "label": "点赞率"},
  20. ]
  21. GROWTH_DIM_KEYS = frozenset(dim["key"] for dim in GROWTH_DIM_META)
  22. def _normalize_parent_id(parent_id: int | None, category_ids: set[int]) -> int | None:
  23. if parent_id in (None, 0) or int(parent_id) not in category_ids:
  24. return None
  25. return int(parent_id)
  26. def _build_growth_tree(
  27. categories: list[GlobalCategoryV2],
  28. weights: list[GlobalCategoryContentWeight],
  29. ) -> list[dict[str, Any]]:
  30. category_ids = {int(category.stable_id) for category in categories}
  31. children_by_parent: dict[int | None, list[GlobalCategoryV2]] = {}
  32. for category in categories:
  33. parent_id = _normalize_parent_id(category.parent_stable_id, category_ids)
  34. children_by_parent.setdefault(parent_id, []).append(category)
  35. for children in children_by_parent.values():
  36. children.sort(key=lambda row: (row.level or 0, int(row.stable_id)))
  37. weight_by_stable_id = {int(row.stable_id): row for row in weights}
  38. def to_node(category: GlobalCategoryV2, ancestors: frozenset[int]) -> dict[str, Any]:
  39. stable_id = int(category.stable_id)
  40. weight = weight_by_stable_id.get(stable_id)
  41. count = int(weight.source_element_count or 0) if weight else 0
  42. next_ancestors = ancestors | {stable_id}
  43. children = [
  44. to_node(child, next_ancestors)
  45. for child in children_by_parent.get(stable_id, [])
  46. if int(child.stable_id) not in next_ancestors
  47. ]
  48. return {
  49. "id": stable_id,
  50. "name": category.name,
  51. "level": category.level,
  52. "description": category.description,
  53. "weights": {
  54. "read_rate": float(weight.read_rate_score) if weight else None,
  55. "avg_read_rate": float(weight.avg_read_rate_score) if weight else None,
  56. "like_rate": float(weight.like_rate_score) if weight else None,
  57. },
  58. "counts": {
  59. "read_rate": count,
  60. "avg_read_rate": count,
  61. "like_rate": count,
  62. },
  63. "account_uid_count": (
  64. int(getattr(weight, "account_uid_count", 0) or 0) if weight else 0
  65. ),
  66. "channel_content_id_count": (
  67. int(getattr(weight, "channel_content_id_count", 0) or 0) if weight else 0
  68. ),
  69. "cal_fans_num_sum": (
  70. float(getattr(weight, "cal_fans_num_sum", 0.0) or 0.0) if weight else 0.0
  71. ),
  72. "children": children,
  73. }
  74. return [to_node(root, frozenset()) for root in children_by_parent.get(None, [])]
  75. def build_growth_category_tree(biz_dt: str | None = None) -> dict[str, Any]:
  76. """Return the V2 category tree with the requested/latest complete daily scores."""
  77. with get_session() as session:
  78. categories = list(
  79. session.scalars(
  80. select(GlobalCategoryV2).order_by(
  81. GlobalCategoryV2.level, GlobalCategoryV2.stable_id
  82. )
  83. ).all()
  84. )
  85. weight_repo = GlobalCategoryContentWeightRepository(session)
  86. resolved_dt = biz_dt or weight_repo.get_latest_completed_biz_dt()
  87. weights = weight_repo.list_completed_models_by_biz_dt(resolved_dt) if resolved_dt else []
  88. nodes = _build_growth_tree(categories, weights)
  89. return {
  90. "biz_dt": resolved_dt,
  91. "dims": GROWTH_DIM_META,
  92. "nodes": nodes,
  93. }
  94. def list_growth_category_channel_contents(
  95. *,
  96. stable_id: int,
  97. metric: str,
  98. biz_dt: str | None = None,
  99. ) -> dict[str, Any]:
  100. if metric not in GROWTH_DIM_KEYS:
  101. raise ValueError(f"Unsupported growth metric: {metric}")
  102. with get_session() as session:
  103. weight_repo = GlobalCategoryContentWeightRepository(session)
  104. resolved_dt = biz_dt or weight_repo.get_latest_completed_biz_dt()
  105. rows = (
  106. GlobalCategoryChannelContentRankRepository(session).list_top(
  107. biz_dt=resolved_dt,
  108. stable_id=stable_id,
  109. metric_type=metric,
  110. )
  111. if resolved_dt
  112. else []
  113. )
  114. items = [
  115. {
  116. "rank_no": int(row.rank_no),
  117. "channel_content_id": row.channel_content_id,
  118. "source_element_id": int(row.source_element_id),
  119. "source_category_stable_id": int(row.source_category_stable_id),
  120. "contribution": float(row.contribution),
  121. "metric_value": float(row.metric_value),
  122. "weighted_score": float(row.weighted_score),
  123. "weighted_share": float(row.weighted_share),
  124. }
  125. for row in rows
  126. ]
  127. return {
  128. "biz_dt": resolved_dt,
  129. "stable_id": stable_id,
  130. "metric": metric,
  131. "total": len(items),
  132. "items": items,
  133. }