tree_local.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. """demand_grade_agent 专用的全局树局部热度查询辅助(不对外注册为工具)。"""
  2. from __future__ import annotations
  3. from collections import defaultdict
  4. from types import SimpleNamespace
  5. from typing import Any
  6. from agents.demand_grade_agent.tools.shared import (
  7. build_category_heat_summary,
  8. build_category_path,
  9. format_category_weight_lines,
  10. )
  11. from supply_infra.scoring.ranking import rank_with_scores
  12. from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
  13. from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
  14. from supply_infra.db.session import get_session
  15. def _normalize_parent_id(parent_id: int | None) -> int | None:
  16. if parent_id is None or parent_id == 0:
  17. return None
  18. return int(parent_id)
  19. def _materialize_category(row: Any) -> SimpleNamespace:
  20. """在 session 内抽出标量,避免 DetachedInstanceError。"""
  21. return SimpleNamespace(
  22. id=int(row.id),
  23. name=row.name,
  24. parent_id=row.parent_id,
  25. level=row.level,
  26. )
  27. def _materialize_weight(row: Any) -> SimpleNamespace:
  28. """在 session 内抽出标量,避免 DetachedInstanceError。"""
  29. return SimpleNamespace(
  30. category_id=int(row.category_id),
  31. biz_dt=row.biz_dt,
  32. total_score=row.total_score,
  33. hung_word_count=row.hung_word_count,
  34. real_rov_7d_avg=row.real_rov_7d_avg,
  35. real_rov_7d_count=row.real_rov_7d_count,
  36. real_vov_7d_avg=row.real_vov_7d_avg,
  37. real_vov_7d_count=row.real_vov_7d_count,
  38. )
  39. def _score_value(weight: Any | None) -> float | None:
  40. if weight is None or weight.total_score is None:
  41. return None
  42. return float(weight.total_score)
  43. def load_local_tree_state(biz_dt: str) -> tuple[dict[int, Any], dict[int | None, list[int]], dict[int, Any]]:
  44. with get_session() as session:
  45. categories = [
  46. _materialize_category(row)
  47. for row in GlobalTreeCategoryRepository(session).list_active_categories()
  48. ]
  49. weights = [
  50. _materialize_weight(row)
  51. for row in CategoryTreeWeightRepository(session).list_by_biz_dt(biz_dt)
  52. ]
  53. by_id = {row.id: row for row in categories}
  54. children: dict[int | None, list[int]] = defaultdict(list)
  55. for row in categories:
  56. children[_normalize_parent_id(row.parent_id)].append(row.id)
  57. for ids in children.values():
  58. ids.sort()
  59. weight_by_id = {row.category_id: row for row in weights}
  60. return by_id, children, weight_by_id
  61. def _describe_node(
  62. category_id: int,
  63. by_id: dict[int, Any],
  64. weight_by_id: dict[int, Any],
  65. *,
  66. biz_dt: str,
  67. global_positions: dict[int, dict[str, float | int]],
  68. ) -> dict[str, Any]:
  69. category = by_id.get(category_id)
  70. weight = weight_by_id.get(category_id)
  71. position = global_positions.get(category_id)
  72. return {
  73. "category_id": category_id,
  74. "name": category.name if category is not None else None,
  75. "path": build_category_path(category_id, by_id),
  76. "hung_word_count": int(weight.hung_word_count or 0) if weight is not None else 0,
  77. "heat": build_category_heat_summary(weight, biz_dt=biz_dt),
  78. "global_tree_position": {
  79. "rank": float(position["rank"]) if position is not None else None,
  80. "scored_node_count": int(position["total"]) if position is not None else 0,
  81. "normalized_rank_score": (
  82. float(position["normalized_score"]) if position is not None else None
  83. ),
  84. },
  85. }
  86. def _sibling_ids(category_id: int, parent_id: int | None, children: dict[int | None, list[int]]) -> list[int]:
  87. if parent_id is not None:
  88. return list(children.get(parent_id, []))
  89. return list(children.get(None, []))
  90. def build_local_heat_snapshot(
  91. biz_dt: str,
  92. category_ids: list[int],
  93. *,
  94. tree_state: tuple[dict[int, Any], dict[int | None, list[int]], dict[int, Any]] | None = None,
  95. ) -> list[dict[str, Any]]:
  96. """构建局部环境快照:自身、父节点、全部兄弟节点的全局热度与后验。"""
  97. if tree_state is None:
  98. by_id, children, weight_by_id = load_local_tree_state(biz_dt)
  99. else:
  100. by_id, children, weight_by_id = tree_state
  101. snapshots: list[dict[str, Any]] = []
  102. global_positions = rank_with_scores([
  103. (category_id, float(weight.total_score))
  104. for category_id, weight in weight_by_id.items()
  105. if weight is not None and weight.total_score is not None
  106. ])
  107. for category_id in dict.fromkeys(int(value) for value in category_ids):
  108. category = by_id.get(category_id)
  109. if category is None:
  110. snapshots.append({
  111. "category_id": category_id,
  112. "error": "分类不存在",
  113. })
  114. continue
  115. parent_id = _normalize_parent_id(category.parent_id)
  116. sibling_ids = _sibling_ids(category_id, parent_id, children)
  117. ranked_siblings = sorted(
  118. sibling_ids,
  119. key=lambda cid: (
  120. _score_value(weight_by_id.get(cid)) is None,
  121. -(_score_value(weight_by_id.get(cid)) or 0),
  122. cid,
  123. ),
  124. )
  125. sibling_total = len(ranked_siblings)
  126. siblings = []
  127. for index, sibling_id in enumerate(ranked_siblings, start=1):
  128. item = _describe_node(
  129. sibling_id,
  130. by_id,
  131. weight_by_id,
  132. biz_dt=biz_dt,
  133. global_positions=global_positions,
  134. )
  135. item["rank_among_siblings"] = index
  136. item["sibling_count"] = sibling_total
  137. item["is_self"] = sibling_id == category_id
  138. siblings.append(item)
  139. snapshots.append({
  140. "category_id": category_id,
  141. "biz_dt": biz_dt,
  142. "global_scored_node_count": len(global_positions),
  143. "self": _describe_node(
  144. category_id,
  145. by_id,
  146. weight_by_id,
  147. biz_dt=biz_dt,
  148. global_positions=global_positions,
  149. ),
  150. "parent": (
  151. _describe_node(
  152. parent_id,
  153. by_id,
  154. weight_by_id,
  155. biz_dt=biz_dt,
  156. global_positions=global_positions,
  157. )
  158. if parent_id is not None
  159. else None
  160. ),
  161. "siblings": siblings,
  162. "sibling_count": sibling_total,
  163. })
  164. return snapshots
  165. def _append_node_block(
  166. lines: list[str],
  167. *,
  168. title: str,
  169. node: dict[str, Any] | None,
  170. biz_dt: str,
  171. weight_by_id: dict[int, Any] | None = None,
  172. ) -> None:
  173. if node is None:
  174. lines.append(f"{title}: 无")
  175. return
  176. lines.append(f"{title}:")
  177. weight = weight_by_id.get(int(node["category_id"])) if weight_by_id is not None else None
  178. lines.extend(format_category_weight_lines(
  179. weight,
  180. category_id=int(node["category_id"]),
  181. name=node.get("name"),
  182. path=node.get("path"),
  183. biz_dt=biz_dt,
  184. hung_word_count=int(node.get("hung_word_count") or 0),
  185. ))
  186. position = node.get("global_tree_position") or {}
  187. rank = position.get("rank")
  188. if rank is None:
  189. lines.append(" 整树位置=无排名(不是低热,表示缺少 total_score)")
  190. else:
  191. lines.append(
  192. f" 整树位置={float(rank):g}/{int(position.get('scored_node_count') or 0)} "
  193. f"整树排名归一分={float(position['normalized_rank_score']):.4f}"
  194. )
  195. def format_local_heat_snapshot(snapshot: dict[str, Any], *, weight_by_id: dict[int, Any] | None = None) -> list[str]:
  196. if snapshot.get("error"):
  197. return [f"--- category_id={snapshot['category_id']} ---", f"错误: {snapshot['error']}"]
  198. biz_dt = snapshot["biz_dt"]
  199. lines = [f"--- category_id={snapshot['category_id']} ---"]
  200. _append_node_block(lines, title="自身节点", node=snapshot["self"], biz_dt=biz_dt, weight_by_id=weight_by_id)
  201. _append_node_block(lines, title="父节点", node=snapshot.get("parent"), biz_dt=biz_dt, weight_by_id=weight_by_id)
  202. siblings = snapshot.get("siblings") or []
  203. lines.append(f"全部兄弟节点(共 {len(siblings)} 个,按 total_score 降序,* 为当前节点):")
  204. for sibling in siblings:
  205. marker = "*" if sibling.get("is_self") else " "
  206. rank_note = f" 兄弟排名 {sibling.get('rank_among_siblings')}/{sibling.get('sibling_count')}"
  207. lines.append(f"{marker} {'-' * 8}{rank_note}")
  208. _append_node_block(lines, title=" 节点", node=sibling, biz_dt=biz_dt, weight_by_id=weight_by_id)
  209. return lines
  210. def render_local_heat_report(biz_dt: str, category_ids: list[int]) -> str:
  211. tree_state = load_local_tree_state(biz_dt)
  212. _, _, weight_by_id = tree_state
  213. snapshots = build_local_heat_snapshot(biz_dt, category_ids, tree_state=tree_state)
  214. if not snapshots:
  215. return "category_ids 不能为空"
  216. lines = [
  217. f"biz_dt={biz_dt} | 局部环境包含节点自身、父节点、全部兄弟节点的全局热度 total_score 与后验 rov_diff/vov_diff;",
  218. "后验为相对全局 diff:>0 效果非常好、[-0.2,0) 可接受、<-0.2 效果不佳;",
  219. "每个节点同时给出整棵树名次;不得只参考附近节点,也不得只参考部分兄弟节点。",
  220. ]
  221. for snapshot in snapshots:
  222. lines.extend(format_local_heat_snapshot(snapshot, weight_by_id=weight_by_id))
  223. lines.append("")
  224. return "\n".join(lines).rstrip()