Ver código fonte

修改分等级Agent

xueyiming 1 semana atrás
pai
commit
331d41c6f0

+ 25 - 17
agents/demand_grade_agent/prompt/system_prompt.md

@@ -1,6 +1,6 @@
 ## 角色与任务
 你是需求优先级分级专家。你会在用户消息中收到一批需求词(来自 `multi_demand_pool_di` 策略需求池)
-和对应的 biz_dt,任务是结合其归属树节点的**先验热度**与**后验真实效果**,逐一划分 S/A/B/C/D
+和对应的 biz_dt,任务是结合其归属树节点的**全局热度**与**后验真实效果**,逐一划分 S/A/B/C/D
 五档优先级,并调用工具落库到 `demand_grade` 表,供下游选题/投放决策参考。
 
 调用方通常还会提供一份“全局树热度交接件”:其中包含需求节点、父节点、兄弟节点的热度和局部排名。
@@ -9,28 +9,30 @@
 你只做分级判断,不生成新需求词,也不修改需求池原始数据;只处理消息中给定的这些需求词,
 不需要自行查找或列举其他待处理需求。
 
-## 先验 / 后验含义
-- **先验**:`category_tree_weight.total_score`(四个分维度 ext_pop / plat_sust_pop / plat_ly_pop / recent_pop 的排名分之和)。反映该需求所在树节点在类目树里的历史热度排名,是"没有真实上线数据时"的兜底依据。
-  - 工具返回中 **`—` 表示该维度样本数据(count=0)**,不是分数为 0;`total_score=—(覆盖维度=0/4)` 表示四个先验维度均无数据
-- **后验**:`real_rov_7d_avg` + `real_rov_7d_count`。
+## 全局热度 / 后验含义
+- **全局热度**:`category_tree_weight.total_score`。反映该需求所在树节点在类目树里的历史热度排名,是"没有真实上线数据时"的兜底依据。
+  - 工具返回中 **`—` 表示无数据**,不是分数为 0。
+- **后验**:`real_rov_7d_avg` + `real_rov_7d_count`(词级工具还会返回 `real_vov_7d`)
   - `real_rov_7d_count > 0`:说明该节点/需求已有真实上线验证数据,**这是高置信信息,判级时应优先参考**,可以据此给出全档位(包括 S 或 D)。
-  - `real_rov_7d_count = 0`(或数据缺失):说明效果未知,只能用先验兜底判断。**无论先验多高,都不建议给到 S 级**(因为没有真实验证支撑),一般封顶在 A。
+  - `real_rov_7d_count = 0`(或数据缺失):说明效果未知,只能用全局热度兜底判断。**无论全局热度多高,都不建议给到 S 级**(因为没有真实验证支撑),一般封顶在 A。
 
 ## 分级参考准则(非硬编码规则,需结合 `query_score_distribution` 自主定阈值)
 - 建议在每个批次开始时调用一次 `query_score_distribution`,参考 total_score 与 real_rov_7d_avg 的分位数(p25/p50/p75/p90),自行制定本批次统一的分档阈值,避免同一批次内前后标准漂移。
 - 有后验数据的需求:
   - 后验效果处于同类中高位(如 real_rov_7d_avg ≥ p75)→ 可评 S 或 A
   - 中等 → B
-  - 明显偏低(如低于 p25)→ C 或 D(即使先验很高,也应如实按后验降级,说明"先验热但验证效果不佳")
+  - 明显偏低(如低于 p25)→ C 或 D(即使全局热度很高,也应如实按后验降级,说明"热度高但验证效果不佳")
 - 无后验数据的需求:
-  - 先验 total_score 很高(如 ≥ p75)→ A(不给 S,注明"无验证数据")
+  - 全局热度 total_score 很高(如 ≥ p75)→ A(不给 S,注明"无验证数据")
   - 中等 → B
   - 偏低 → C
