category_tree.py 4.5 KB

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