category_tree.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. """Build nested category tree from global_tree_category (+ optional weights)."""
  2. from __future__ import annotations
  3. from decimal import Decimal
  4. from typing import Any
  5. from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
  6. from supply_infra.db.models.global_tree_category import GlobalTreeCategory
  7. from supply_infra.db.repositories.category_tree_weight_repo import (
  8. CategoryTreeWeightRepository,
  9. )
  10. from supply_infra.db.repositories.global_tree_category_repo import (
  11. GlobalTreeCategoryRepository,
  12. )
  13. from supply_infra.db.session import get_session
  14. DIM_KEYS: tuple[str, ...] = (
  15. "ext_pop",
  16. "plat_sust_pop",
  17. "plat_ly_pop",
  18. "recent_pop",
  19. "real_rov_7d",
  20. "real_vov_7d",
  21. )
  22. TOTAL_SCORE_KEY = "total_score"
  23. TOTAL_SCORE_DIM_KEYS: tuple[str, ...] = (
  24. "ext_pop",
  25. "plat_sust_pop",
  26. "plat_ly_pop",
  27. "recent_pop",
  28. )
  29. DIM_META: list[dict[str, str]] = [
  30. {"key": TOTAL_SCORE_KEY, "label": "全局热度"},
  31. {"key": "ext_pop", "label": "外部热度"},
  32. {"key": "plat_sust_pop", "label": "平台持续热度"},
  33. {"key": "plat_ly_pop", "label": "去年同期热度"},
  34. {"key": "recent_pop", "label": "近期热度"},
  35. {"key": "real_rov_7d", "label": "真实ROV(7日)"},
  36. {"key": "real_vov_7d", "label": "真实VOV(7日)"},
  37. ]
  38. def _normalize_parent_id(parent_id: int | None) -> int | None:
  39. if parent_id is None or parent_id == 0:
  40. return None
  41. return parent_id
  42. def _to_float(value: Decimal | float | int | None) -> float:
  43. if value is None:
  44. return 0.0
  45. return float(value)
  46. def _weights_payload(row: CategoryTreeWeight | None) -> dict[str, float]:
  47. if row is None:
  48. return {dim: 0.0 for dim in (TOTAL_SCORE_KEY, *DIM_KEYS)}
  49. return {
  50. TOTAL_SCORE_KEY: _to_float(row.total_score),
  51. **{
  52. dim: _to_float(getattr(row, f"{dim}_avg", None))
  53. for dim in DIM_KEYS
  54. },
  55. }
  56. def _counts_payload(row: CategoryTreeWeight | None) -> dict[str, int]:
  57. if row is None:
  58. return {dim: 0 for dim in (TOTAL_SCORE_KEY, *DIM_KEYS)}
  59. return {
  60. TOTAL_SCORE_KEY: sum(
  61. int(getattr(row, f"{dim}_count", 0) or 0) > 0
  62. for dim in TOTAL_SCORE_DIM_KEYS
  63. ),
  64. **{
  65. dim: int(getattr(row, f"{dim}_count", 0) or 0)
  66. for dim in DIM_KEYS
  67. },
  68. }
  69. def _build_children_map(
  70. categories: list[GlobalTreeCategory],
  71. ) -> dict[int | None, list[GlobalTreeCategory]]:
  72. children_map: dict[int | None, list[GlobalTreeCategory]] = {}
  73. for category in categories:
  74. parent_key = _normalize_parent_id(category.parent_id)
  75. children_map.setdefault(parent_key, []).append(category)
  76. for children in children_map.values():
  77. children.sort(key=lambda c: (c.level or 0, c.id))
  78. return children_map
  79. def _to_node(
  80. category: GlobalTreeCategory,
  81. children_map: dict[int | None, list[GlobalTreeCategory]],
  82. weight_by_id: dict[int, CategoryTreeWeight],
  83. ) -> dict[str, Any]:
  84. children = [
  85. _to_node(child, children_map, weight_by_id)
  86. for child in children_map.get(category.id, [])
  87. ]
  88. weight_row = weight_by_id.get(int(category.id))
  89. return {
  90. "id": category.id,
  91. "name": category.name,
  92. "level": category.level,
  93. "description": category.description,
  94. "weights": _weights_payload(weight_row),
  95. "counts": _counts_payload(weight_row),
  96. "hung_word_count": int(weight_row.hung_word_count) if weight_row else 0,
  97. "children": children,
  98. }
  99. def build_category_tree(biz_dt: str | None = None) -> dict[str, Any]:
  100. """Load active categories with per-dim avg; nest as roots.
  101. Returns ``{biz_dt, dims, nodes}``. When no weight rows exist, ``biz_dt`` is
  102. ``None`` and each node still carries zeroed ``weights`` / ``counts``.
  103. """
  104. with get_session() as session:
  105. categories = GlobalTreeCategoryRepository(session).list_active_categories()
  106. weight_repo = CategoryTreeWeightRepository(session)
  107. resolved_dt = biz_dt or weight_repo.get_latest_biz_dt()
  108. weight_rows: list[CategoryTreeWeight] = []
  109. if resolved_dt:
  110. weight_rows = weight_repo.list_by_biz_dt(resolved_dt)
  111. weight_by_id = {int(r.category_id): r for r in weight_rows}
  112. children_map = _build_children_map(categories)
  113. nodes = [
  114. _to_node(root, children_map, weight_by_id)
  115. for root in children_map.get(None, [])
  116. ]
  117. return {
  118. "biz_dt": resolved_dt,
  119. "dims": DIM_META,
  120. "nodes": nodes,
  121. }