-  - 先验也很低、几乎无信号(total_score 为 — 或覆盖维度=0/4)→ D
+  - 全局热度也很低、几乎无信号(total_score 为 —)→ D
 - 一个需求可能挂在多个树节点上:以最相关/得分最高的节点为主要依据,reason 中说明取用了哪个节点。
-- **局部热度校正**:节点自身热度高、父节点热且在兄弟中靠前时,可上调同等全局分下的等级或 `score`;
+- **局部热度校正**:结合 `query_category_local_heat` 返回的完整局部环境(自身节点、父节点、全部兄弟节点的全局热度与后验数据)做校正。
+  节点自身热度高、父节点热且在兄弟中靠前时,可上调同等全局分下的等级或 `score`;
   节点自身偏冷、父节点与兄弟整体也偏冷时,应下调等级或 `score`。节点自身与局部环境冲突时,
   不直接套规则:结合词级后验判断它是局部突发还是弱信号,并在 reason 中写明冲突。
+  **禁止只引用部分兄弟节点**;必须以工具返回的全部兄弟节点数据作为局部参照。
 - 父节点/兄弟节点只用于校正,不得覆盖明确的低后验:有充分真实后验且表现差时,仍应降级。
 
 ## 同义/相似需求合并
@@ -49,11 +51,12 @@
 ## 可用工具
 - `query_latest_biz_dt()`:若用户消息未给出明确 biz_dt 时调用,返回需求池/权重表/热度统计表各自最新业务日。
 - `search_related_pool_demands(biz_dt, keywords)`:按同名/包含关系搜索需求池,**可一次传入多个 keyword** 批量查找同语义需求。
-- `query_demand_category_and_weight(demand_names, biz_dt=None)`:核心取数工具,**可一次传入多个 demand_name** 批量查询归属树节点 → 先验四维度 + total_score,及后验 real_rov_7d_avg/count
+- `query_demand_category_and_weight(demand_names, biz_dt=None)`:核心取数工具,**可一次传入多个 demand_name** 批量查询归属树节点 → 全局热度 total_score,及后验 real_rov_7d/real_vov_7d
 - `query_category_path(category_ids)`:查询类目根到叶路径文本,用于写 reason。
-- `query_demand_popularity_by_word(demand_word_names, biz_dt=None)`:按需求词粒度直接查热度统计,**可一次传入多个词** 交叉验证树节点级结论。
-- `query_score_distribution(biz_dt=None)`:查询先验/后验分数分布,制定本批次统一分档标准。
-- `build_grade_plan_context(...)`:按统筹规划选定的树节点生成节点组原因、共同特征和节点/父/兄弟热度;调用方未提供或需要复查时使用。
+- `query_category_local_heat(biz_dt, category_ids)`:查询节点自身、父节点、**全部兄弟节点**的全局热度 total_score + 后验 real_rov_7d/real_vov_7d,并给出兄弟内排名;判级时必须参考列出的全部相关节点,不得只看部分节点。
+- `query_demand_popularity_by_word(demand_word_names, biz_dt=None)`:按需求词粒度直接查后验热度统计,**可一次传入多个词** 交叉验证树节点级结论。
+- `query_score_distribution(biz_dt=None)`:查询全局热度与后验分数分布,制定本批次统一分档标准。
+- `build_grade_plan_context(...)`:按统筹规划选定的树节点生成待分级需求、节点组原因,并附带 `local_heat` 局部热度快照;需要复查局部环境时可再调用 `query_category_local_heat`。
 - `batch_save_demand_grades(items, biz_dt=None)`:批量落库分级结果,可重复调用按 (biz_dt, demand_name) upsert 覆盖修正。`related_pool_ids` 必填,`video_list`/`strategies` 自动推导。
 
 ## 工作流程
@@ -62,14 +65,19 @@
 3. **优先批量调用取数工具以减少往返**:
    - 一次 `search_related_pool_demands(biz_dt, keywords=[...])` 覆盖本批所有需求词(或按 5~10 个一组分批);
    - 一次 `query_demand_category_and_weight(demand_names=[...], biz_dt=...)` 批量取归属与权重;
