"""demand_grade_agent 专用的全局树局部热度查询辅助(不对外注册为工具)。""" from __future__ import annotations from collections import defaultdict from types import SimpleNamespace from typing import Any from agents.demand_grade_agent.tools.shared import ( build_category_heat_summary, build_category_path, format_category_weight_lines, ) from supply_infra.scoring.ranking import rank_with_scores from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository from supply_infra.db.session import get_session def _normalize_parent_id(parent_id: int | None) -> int | None: if parent_id is None or parent_id == 0: return None return int(parent_id) def _materialize_category(row: Any) -> SimpleNamespace: """在 session 内抽出标量,避免 DetachedInstanceError。""" return SimpleNamespace( id=int(row.id), name=row.name, parent_id=row.parent_id, level=row.level, ) def _materialize_weight(row: Any) -> SimpleNamespace: """在 session 内抽出标量,避免 DetachedInstanceError。""" return SimpleNamespace( category_id=int(row.category_id), biz_dt=row.biz_dt, total_score=row.total_score, hung_word_count=row.hung_word_count, real_rov_7d_avg=row.real_rov_7d_avg, real_rov_7d_count=row.real_rov_7d_count, real_vov_7d_avg=row.real_vov_7d_avg, real_vov_7d_count=row.real_vov_7d_count, ) def _score_value(weight: Any | None) -> float | None: if weight is None or weight.total_score is None: return None return float(weight.total_score) def load_local_tree_state(biz_dt: str) -> tuple[dict[int, Any], dict[int | None, list[int]], dict[int, Any]]: with get_session() as session: categories = [ _materialize_category(row) for row in GlobalTreeCategoryRepository(session).list_active_categories() ] weights = [ _materialize_weight(row) for row in CategoryTreeWeightRepository(session).list_by_biz_dt(biz_dt) ] by_id = {row.id: row for row in categories} children: dict[int | None, list[int]] = defaultdict(list) for row in categories: children[_normalize_parent_id(row.parent_id)].append(row.id) for ids in children.values(): ids.sort() weight_by_id = {row.category_id: row for row in weights} return by_id, children, weight_by_id def _describe_node( category_id: int, by_id: dict[int, Any], weight_by_id: dict[int, Any], *, biz_dt: str, global_positions: dict[int, dict[str, float | int]], ) -> dict[str, Any]: category = by_id.get(category_id) weight = weight_by_id.get(category_id) position = global_positions.get(category_id) return { "category_id": category_id, "name": category.name if category is not None else None, "path": build_category_path(category_id, by_id), "hung_word_count": int(weight.hung_word_count or 0) if weight is not None else 0, "heat": build_category_heat_summary(weight, biz_dt=biz_dt), "global_tree_position": { "rank": float(position["rank"]) if position is not None else None, "scored_node_count": int(position["total"]) if position is not None else 0, "normalized_rank_score": ( float(position["normalized_score"]) if position is not None else None ), }, } def _sibling_ids(category_id: int, parent_id: int | None, children: dict[int | None, list[int]]) -> list[int]: if parent_id is not None: return list(children.get(parent_id, [])) return list(children.get(None, [])) def build_local_heat_snapshot( biz_dt: str, category_ids: list[int], *, tree_state: tuple[dict[int, Any], dict[int | None, list[int]], dict[int, Any]] | None = None, ) -> list[dict[str, Any]]: """构建局部环境快照:自身、父节点、全部兄弟节点的全局热度与后验。""" if tree_state is None: by_id, children, weight_by_id = load_local_tree_state(biz_dt) else: by_id, children, weight_by_id = tree_state snapshots: list[dict[str, Any]] = [] global_positions = rank_with_scores([ (category_id, float(weight.total_score)) for category_id, weight in weight_by_id.items() if weight is not None and weight.total_score is not None ]) for category_id in dict.fromkeys(int(value) for value in category_ids): category = by_id.get(category_id) if category is None: snapshots.append({ "category_id": category_id, "error": "分类不存在", }) continue parent_id = _normalize_parent_id(category.parent_id) sibling_ids = _sibling_ids(category_id, parent_id, children) ranked_siblings = sorted( sibling_ids, key=lambda cid: ( _score_value(weight_by_id.get(cid)) is None, -(_score_value(weight_by_id.get(cid)) or 0), cid, ), ) sibling_total = len(ranked_siblings) siblings = [] for index, sibling_id in enumerate(ranked_siblings, start=1): item = _describe_node( sibling_id, by_id, weight_by_id, biz_dt=biz_dt, global_positions=global_positions, ) item["rank_among_siblings"] = index item["sibling_count"] = sibling_total item["is_self"] = sibling_id == category_id siblings.append(item) snapshots.append({ "category_id": category_id, "biz_dt": biz_dt, "global_scored_node_count": len(global_positions), "self": _describe_node( category_id, by_id, weight_by_id, biz_dt=biz_dt, global_positions=global_positions, ), "parent": ( _describe_node( parent_id, by_id, weight_by_id, biz_dt=biz_dt, global_positions=global_positions, ) if parent_id is not None else None ), "siblings": siblings, "sibling_count": sibling_total, }) return snapshots def _append_node_block( lines: list[str], *, title: str, node: dict[str, Any] | None, biz_dt: str, weight_by_id: dict[int, Any] | None = None, ) -> None: if node is None: lines.append(f"{title}: 无") return lines.append(f"{title}:") weight = weight_by_id.get(int(node["category_id"])) if weight_by_id is not None else None lines.extend(format_category_weight_lines( weight, category_id=int(node["category_id"]), name=node.get("name"), path=node.get("path"), biz_dt=biz_dt, hung_word_count=int(node.get("hung_word_count") or 0), )) position = node.get("global_tree_position") or {} rank = position.get("rank") if rank is None: lines.append(" 整树位置=无排名(不是低热,表示缺少 total_score)") else: lines.append( f" 整树位置={float(rank):g}/{int(position.get('scored_node_count') or 0)} " f"整树排名归一分={float(position['normalized_rank_score']):.4f}" ) def format_local_heat_snapshot(snapshot: dict[str, Any], *, weight_by_id: dict[int, Any] | None = None) -> list[str]: if snapshot.get("error"): return [f"--- category_id={snapshot['category_id']} ---", f"错误: {snapshot['error']}"] biz_dt = snapshot["biz_dt"] lines = [f"--- category_id={snapshot['category_id']} ---"] _append_node_block(lines, title="自身节点", node=snapshot["self"], biz_dt=biz_dt, weight_by_id=weight_by_id) _append_node_block(lines, title="父节点", node=snapshot.get("parent"), biz_dt=biz_dt, weight_by_id=weight_by_id) siblings = snapshot.get("siblings") or [] lines.append(f"全部兄弟节点(共 {len(siblings)} 个,按 total_score 降序,* 为当前节点):") for sibling in siblings: marker = "*" if sibling.get("is_self") else " " rank_note = f" 兄弟排名 {sibling.get('rank_among_siblings')}/{sibling.get('sibling_count')}" lines.append(f"{marker} {'-' * 8}{rank_note}") _append_node_block(lines, title=" 节点", node=sibling, biz_dt=biz_dt, weight_by_id=weight_by_id) return lines def render_local_heat_report(biz_dt: str, category_ids: list[int]) -> str: tree_state = load_local_tree_state(biz_dt) _, _, weight_by_id = tree_state snapshots = build_local_heat_snapshot(biz_dt, category_ids, tree_state=tree_state) if not snapshots: return "category_ids 不能为空" lines = [ f"biz_dt={biz_dt} | 局部环境包含节点自身、父节点、全部兄弟节点的全局热度 total_score 与后验 rov_diff/vov_diff;", "后验为相对全局 diff:>0 效果非常好、[-0.2,0) 可接受、<-0.2 效果不佳;", "每个节点同时给出整棵树名次;不得只参考附近节点,也不得只参考部分兄弟节点。", ] for snapshot in snapshots: lines.extend(format_local_heat_snapshot(snapshot, weight_by_id=weight_by_id)) lines.append("") return "\n".join(lines).rstrip()