+   - 根据归属到的 `category_id`,调用 `query_category_local_heat(biz_dt, category_ids=[...])` 获取父/兄弟局部热度;
    - 必要时一次 `query_demand_popularity_by_word(demand_word_names=[...])` 做词粒度交叉验证。
    各工具返回结果每段均标注原始查询词(如 `--- demand_name: xxx ---`),便于对应落库。
-4. 对给定列表中的每一个需求词,结合上述批量结果和交接件中的父/兄弟局部热度判定 S/A/B/C/D。reason 必须同时写清全局位置和局部判断(如“节点 total_score=3.2、兄弟前 10%、父节点高热,无后验数据,因此上调至 A”);`related_pool_ids` 取自 `search_related_pool_demands` 返回的 `[id=...]`。
+4. 对给定列表中的每一个需求词,结合以下数据判定 S/A/B/C/D:
+   - 需求词自身:`search_related_pool_demands` / `query_demand_popularity_by_word`
+   - 归属分类节点:`query_demand_category_and_weight`
+   - 局部环境(父节点 + 全部兄弟节点完整权重):`query_category_local_heat`
+   reason 必须同时写清全局位置、局部判断和词级后验(如“节点 total_score=3.2、兄弟 4/4 中排名第 1、父节点高热,无后验数据,因此上调至 A”);`related_pool_ids` 取自 `search_related_pool_demands` 返回的 `[id=...]`。
 5. 全部处理完后,调用一次(或分 2~3 次)`batch_save_demand_grades` 落库,覆盖这批给定的所有需求词。
 6. 简要汇报本批次的分级结果后结束本轮任务。
 
 ## 原则
 - 禁止幻觉:只能引用工具真实返回的数值和类目路径,不能编造 category_id、分数或树节点名称;工具输出中的 `—` 表示无数据,不得当作 0 写入落库字段。
-- reason 必须具体:写清引用的先验/后验数值、是否合并了同义词、依据哪个树节点。
+- reason 必须具体:写清引用的全局热度/后验数值、是否合并了同义词、依据哪个树节点。
 - 找不到归属树节点或权重数据的需求:如实说明"无法评级/数据缺失",不要强行给出等级去凑数。
-- 有后验数据始终优先于纯先验判断;无后验数据时保持谨慎,不给最高档。
+- 有后验数据始终优先于纯全局热度判断;无后验数据时保持谨慎,不给最高档。

+ 2 - 2
agents/demand_grade_agent/run.py

@@ -36,8 +36,8 @@ def main(
     需求词列表:
     {names_str}
 
-    以下是编排 Agent 提供的全局树热度交接件。它包含节点自身、父节点、兄弟节点热度及局部排名;
-    必须将其纳入评分理由。必要时可调用 build_grade_plan_context 复查,但不得忽略局部冷热信号
+    以下是统筹规划提供的节点组上下文(含 `local_heat` 局部热度快照)。
+    必须结合父节点、兄弟节点排名做局部校正;需要更细信息时可调用 `query_category_local_heat`
     {handoff}
     """
     result = agent.run(user_input)

+ 3 - 0
agents/demand_grade_agent/tools/__init__.py

@@ -11,6 +11,7 @@ from typing import Any
 
 from agents.demand_grade_agent.tools.batch_save_demand_grades import batch_save_demand_grades
 from agents.demand_grade_agent.tools.build_grade_plan_context import build_grade_plan_context
+from agents.demand_grade_agent.tools.query_category_local_heat import query_category_local_heat
 from agents.demand_grade_agent.tools.query_category_path import query_category_path
 from agents.demand_grade_agent.tools.query_demand_category_and_weight import (
     query_demand_category_and_weight,
@@ -30,6 +31,7 @@ ALL_TOOLS: list[Callable[..., Any]] = [
     search_related_pool_demands,
     query_demand_category_and_weight,
     query_category_path,
+    query_category_local_heat,
     query_demand_popularity_by_word,
     query_score_distribution,
     build_grade_plan_context,
@@ -40,6 +42,7 @@ __all__ = [
     "ALL_TOOLS",
     "batch_save_demand_grades",
     "build_grade_plan_context",
+    "query_category_local_heat",
     "query_category_path",
     "query_demand_category_and_weight",
     "query_demand_popularity_by_word",

+ 2 - 0
agents/demand_grade_agent/tools/build_grade_plan_context.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 
 import json
 
+from agents.demand_grade_agent.tools.tree_local import build_local_heat_snapshot
 from supply_agent.tools import tool
 from supply_infra.db.repositories.demand_belong_category_repo import DemandBelongCategoryRepository
 from supply_infra.db.repositories.demand_belong_pool_rel_repo import DemandBelongPoolRelRepository
@@ -44,4 +45,5 @@ def build_grade_plan_context(
             "shared_traits": shared_traits.strip(),
             "selection_method": "global_tree_heat_first",
         },
+        "local_heat": build_local_heat_snapshot(biz_dt, selected_ids),
     }, ensure_ascii=False)

+ 56 - 0
agents/demand_grade_agent/tools/query_category_local_heat.py

@@ -0,0 +1,56 @@
+"""查询树节点自身、父节点、兄弟节点的局部热度与排名。"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from agents.demand_grade_agent.tools.shared import normalize_biz_dt
+from agents.demand_grade_agent.tools.tree_local import render_local_heat_report
+from supply_agent.tools import tool
+
+logger = logging.getLogger(__name__)
+
+
+def _normalize_ids(category_ids: list[Any]) -> tuple[list[int], str | None]:
+    if not category_ids:
+        return [], "category_ids 不能为空"
+    out: list[int] = []
+    seen: set[int] = set()
+    for raw in category_ids:
+        try:
+            cid = int(raw)
+        except (TypeError, ValueError):
+            return [], f"category_ids 含无效 id: {raw!r}"
+        if cid in seen:
+            continue
+        seen.add(cid)
+        out.append(cid)
+    if not out:
+        return [], "category_ids 不能为空"
+    return out, None
+
+
+@tool
+def query_category_local_heat(biz_dt: str, category_ids: list[int]) -> str:
+    """查询节点自身、父节点、全部兄弟节点的全局热度与后验数据及兄弟内排名。
+
+    只返回 total_score 与 real_rov_7d/real_vov_7d,不包含四维先验明细。
+    """
+    normalized_dt, err = normalize_biz_dt(biz_dt)
+    if err:
+        return err
+    ids, err = _normalize_ids(category_ids)
+    if err:
+        return err
+
+    try:
+        report = render_local_heat_report(normalized_dt, ids)
+        logger.info("query_category_local_heat completed: biz_dt=%s ids=%s", normalized_dt, ids)
+        return report
+    except Exception as exc:
+        logger.error("query_category_local_heat failed: %s", exc, exc_info=True)
+        return f"查询局部树节点热度失败: {exc}"
+
+
+if __name__ == "__main__":
+    print(query_category_local_heat("20260714", [313, 686]))

+ 12 - 30
agents/demand_grade_agent/tools/query_demand_category_and_weight.py

@@ -10,8 +10,7 @@ from sqlalchemy.orm import Session
 
 from agents.demand_grade_agent.tools.shared import (
     build_category_path,
-    format_dim_with_count,
-    format_score,
+    format_category_weight_lines,
     normalize_biz_dt,
     normalize_str_list,
 )
@@ -32,32 +31,15 @@ from supply_infra.db.session import get_session
 logger = logging.getLogger(__name__)
 
 
-def _prior_dim_count(weight: CategoryTreeWeight) -> int:
-    """有四维先验样本的维度数(最多 4)。"""
-    return sum(
-        int(getattr(weight, f"{dim}_count", 0) or 0) > 0
-        for dim in ("ext_pop", "plat_sust_pop", "plat_ly_pop", "recent_pop")
-    )
-
-
 def _format_weight_row(weight: CategoryTreeWeight, path: str | None) -> str:
-    prior_dims = _prior_dim_count(weight)
-    posterior_note = "有后验验证数据" if weight.real_rov_7d_count > 0 else "无后验验证数据(效果未知)"
-    total_note = (
-        f"{format_score(weight.total_score)}(覆盖维度={prior_dims}/4)"
-        if prior_dims > 0
-        else "—(覆盖维度=0/4)"
-    )
-    return (
-        f"[category_id={weight.category_id}] {path or '(未知路径)'}\n"
-        f"  biz_dt={weight.biz_dt}  先验total_score={total_note}\n"
-        f"  先验分维度: 外部热度={format_dim_with_count(weight.ext_pop_avg, weight.ext_pop_count)}"
-        f" 平台持续热度={format_dim_with_count(weight.plat_sust_pop_avg, weight.plat_sust_pop_count)}"
-        f" 平台去年同期={format_dim_with_count(weight.plat_ly_pop_avg, weight.plat_ly_pop_count)}"
-        f" 近期热度={format_dim_with_count(weight.recent_pop_avg, weight.recent_pop_count)}\n"
-        f"  后验real_rov_7d: avg={format_dim_with_count(weight.real_rov_7d_avg, weight.real_rov_7d_count)}"
-        f" ({posterior_note})"
-    )
+    return "\n".join(format_category_weight_lines(
+        weight,
+        category_id=int(weight.category_id),
+        name=None,
+        path=path,
+        biz_dt=weight.biz_dt,
+        hung_word_count=int(weight.hung_word_count or 0),
+    ))
 
 
 def _query_one_demand_category_and_weight(
@@ -118,7 +100,7 @@ def query_demand_category_and_weight(
     biz_dt: Optional[str] = None,
 ) -> str:
     """
-    需求名 → 归属树节点 → 先验热度(total_score等) + 后验真实效果(real_rov_7d)。
+    需求名 → 归属树节点 → 全局热度 total_score + 后验真实效果(real_rov_7d)。
 
     支持批量传入多个需求名,一次调用返回各词的归属与权重;每段结果前会标注原始 demand_name。
 
@@ -126,7 +108,7 @@ def query_demand_category_and_weight(
     1. 先在 multi_demand_pool_di 中按需求名精确匹配找到池表行 id;
     2. 经 demand_belong_pool_rel 反查这些池表行归属的 demand_belong_category;
     3. 若第 2 步查不到关系,退化为按需求名模糊匹配 demand_belong_category.name;
-    4. 用归属到的 category_id 查询 category_tree_weight 的先验/后验数据。
+    4. 用归属到的 category_id 查询 category_tree_weight 的全局热度与后验数据。
 
     一个需求可能挂在多个树节点上,会全部列出。
 
@@ -141,7 +123,7 @@ def query_demand_category_and_weight(
         --- demand_name: 减脂期加餐 ---
         「减脂期加餐」归属 1 个树节点:
         [category_id=88] 美食 > 减脂饮食 > 加餐
-          biz_dt=20260716  先验total_score=3.42
+          biz_dt=20260716  全局热度total_score=3.42
           ...
     """
     normalized_dt, err = normalize_biz_dt(biz_dt)

+ 3 - 6
agents/demand_grade_agent/tools/query_demand_popularity_by_word.py

@@ -38,11 +38,8 @@ def _query_one_demand_popularity_by_word(
         posterior_note = "有后验数据" if row.real_rov_7d_count > 0 else "无后验数据(效果未知)"
         lines.append(
             f"[biz_dt={row.biz_dt}] {row.demand_word_name}: "
-            f"外部热度={format_dim_with_count(row.ext_pop_avg, row.ext_pop_count)} "
-            f"平台持续热度={format_dim_with_count(row.plat_sust_pop_avg, row.plat_sust_pop_count)} "
-            f"平台去年同期={format_dim_with_count(row.plat_ly_pop_avg, row.plat_ly_pop_count)} "
-            f"近期热度={format_dim_with_count(row.recent_pop_avg, row.recent_pop_count)}\n"
-            f"  后验real_rov_7d: avg={format_dim_with_count(row.real_rov_7d_avg, row.real_rov_7d_count)} "
+            f"后验real_rov_7d={format_dim_with_count(row.real_rov_7d_avg, row.real_rov_7d_count)} "
+            f"后验real_vov_7d={format_dim_with_count(row.real_vov_7d_avg, row.real_vov_7d_count)} "
             f"({posterior_note})"
         )
     return "\n".join(lines)
@@ -69,7 +66,7 @@ def query_demand_popularity_by_word(
     Returns:
         每个 demand_word_name 一段,段首标注 `--- demand_word_name: xxx ---`,例如:
         --- demand_word_name: 减脂期加餐 ---
-        [biz_dt=20260716] 减脂期加餐: 外部热度=1.10 ...
+        [biz_dt=20260716] 减脂期加餐: 后验real_rov_7d=0.12(n=5) 后验real_vov_7d=0.08(n=5) (有后验数据)
     """
     normalized_dt, err = normalize_biz_dt(biz_dt)
     if err:

+ 4 - 8
agents/demand_grade_agent/tools/query_score_distribution.py

@@ -14,11 +14,7 @@ from supply_infra.db.session import get_session
 logger = logging.getLogger(__name__)
 
 _DIM_FIELDS: tuple[tuple[str, str], ...] = (
-    ("total_score", "先验total_score(四维排名分之和)"),
-    ("ext_pop_avg", "外部热度"),
-    ("plat_sust_pop_avg", "平台持续热度"),
-    ("plat_ly_pop_avg", "平台去年同期热度"),
-    ("recent_pop_avg", "近期热度"),
+    ("total_score", "全局热度total_score"),
 )
 
 
@@ -35,10 +31,10 @@ def _format_dist(label: str, dist: dict) -> str:
 @tool
 def query_score_distribution(biz_dt: Optional[str] = None) -> str:
     """
-    查询指定业务日 category_tree_weight 的分数分布(先验四维度+total_score,及后验real_rov_7d)
+    查询指定业务日 category_tree_weight 的全局热度与后验分布
 
     建议在批量分级任务开始时调用一次,参考分位数自行制定本批次统一的分档阈值
-    (例如 total_score 前 10% 视为先验很高),避免同一批次内多次判断标准漂移。
+    (例如 total_score 前 10% 视为全局热度很高),避免同一批次内多次判断标准漂移。
     后验 real_rov_7d_avg 的分布只统计 real_rov_7d_count>0(有真实验证数据)的子集,
     因为无验证数据的行 avg 无意义。
 
@@ -47,7 +43,7 @@ def query_score_distribution(biz_dt: Optional[str] = None) -> str:
 
     Returns:
         每个维度一行分布摘要,例如:
-        先验total_score(四维排名分之和) (n=1500): min=0.0000 p25=0.8500 p50=1.6000 p75=2.9000 p90=4.1000 max=8.2000
+        全局热度total_score (n=1500): min=0.0000 p25=0.8500 ...
         后验real_rov_7d_avg(仅count>0子集) (n=210): ...
     """
     normalized_dt, err = normalize_biz_dt(biz_dt)

+ 1 - 1
agents/demand_grade_agent/tools/search_related_pool_demands.py

@@ -94,7 +94,7 @@ def search_related_pool_demands(biz_dt: str, keywords: list[str]) -> str:
 
 
 def main() -> None:
-    print(search_related_pool_demands(biz_dt="20260716", keywords=["加餐", "减脂"]))
+    print(search_related_pool_demands(biz_dt="20260714", keywords=["加餐", "减脂"]))
 
 
 if __name__ == "__main__":

+ 61 - 0
agents/demand_grade_agent/tools/shared.py

@@ -55,6 +55,67 @@ def format_dim_with_count(
     return f"{format_score(avg)}(n={sample_count})"
 
 
+def build_category_heat_summary(weight: Any | None, *, biz_dt: str | None = None) -> dict[str, Any]:
+    """构建分类节点的全局热度与后验摘要(不含四维先验明细)。"""
+    if weight is None:
+        return {
+            "biz_dt": biz_dt,
+            "global_heat": {"total_score": None},
+            "posterior": {
+                "real_rov_7d": {"avg": None, "count": 0},
+                "real_vov_7d": {"avg": None, "count": 0},
+            },
+            "has_weight_data": False,
+        }
+    return {
+        "biz_dt": getattr(weight, "biz_dt", biz_dt),
+        "global_heat": {"total_score": to_float(weight.total_score)},
+        "posterior": {
+            "real_rov_7d": {
+                "avg": to_float(weight.real_rov_7d_avg),
+                "count": int(weight.real_rov_7d_count or 0),
+            },
+            "real_vov_7d": {
+                "avg": to_float(weight.real_vov_7d_avg),
+                "count": int(weight.real_vov_7d_count or 0),
+            },
+        },
+        "has_weight_data": True,
+    }
+
+
+def format_category_weight_lines(
+    weight: Any | None,
+    *,
+    category_id: int,
+    name: str | None,
+    path: str | None,
+    biz_dt: str | None = None,
+    hung_word_count: int = 0,
+) -> list[str]:
+    """格式化单个分类节点的全局热度与后验文本块。"""
+    detail = build_category_heat_summary(weight, biz_dt=biz_dt)
+    global_heat = detail["global_heat"]
+    posterior = detail["posterior"]
+    posterior_note = (
+        "有后验验证数据"
+        if int(posterior["real_rov_7d"]["count"]) > 0
+        else "无后验验证数据(效果未知)"
+    )
+    return [
+        f"[{category_id}]{name or ''} {path or '(未知路径)'}",
+        f"  biz_dt={detail.get('biz_dt') or biz_dt or '—'}  hung_word_count={hung_word_count}",
+        f"  全局热度total_score={format_score(global_heat['total_score'])}",
+        (
+            "  后验real_rov_7d="
+            f"{format_dim_with_count(posterior['real_rov_7d']['avg'], posterior['real_rov_7d']['count'])} "
+            f"后验real_vov_7d="
+            f"{format_dim_with_count(posterior['real_vov_7d']['avg'], posterior['real_vov_7d']['count'])} "
+            f"({posterior_note})"
+        ),
+    ]
+
+
 def percentile(sorted_values: list[float], pct: float) -> float | None:
     """对已排序(升序)的数值列表求分位数(线性插值),pct 取 0~100。"""
     if not sorted_values:

+ 208 - 0
agents/demand_grade_agent/tools/tree_local.py

@@ -0,0 +1,208 @@
+"""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.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,
+) -> dict[str, Any]:
+    category = by_id.get(category_id)
+    weight = weight_by_id.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),
+    }
+
+
+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]] = []
+    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)
+            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,
+            "self": _describe_node(category_id, by_id, weight_by_id, biz_dt=biz_dt),
+            "parent": (
+                _describe_node(parent_id, by_id, weight_by_id, biz_dt=biz_dt)
+                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),
+    ))
+
+
+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 与后验数据;",
+        "不得只参考部分兄弟节点,应以本报告列出的全部节点为准。",
+    ]
+    for snapshot in snapshots:
+        lines.extend(format_local_heat_snapshot(snapshot, weight_by_id=weight_by_id))
+        lines.append("")
+    return "\n".join(lines).rstrip()