Ver Fonte

feat: implement demand v2 scoped evidence flow

SamLee há 3 semanas atrás
pai
commit
051b9879d0

+ 6 - 0
.env.template

@@ -12,3 +12,9 @@ OPEN_ROUTER_API_KEY=sk-or-v1-868c3d3256fbf706feb0701a886424fd38468d099c9f1931ccf
 # DEMAND_PATTERN_PG_DSN takes precedence; PGVECTOR_DSN is the shared fallback.
 DEMAND_PATTERN_PG_DSN=
 PGVECTOR_DSN=
+
+# ODPS / Hive supply-gap read-only source.
+ODPS_ACCESS_ID=
+ODPS_ACCESS_KEY=
+ODPS_PROJECT=loghubods
+ODPS_ENDPOINT=http://service.odps.aliyun.com/api

+ 30 - 21
examples/demand/data_query_tools.py

@@ -1,4 +1,5 @@
 import hashlib
+import os
 from zoneinfo import ZoneInfo
 
 from datetime import date, datetime, timedelta
@@ -6,18 +7,25 @@ import json
 from pathlib import Path
 
 
+def _odps_config():
+    return {
+        "access_id": os.getenv("ODPS_ACCESS_ID", "").strip(),
+        "access_key": os.getenv("ODPS_ACCESS_KEY", "").strip(),
+        "project": os.getenv("ODPS_PROJECT", "loghubods").strip(),
+        "endpoint": os.getenv("ODPS_ENDPOINT", "http://service.odps.aliyun.com/api").strip(),
+    }
+
+
 def get_odps_data(sql):
     from odps import ODPS
     from odps.errors import ODPSError
 
-    # 配置信息
-    access_id = 'LTAI9EBa0bd5PrDa'
-    access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
-    project = 'loghubods'
-    endpoint = 'http://service.odps.aliyun.com/api'
+    cfg = _odps_config()
+    if not cfg["access_id"] or not cfg["access_key"]:
+        raise RuntimeError("ODPS config missing: set ODPS_ACCESS_ID and ODPS_ACCESS_KEY")
 
     # 1. 初始化 ODPS 入口
-    o = ODPS(access_id, access_key, project, endpoint=endpoint)
+    o = ODPS(cfg["access_id"], cfg["access_key"], cfg["project"], endpoint=cfg["endpoint"])
 
     try:
         # 2. 执行 SQL 并获取结果
@@ -37,13 +45,11 @@ def execute_odps_sql(sql) -> bool:
     from odps import ODPS
     from odps.errors import ODPSError
 
-    # 配置信息
-    access_id = 'LTAI9EBa0bd5PrDa'
-    access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
-    project = 'loghubods'
-    endpoint = 'http://service.odps.aliyun.com/api'
+    cfg = _odps_config()
+    if not cfg["access_id"] or not cfg["access_key"]:
+        raise RuntimeError("ODPS config missing: set ODPS_ACCESS_ID and ODPS_ACCESS_KEY")
 
-    o = ODPS(access_id, access_key, project, endpoint=endpoint)
+    o = ODPS(cfg["access_id"], cfg["access_key"], cfg["project"], endpoint=cfg["endpoint"])
     try:
         instance = o.execute_sql(sql)
         instance.wait_for_success()
@@ -281,7 +287,7 @@ PARTITION (dt='{dt}')
 def get_demand_merge_level2_names():
     date_time = datetime.now(ZoneInfo("Asia/Shanghai")).date() - timedelta(days=1)
     day = date_time.strftime("%Y%m%d")
-    count = 50
+    min_lack_count = 50
     sql_query = f'''
 select *
 from (
@@ -332,7 +338,7 @@ from loghubods.video_dimension_detail_add_column
 where dt = '{day}'
 group by dt, merge二级品类
 ) t1 
-where t1.缺量>= {count}
+where t1.缺量>= {min_lack_count}
 
 '''
 
@@ -342,21 +348,24 @@ where t1.缺量>= {count}
         for r in data:
             lack_count = r[9]
             if lack_count > 1000:
-                count = 70
+                requested_count = 70
             elif 500 < lack_count <= 1000:
-                count = 60
+                requested_count = 60
             elif 100 < lack_count <= 500:
-                count = 40
+                requested_count = 40
             elif 50 < lack_count <= 100:
-                count = 20
+                requested_count = 20
             else:
-                count = 10
-            if count == 0:
+                requested_count = 10
+            if requested_count == 0:
                 continue
             result_list.append({
                 "cluster_name": r[1],
                 "platform_type": "piaoquan",
-                "count": count,
+                "count": requested_count,
+                "gap_dt": str(r[0]),
+                "lack_count": float(lack_count or 0),
+                "requested_count": requested_count,
             })
     return result_list
 

+ 2 - 0
examples/demand/db_manager.py

@@ -15,6 +15,7 @@ from examples.demand.pg_pattern_repository import (
     query_itemset_evidence,
     query_itemset_items_with_categories,
     query_latest_success_execution_id,
+    query_seed_points_for_itemsets,
     query_video_ids_by_names,
 )
 
@@ -55,5 +56,6 @@ __all__ = [
     "query_itemset_evidence",
     "query_itemset_items_with_categories",
     "query_latest_success_execution_id",
+    "query_seed_points_for_itemsets",
     "query_video_ids_by_names",
 ]

+ 6 - 6
examples/demand/demand.md

@@ -71,8 +71,7 @@ $system$
   "source_post_id": "55157577",
   "case_ids": {
     "pattern_itemset": ["55157577"]
-  },
-  "seed_terms": ["祝福词句"]
+  }
 }
 ```
 
@@ -82,9 +81,10 @@ $system$
 - Pattern 证据源是 PG Pattern V2,最终只允许使用 `pattern_itemset.scope="topic"` 的分类 Pattern;`topic_element`、script、paragraph、group scope 都不能作为最终证据。
 - `source_tool` 必须填写 `get_itemset_detail`,因为最终证据必须来自项集详情。
 - `itemset_ids` 必须来自 `get_frequent_itemsets` 返回的真实项集 ID,并且在创建需求前必须用 `get_itemset_detail` 查询过。
+- 每条 DemandItem 只能绑定一个 `itemset_id`。
 - `source_post_id` 必须从 `get_itemset_detail` 返回的 `post_ids` 中选择一个真实帖子 ID;没有明确帖子时不要创建该 DemandItem。
 - `case_ids` 固定按 pattern 来源表达,例如 `{"pattern_itemset": ["source_post_id"]}`。
-- `seed_terms` 只能来自 `get_itemset_detail` 返回的 items 中的 `element_name` / `category_path` / 分类名称,或当前 DemandItem 的真实元素/分类词,不允许凭空补 ID 或词
+- 不要填写 `seed_terms`;`seed_terms`、`query_seed_points`、`source_certainty`、`validation_status` 和 `demand_scope` 都由代码从 PG DB 校验并补齐
 
 ## 工具概览
 
@@ -116,6 +116,7 @@ $system$
 - result 中出现的每一个具体内容,都必须有对应的 DemandItem
 - 每个 DemandItem 都必须包含 `evidence_refs`,且 `evidence_refs.source_kind` 必须是 `pattern_itemset`
 - 创建 DemandItem 前,必须先调用 `get_frequent_itemsets` 找到候选项集,再调用 `get_itemset_detail` 拿到 `itemset_ids`、`items`、`post_ids`,并把一个真实 `post_id` 写入 `source_post_id`
+- 每条 DemandItem 只能绑定一个 itemset;不要把多个 itemset 合并到一条需求里。
 - 不允许用 `search_elements` / `search_categories` / 共现查询结果直接创建最终 DemandItem;这些工具只能辅助理解和筛选,最终创建时仍必须绑定到一个经过 `get_itemset_detail` 验证的 itemset
 - `evidence_refs` 是候选引用,不要写 `source_certainty=db_validated` 或 `validation_status=passed`,最终校验由代码完成
 - `search_elements` / `search_categories`只能用于查询单元素/单分类,不能用于查询完整树,完整树查询用`get_category_tree`
@@ -147,7 +148,7 @@ $user$
 4. 只从 `get_itemset_detail` 返回的详情中选择需求证据:
    - `itemset_ids`: 当前项集 ID。
    - `source_post_id`: 从该项集 `post_ids` 中选择一个真实帖子。
-   - `seed_terms`: 从该项集 `items` 里的分类路径、分类名或元素名提取。
+   - 不要填写 `seed_terms`,代码会从该项集 `items` 里的分类路径、分类名或元素名提取。
 5. 调用 `create_demand_item` 或 `create_demand_items` 时,每条必须使用如下结构:
 
 ```json
@@ -163,8 +164,7 @@ $user$
     "source_post_id": "55157577",
     "case_ids": {
       "pattern_itemset": ["55157577"]
-    },
-    "seed_terms": ["从 itemset items 中提取的真实词"]
+    }
   }
 }
 ```

+ 12 - 10
examples/demand/demand_mysql.md

@@ -10,7 +10,7 @@ $system$
 
 你是 DemandAgent,目标是为「%merge_level2%」生成约 %count% 条可写入 `demand_content` 的需求。
 
-本入口用于云端 MySQL 单表落库,必须高效执行,不要展开整棵分类树,不要查询权重文件
+本入口用于云端 MySQL 单表落库,必须高效执行,不要展开整棵分类树。
 
 ## 硬约束
 
@@ -19,17 +19,19 @@ $system$
 - `evidence_refs.source_kind` 固定为 `"pattern_itemset"`。
 - `evidence_refs.source_tool` 固定为 `"get_itemset_detail"`。
 - `itemset_ids` 必须来自 `get_frequent_itemsets` 和 `get_itemset_detail` 返回的真实 itemset。
+- 每条 DemandItem 只能绑定一个 `itemset_id`。
 - `source_post_id` 必须从 `get_itemset_detail` 返回的 `post_ids` 中选择。
-- `seed_terms` 必须来自 itemset 的 `items` 分类名或分类路径
+- 不要填写 `seed_terms`;`seed_terms`、`query_seed_points`、`demand_scope` 都由代码从 PG DB 派生
 - 不要写 `source_certainty` 或 `validation_status`,这些由代码 DB 强校验后补齐。
 
 ## 执行步骤
 
-1. 调用 `get_frequent_itemsets(top_n=%count% * 3, min_support=5, sort_by="absolute_support")`。
-2. 从返回结果中挑选语义清晰、彼此尽量不重复的 itemset,数量尽量接近 %count%。
-3. 调用一次 `get_itemset_detail(itemset_ids=[...])` 查询这些 itemset 的详情。
-4. 调用一次 `create_demand_items(demand_items=[...])` 批量创建需求。
-5. 最后只用一句话总结,不要再调用其他工具。
+1. 可先调用 `get_weight_score_topn` 查看当前品类下高权重元素/分类,用于辅助判断哪些方向值得生成需求。
+2. 调用 `get_frequent_itemsets(top_n=%count% * 3, min_support=5, sort_by="absolute_support")`。工具会自动按当前 `merge_leve2/platform` 过滤支撑帖。
+3. 从返回结果中挑选语义清晰、彼此尽量不重复的 itemset,数量尽量接近 %count%。
+4. 调用一次 `get_itemset_detail(itemset_ids=[...])` 查询这些 itemset 的详情。
+5. 调用一次 `create_demand_items(demand_items=[...])` 批量创建需求。
+6. 最后只用一句话总结,不要再调用其他工具。
 
 ## DemandItem 格式
 
@@ -46,8 +48,7 @@ $system$
     "source_post_id": "从 post_ids 中选择一个真实帖子",
     "case_ids": {
       "pattern_itemset": ["同 source_post_id"]
-    },
-    "seed_terms": ["从 items 中提取的真实分类词"]
+    }
   }
 }
 ```
@@ -58,7 +59,8 @@ $system$
 - 过滤纯形式、过于抽象、难以表达用户需求的组合。
 - 如果候选不足 %count% 条,可以少于 %count%,不要编造需求。
 - 不要重复创建语义相同的需求。
+- 不要把多个 itemset 合并到同一条 DemandItem;如果一个组合值得生成需求,就为它单独创建一条。
 
 $user$
 
-请针对「%merge_level2%」生成约 %count% 条 DemandItem。严格按系统指令执行,只使用 `get_frequent_itemsets`、`get_itemset_detail`、`create_demand_items`。
+请针对「%merge_level2%」生成约 %count% 条 DemandItem。严格按系统指令执行,只使用 `get_weight_score_topn`、`get_weight_score_by_name`、`get_frequent_itemsets`、`get_itemset_detail`、`create_demand_items`。

+ 32 - 6
examples/demand/demand_pattern_tools.py

@@ -87,7 +87,22 @@ def _env_int(name: str, default: int) -> int:
 
 
 def _is_mysql_demand_content_entrypoint() -> bool:
-    return os.getenv("DEMAND_MYSQL_ENTRYPOINT") == "run_existing_execution_mysql"
+    return os.getenv("DEMAND_MYSQL_ENTRYPOINT") in {
+        "run_existing_execution_mysql",
+        "run_hive_gap_mysql",
+    }
+
+
+def _context_scope_value(key: str) -> Any:
+    return TopicBuildAgentContext.get_metadata(key)
+
+
+def _resolve_scope_arg(value: Any, metadata_key: str) -> Any:
+    if value is not None:
+        if isinstance(value, str) and not value.strip():
+            return _context_scope_value(metadata_key)
+        return value
+    return _context_scope_value(metadata_key)
 
 
 def _compact_itemset_detail_for_mysql(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -160,6 +175,7 @@ def get_frequent_itemsets(
         sort_by: str = "absolute_support",
         account_name=None,
         merge_leve2=None,
+        platform=None,
 ) -> str:
     """获取频繁项集——即经常在同一帖子中共同出现的分类组合,按 dimension_mode/depth 分组返回。
 
@@ -183,18 +199,21 @@ def get_frequent_itemsets(
         sort_by: 排序方式:absolute_support=共现帖子数(默认),support=相对支持度,item_count=分类数量。
         account_name: 按账号名筛选,支持单个字符串或列表(多个取OR)。
         merge_leve2: 按二级品类筛选,支持单个字符串或列表(多个取OR)。
+        platform: 按平台筛选,默认读取当前任务 platform_type。
 
     Returns:
         按 dimension_mode/depth 分组的项集JSON,每组含 itemsets 列表。
     """
     execution_id = TopicBuildAgentContext.get_execution_id()
+    merge_leve2 = _resolve_scope_arg(merge_leve2, "merge_leve2")
+    platform = _resolve_scope_arg(platform, "platform")
     params = {
         "execution_id": execution_id, "top_n": top_n,
         "category_ids": category_ids, "dimension_mode": dimension_mode,
         "min_support": min_support,
         "min_item_count": min_item_count, "max_item_count": max_item_count,
         "sort_by": sort_by,
-        "account_name": account_name, "merge_leve2": merge_leve2,
+        "account_name": account_name, "merge_leve2": merge_leve2, "platform": platform,
     }
     _log_tool_input("get_frequent_itemsets", params)
 
@@ -204,7 +223,7 @@ def get_frequent_itemsets(
         min_support=min_support,
         min_item_count=min_item_count, max_item_count=max_item_count,
         sort_by=sort_by,
-        account_name=account_name, merge_leve2=merge_leve2,
+        account_name=account_name, merge_leve2=merge_leve2, platform=platform,
     )
     result = json.dumps(data, ensure_ascii=False, indent=2)
     return _log_tool_output("get_frequent_itemsets", result)
@@ -215,7 +234,7 @@ def get_frequent_itemsets(
     "\n- 从 get_frequent_itemsets 中发现有价值的项集后,批量查看其匹配了哪些帖子"
     "\n- 获取 post_ids 后可传给 get_post_elements 查看帖子的具体内容"
     "\n- 支持传入多个 itemset_id,一次获取多个项集的详情,减少调用次数")
-def get_itemset_detail(itemset_ids) -> str:
+def get_itemset_detail(itemset_ids, merge_leve2=None, platform=None) -> str:
     """获取一个或多个频繁项集的详情,包括每个项集的维度模式、depth、items 结构(含分类路径、元素名称、维度、点类型)和匹配的帖子ID列表。
 
     Args:
@@ -225,17 +244,24 @@ def get_itemset_detail(itemset_ids) -> str:
         项集详情列表的JSON字符串,每项含 id, dimension_mode, target_depth, items, post_ids, absolute_support。
     """
     itemset_ids = _normalize_itemset_ids(itemset_ids)
+    merge_leve2 = _resolve_scope_arg(merge_leve2, "merge_leve2")
+    platform = _resolve_scope_arg(platform, "platform")
     if _is_mysql_demand_content_entrypoint():
         max_ids = max(_env_int("DEMAND_ITEMSET_DETAIL_MAX_IDS", 12), 1)
         itemset_ids = itemset_ids[:max_ids]
-    params = {"itemset_ids": itemset_ids}
+    params = {"itemset_ids": itemset_ids, "merge_leve2": merge_leve2, "platform": platform}
     _log_tool_input("get_itemset_detail", params)
 
     if not itemset_ids:
         return _log_tool_output("get_itemset_detail", "错误: itemset_ids 为空或无法解析")
 
     execution_id = TopicBuildAgentContext.get_execution_id()
-    data = pattern_service.get_itemset_posts(itemset_ids, execution_id=execution_id)
+    data = pattern_service.get_itemset_posts(
+        itemset_ids,
+        execution_id=execution_id,
+        merge_leve2=merge_leve2,
+        platform=platform,
+    )
     if not data:
         return _log_tool_output("get_itemset_detail", f"未找到 itemset_ids={itemset_ids} 的项集")
 

+ 132 - 20
examples/demand/evidence_pack_builder.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import json
+import os
 import re
 from collections import defaultdict
 from collections.abc import Iterable, Mapping
@@ -14,6 +15,7 @@ from examples.demand.db_manager import (
     query_execution_for_evidence,
     query_itemset_evidence,
     query_itemset_items_with_categories,
+    query_seed_points_for_itemsets,
 )
 
 
@@ -28,6 +30,10 @@ def build_evidence_pack(
         trace_id: str,
         demand_task_id: int | None,
         demand_content_id: int | None,
+        *,
+        demand_scope: Mapping[str, Any] | None = None,
+        merge_leve2: str | None = None,
+        platform: str | None = None,
 ) -> dict[str, Any]:
     """Return a DB-validated evidence pack or a reject result.
 
@@ -41,6 +47,9 @@ def build_evidence_pack(
             trace_id=trace_id,
             demand_task_id=demand_task_id,
             demand_content_id=demand_content_id,
+            demand_scope=demand_scope,
+            merge_leve2=merge_leve2,
+            platform=platform,
         )
     except Exception as exc:
         return _reject(f"db evidence validation failed: {exc}")
@@ -52,8 +61,22 @@ def _build_evidence_pack(
         trace_id: str,
         demand_task_id: int | None,
         demand_content_id: int | None,
+        *,
+        demand_scope: Mapping[str, Any] | None = None,
+        merge_leve2: str | None = None,
+        platform: str | None = None,
 ) -> dict[str, Any]:
     evidence_refs = _extract_evidence_refs(demand_item)
+    resolved_scope = _resolve_demand_scope(
+        execution_id=int(execution_id),
+        evidence_refs=evidence_refs,
+        demand_item=demand_item,
+        demand_scope=demand_scope,
+        merge_leve2=merge_leve2,
+        platform=platform,
+    )
+    scope_merge_leve2 = _clean_str(resolved_scope.get("merge_leve2"))
+    scope_platform = _clean_str(resolved_scope.get("platform"))
     itemset_ids, invalid_itemset_ids = _normalize_int_list(
         _first_present(
             evidence_refs.get("itemset_ids"),
@@ -72,6 +95,8 @@ def _build_evidence_pack(
         return _reject("missing source_kind=pattern_itemset")
     if not itemset_ids:
         return _reject("missing itemset_ids for source_kind=pattern_itemset")
+    if len(itemset_ids) != 1:
+        return _reject("V2 exact evidence requires exactly one itemset_id per DemandItem")
 
     execution = query_execution_for_evidence(int(execution_id))
     if not execution:
@@ -82,7 +107,12 @@ def _build_evidence_pack(
             f"execution_id {execution_id} status is {execution.get('status')}, not success"
         )
 
-    itemsets = query_itemset_evidence(execution_id=int(execution_id), itemset_ids=itemset_ids)
+    itemsets = query_itemset_evidence(
+        execution_id=int(execution_id),
+        itemset_ids=itemset_ids,
+        merge_leve2=scope_merge_leve2 or None,
+        platform=scope_platform or None,
+    )
     found_itemset_ids = {int(itemset["id"]) for itemset in itemsets}
     missing_itemset_ids = [itemset_id for itemset_id in itemset_ids if itemset_id not in found_itemset_ids]
     if missing_itemset_ids:
@@ -145,13 +175,22 @@ def _build_evidence_pack(
     else:
         source_post_id = requested_source_post_id
 
-    seed_terms = _resolve_seed_terms(evidence_refs, itemset_items, element_bindings)
-    seed_reason = _validate_seed_terms(seed_terms, itemset_items, element_bindings)
+    seed_terms = _build_seed_terms_from_itemset_items(itemset_items)
+    seed_reason = _validate_seed_terms(seed_terms, itemset_items, [])
     if seed_reason:
         return _reject(seed_reason)
 
     case_rows = query_case_ids_by_post_ids(matched_post_ids)
     decode_case_ids = _unique_strings(row.get("case_id") for row in case_rows)
+    query_seed_points = query_seed_points_for_itemsets(
+        execution_id=int(execution_id),
+        itemset_ids=itemset_ids,
+        matched_post_ids=matched_post_ids,
+        top_k=_env_int("DEMAND_QUERY_SEED_POINTS_TOP_K", 30),
+    )
+    support_itemset = itemsets[0]
+    scoped_post_count = support_itemset.get("scoped_post_count")
+    filtered_absolute_support = support_itemset.get("filtered_absolute_support")
 
     evidence_pack = {
         "pattern_source_system": PATTERN_SOURCE_SYSTEM,
@@ -166,11 +205,15 @@ def _build_evidence_pack(
         "itemset_items": _build_itemset_items(itemset_items),
         "support": min(float(itemset["support"]) for itemset in itemsets),
         "absolute_support": min(int(itemset["absolute_support"]) for itemset in itemsets),
+        "filtered_absolute_support": int(filtered_absolute_support) if filtered_absolute_support is not None else None,
+        "scoped_post_count": int(scoped_post_count) if scoped_post_count is not None else None,
         "matched_post_ids": matched_post_ids,
         "video_ids": matched_post_ids,
         "case_ids": matched_post_ids,
         "decode_case_ids": decode_case_ids,
         "seed_terms": seed_terms,
+        "query_seed_points": query_seed_points,
+        "demand_scope": resolved_scope,
         "trace_id": trace_id,
         "demand_task_id": demand_task_id,
         "demand_content_id": demand_content_id,
@@ -218,6 +261,66 @@ def _extract_evidence_refs(demand_item: Any) -> dict[str, Any]:
     return dict(value) if isinstance(value, Mapping) else {}
 
 
+def _resolve_demand_scope(
+        *,
+        execution_id: int,
+        evidence_refs: Mapping[str, Any],
+        demand_item: Any,
+        demand_scope: Mapping[str, Any] | None,
+        merge_leve2: str | None,
+        platform: str | None,
+) -> dict[str, Any]:
+    raw_scope = {}
+    for value in (
+            demand_scope,
+            evidence_refs.get("demand_scope"),
+            _read_field(demand_item, "demand_scope"),
+    ):
+        if isinstance(value, Mapping):
+            raw_scope = dict(value)
+            break
+
+    scope_source = _clean_str(raw_scope.get("scope_source"))
+    if not scope_source:
+        scope_source = "odps_gap" if raw_scope.get("gap_dt") or raw_scope.get("lack_count") is not None else "manual_cli"
+
+    resolved = {
+        "scope_source": scope_source,
+        "merge_leve2": _clean_str(
+            _first_present(
+                merge_leve2,
+                raw_scope.get("merge_leve2"),
+                raw_scope.get("merge_level2"),
+                evidence_refs.get("merge_leve2"),
+                evidence_refs.get("merge_level2"),
+            )
+        ),
+        "platform": _clean_str(
+            _first_present(
+                platform,
+                raw_scope.get("platform"),
+                raw_scope.get("platform_type"),
+                evidence_refs.get("platform"),
+                evidence_refs.get("platform_type"),
+            )
+        ),
+        "pattern_execution_id": int(execution_id),
+    }
+
+    for field in ("gap_dt", "requested_count", "lack_count"):
+        value = raw_scope.get(field)
+        if value is not None and value != "":
+            resolved[field] = value
+    return resolved
+
+
+def _env_int(name: str, default: int) -> int:
+    try:
+        return int(os.getenv(name, str(default)))
+    except ValueError:
+        return default
+
+
 def _read_field(source: Any, field: str, default: Any = None) -> Any:
     if source is None:
         return default
@@ -343,7 +446,20 @@ def _validate_itemset_facts(itemsets: list[dict[str, Any]]) -> str | None:
         matched_post_ids = itemset.get("matched_post_ids") or []
         if not matched_post_ids:
             return f"itemset_id {itemset_id} missing matched_post_ids"
-        if len(matched_post_ids) != int(itemset["absolute_support"]):
+        scoped_post_count = itemset.get("scoped_post_count")
+        if scoped_post_count is not None:
+            scoped_count = int(scoped_post_count)
+            if len(matched_post_ids) != scoped_count:
+                return (
+                    f"itemset_id {itemset_id} matched_post_ids count {len(matched_post_ids)} "
+                    f"does not equal scoped_post_count {scoped_count}"
+                )
+            if scoped_count > int(itemset["absolute_support"]):
+                return (
+                    f"itemset_id {itemset_id} scoped_post_count {scoped_count} "
+                    f"is greater than absolute_support {itemset['absolute_support']}"
+                )
+        elif len(matched_post_ids) != int(itemset["absolute_support"]):
             return (
                 f"itemset_id {itemset_id} matched_post_ids count {len(matched_post_ids)} "
                 f"does not equal absolute_support {itemset['absolute_support']}"
@@ -491,19 +607,8 @@ def _validate_element_bindings(
     return None
 
 
-def _resolve_seed_terms(
-        evidence_refs: Mapping[str, Any],
-        itemset_items: list[dict[str, Any]],
-        element_bindings: list[dict[str, Any]],
-) -> list[str]:
-    provided = _unique_strings(_normalize_list(evidence_refs.get("seed_terms")))
-    if provided:
-        covered_terms = _build_covered_terms(itemset_items, element_bindings)
-        verified = [term for term in provided if _normalize_term(term) in covered_terms]
-        if verified:
-            return verified
-
-    derived: list[str] = []
+def _build_seed_terms_from_itemset_items(itemset_items: list[dict[str, Any]]) -> list[str]:
+    candidates: list[str] = []
     for item in itemset_items:
         for value in (
                 item.get("element_name"),
@@ -512,9 +617,16 @@ def _resolve_seed_terms(
                 _last_path_part(item.get("category_full_path")),
         ):
             text = _clean_str(value)
-            if text and text not in derived:
-                derived.append(text)
-    return derived
+            if text and text not in candidates:
+                candidates.append(text)
+
+    filtered = [
+        term for term in candidates
+        if _normalize_term(term) not in {"其他", "其它", "未知", "内容", "视频"}
+    ]
+    source = filtered or candidates
+    max_terms = max(_env_int("DEMAND_SEED_TERMS_MAX", 10), 1)
+    return source[:max_terms]
 
 
 def _validate_seed_terms(

+ 39 - 1
examples/demand/mysql_demand_content_sink.py

@@ -143,11 +143,19 @@ def _validate_row(row: dict[str, Any], ext_data: dict[str, Any]) -> None:
         "case_ids",
         "seed_terms",
         "trace_id",
+        "query_seed_points",
+        "demand_scope",
     ]
     for field in required:
         value = evidence_pack.get(field)
+        if field == "query_seed_points":
+            if value is None or not isinstance(value, list):
+                raise ValueError("missing evidence_pack.query_seed_points")
+            continue
         if value is None or value == "" or value == []:
             raise ValueError(f"missing evidence_pack.{field}")
+    if not isinstance(evidence_pack.get("itemset_ids"), list) or len(evidence_pack["itemset_ids"]) != 1:
+        raise ValueError("evidence_pack.itemset_ids must contain exactly one itemset_id")
 
     matched_post_ids = [str(post_id) for post_id in evidence_pack.get("matched_post_ids") or []]
     if str(evidence_pack["source_post_id"]) not in set(matched_post_ids):
@@ -156,8 +164,38 @@ def _validate_row(row: dict[str, Any], ext_data: dict[str, Any]) -> None:
         raise ValueError("evidence_pack.video_ids must equal matched_post_ids")
     if [str(post_id) for post_id in evidence_pack.get("case_ids") or []] != matched_post_ids:
         raise ValueError("evidence_pack.case_ids must equal matched_post_ids")
-    if len(matched_post_ids) < int(evidence_pack["absolute_support"]):
+    demand_scope = evidence_pack.get("demand_scope") or {}
+    if not isinstance(demand_scope, dict):
+        raise ValueError("evidence_pack.demand_scope must be object")
+    if demand_scope.get("merge_leve2") and row.get("merge_leve2") != demand_scope.get("merge_leve2"):
+        raise ValueError("evidence_pack.demand_scope.merge_leve2 must equal row.merge_leve2")
+
+    scoped_count = evidence_pack.get("scoped_post_count")
+    filtered_support = evidence_pack.get("filtered_absolute_support")
+    if scoped_count is not None:
+        scoped_count = int(scoped_count)
+        if scoped_count <= 0:
+            raise ValueError("evidence_pack.scoped_post_count must be positive")
+        if len(matched_post_ids) != scoped_count:
+            raise ValueError("evidence_pack.matched_post_ids must cover scoped_post_count")
+        if scoped_count > int(evidence_pack["absolute_support"]):
+            raise ValueError("evidence_pack.scoped_post_count must not exceed absolute_support")
+    elif len(matched_post_ids) < int(evidence_pack["absolute_support"]):
         raise ValueError("evidence_pack.matched_post_ids must cover absolute_support")
+    if filtered_support is not None and scoped_count is not None and int(filtered_support) != scoped_count:
+        raise ValueError("evidence_pack.filtered_absolute_support must equal scoped_post_count")
+
+    expected_rank = 1
+    for point in evidence_pack.get("query_seed_points") or []:
+        if not isinstance(point, dict):
+            raise ValueError("evidence_pack.query_seed_points items must be objects")
+        if point.get("point_type") not in {"灵感点", "目的点"}:
+            raise ValueError("evidence_pack.query_seed_points point_type must be 灵感点 or 目的点")
+        if int(point.get("coverage_post_count") or 0) <= 0:
+            raise ValueError("evidence_pack.query_seed_points coverage_post_count must be positive")
+        if int(point.get("rank") or 0) != expected_rank:
+            raise ValueError("evidence_pack.query_seed_points rank must be continuous")
+        expected_rank += 1
     if not row.get("merge_leve2") or not row.get("name") or not row.get("dt"):
         raise ValueError("missing demand_content merge_leve2/name/dt")
 

+ 25 - 3
examples/demand/pattern_builds/pg_pattern_service.py

@@ -91,8 +91,9 @@ def search_top_itemsets(
         sort_by: str = "absolute_support",
         account_name=None,
         merge_leve2=None,
+        platform=None,
 ) -> dict[str, Any]:
-    del account_name, merge_leve2  # PG Pattern V2 MVP does not filter itemsets by post metadata here.
+    del account_name
     rows = repo.query_topic_itemsets(
         execution_id=execution_id,
         category_ids=category_ids,
@@ -102,6 +103,8 @@ def search_top_itemsets(
         max_item_count=max_item_count,
         sort_by=sort_by,
         limit=max(int(top_n or 20) * 10, int(top_n or 20)),
+        merge_leve2=merge_leve2,
+        platform=platform,
     )
     itemset_ids = [int(row["id"]) for row in rows]
     items_by_itemset: dict[int, list[dict[str, Any]]] = defaultdict(list)
@@ -137,6 +140,9 @@ def search_top_itemsets(
                 "item_count": row.get("item_count"),
                 "support": row.get("support"),
                 "absolute_support": row.get("absolute_support"),
+                "filtered_absolute_support": row.get("filtered_absolute_support"),
+                "scoped_post_count": row.get("scoped_post_count"),
+                "scope_filter": row.get("scope_filter") or {},
                 "dimensions": row.get("dimensions") or [],
                 "items": items_by_itemset.get(int(row["id"]), []),
             }
@@ -145,20 +151,32 @@ def search_top_itemsets(
     return {
         "pattern_source_system": repo.PG_PATTERN_SOURCE_SYSTEM,
         "scope": repo.TOPIC_SCOPE,
+        "scope_filter": rows[0].get("scope_filter") if rows else {},
         "total": len(rows),
         "showing": sum(len(group["itemsets"]) for group in groups.values()),
         "groups": groups,
     }
 
 
-def get_itemset_posts(itemset_ids: list[int], execution_id: int | None = None) -> list[dict[str, Any]]:
+def get_itemset_posts(
+        itemset_ids: list[int],
+        execution_id: int | None = None,
+        *,
+        merge_leve2=None,
+        platform=None,
+) -> list[dict[str, Any]]:
     clean_ids = _clean_ints(itemset_ids)
     if not clean_ids:
         return []
     if execution_id is None:
         raise ValueError("PG Pattern V2 get_itemset_posts requires execution_id")
 
-    itemsets = repo.query_itemset_evidence(execution_id=execution_id, itemset_ids=clean_ids)
+    itemsets = repo.query_itemset_evidence(
+        execution_id=execution_id,
+        itemset_ids=clean_ids,
+        merge_leve2=merge_leve2,
+        platform=platform,
+    )
     itemset_items = repo.query_itemset_items_with_categories(execution_id=execution_id, itemset_ids=clean_ids)
     items_by_itemset: dict[int, list[dict[str, Any]]] = defaultdict(list)
     for item in itemset_items:
@@ -178,10 +196,14 @@ def get_itemset_posts(itemset_ids: list[int], execution_id: int | None = None) -
                 "item_count": itemset.get("item_count"),
                 "support": itemset.get("support"),
                 "absolute_support": itemset.get("absolute_support"),
+                "filtered_absolute_support": itemset.get("filtered_absolute_support"),
+                "scoped_post_count": itemset.get("scoped_post_count"),
+                "scope_filter": itemset.get("scope_filter") or {},
                 "dimensions": itemset.get("dimensions") or [],
                 "items": items_by_itemset.get(int(itemset["id"]), []),
                 "post_ids": itemset.get("matched_post_ids") or [],
                 "matched_post_ids": itemset.get("matched_post_ids") or [],
+                "global_matched_post_ids": itemset.get("global_matched_post_ids") or [],
             }
         )
     return details

+ 335 - 72
examples/demand/pg_pattern_repository.py

@@ -155,6 +155,17 @@ def _to_post_id_list(value: Any) -> list[str]:
     return post_ids
 
 
+def _to_str_filter_list(value: Any) -> list[str]:
+    result: list[str] = []
+    seen: set[str] = set()
+    for item in _jsonish_to_list(value):
+        text = str(item).strip() if item is not None else ""
+        if text and text not in seen:
+            seen.add(text)
+            result.append(text)
+    return result
+
+
 def _to_int_list(values: Any) -> list[int]:
     result: list[int] = []
     seen: set[int] = set()
@@ -171,6 +182,17 @@ def _to_int_list(values: Any) -> list[int]:
     return result
 
 
+def _scope_filter_payload(merge_leve2: Any = None, platform: Any = None) -> dict[str, list[str]]:
+    return {
+        "merge_leve2": _to_str_filter_list(merge_leve2),
+        "platform": _to_str_filter_list(platform),
+    }
+
+
+def _scope_is_active(scope_filter: Mapping[str, list[str]]) -> bool:
+    return bool(scope_filter.get("merge_leve2") or scope_filter.get("platform"))
+
+
 def _clean_names(names: Iterable[str]) -> list[str]:
     result: list[str] = []
     seen: set[str] = set()
@@ -233,71 +255,88 @@ def query_latest_success_execution_id() -> int | None:
     return int(row["id"]) if row else None
 
 
-def query_itemset_evidence(execution_id: int, itemset_ids: Iterable[Any]) -> list[dict[str, Any]]:
+def query_itemset_evidence(
+        execution_id: int,
+        itemset_ids: Iterable[Any],
+        *,
+        merge_leve2: Any = None,
+        platform: Any = None,
+) -> list[dict[str, Any]]:
     """Read PG topic-scope itemset facts and aggregate matched posts."""
     clean_ids = _to_int_list(itemset_ids or [])
     if not clean_ids:
         return []
 
+    scope_filter = _scope_filter_payload(merge_leve2=merge_leve2, platform=platform)
+    scoped = _scope_is_active(scope_filter)
+    scope_clauses: list[str] = []
+    scope_params: list[Any] = []
+    if scope_filter["merge_leve2"]:
+        scope_clauses.append("p_scope.merge_leve2 = ANY(%s)")
+        scope_params.append(scope_filter["merge_leve2"])
+    if scope_filter["platform"]:
+        scope_clauses.append("p_scope.platform = ANY(%s)")
+        scope_params.append(scope_filter["platform"])
+    scope_where_sql = f" AND {' AND '.join(scope_clauses)}" if scope_clauses else ""
+
     rows = _fetch_all(
-        """
+        f"""
+        WITH selected AS (
+            SELECT
+                i.id,
+                i.execution_id,
+                i.mining_config_id,
+                cfg.execution_id AS mining_config_execution_id,
+                cfg.scope AS mining_config_scope,
+                cfg.name AS mining_config_name,
+                cfg.algorithm AS mining_config_algorithm,
+                cfg.params::text AS mining_config_params,
+                cfg.params ->> 'dimension_mode' AS mining_config_dimension_mode,
+                COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS mining_config_target_depth,
+                i.scope,
+                i.combination_type,
+                i.item_count,
+                i.support,
+                i.absolute_support,
+                i.dimensions::text AS dimensions,
+                i.is_cross_point,
+                i.is_cross_layer
+            FROM pattern_itemset i
+            JOIN pattern_mining_config cfg
+              ON cfg.id = i.mining_config_id
+             AND cfg.execution_id = i.execution_id
+             AND cfg.scope = %s
+            WHERE i.execution_id = %s
+              AND i.scope = %s
+              AND i.id = ANY(%s)
+        )
         SELECT
-            i.id,
-            i.execution_id,
-            i.mining_config_id,
-            cfg.execution_id AS mining_config_execution_id,
-            cfg.scope AS mining_config_scope,
-            cfg.name AS mining_config_name,
-            cfg.algorithm AS mining_config_algorithm,
-            cfg.params::text AS mining_config_params,
-            cfg.params ->> 'dimension_mode' AS mining_config_dimension_mode,
-            COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS mining_config_target_depth,
-            i.scope,
-            i.combination_type,
-            i.item_count,
-            i.support,
-            i.absolute_support,
-            i.dimensions::text AS dimensions,
-            i.is_cross_point,
-            i.is_cross_layer,
+            s.*,
             COALESCE(
-                array_agg(p.post_id ORDER BY p.post_id)
-                    FILTER (WHERE p.post_id IS NOT NULL),
+                (
+                    SELECT array_agg(p_all.post_id ORDER BY p_all.post_id)
+                    FROM pattern_itemset_post p_all
+                    WHERE p_all.itemset_id = s.id
+                      AND p_all.execution_id = s.execution_id
+                ),
                 ARRAY[]::varchar[]
-            ) AS matched_post_ids
-        FROM pattern_itemset i
-        JOIN pattern_mining_config cfg
-          ON cfg.id = i.mining_config_id
-         AND cfg.execution_id = i.execution_id
-         AND cfg.scope = %s
-        LEFT JOIN pattern_itemset_post p
-          ON p.itemset_id = i.id
-         AND p.execution_id = i.execution_id
-        WHERE i.execution_id = %s
-          AND i.scope = %s
-          AND i.id = ANY(%s)
-        GROUP BY
-            i.id,
-            i.execution_id,
-            i.mining_config_id,
-            cfg.execution_id,
-            cfg.scope,
-            cfg.name,
-            cfg.algorithm,
-            cfg.params::text,
-            cfg.params ->> 'dimension_mode',
-            COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth'),
-            i.scope,
-            i.combination_type,
-            i.item_count,
-            i.support,
-            i.absolute_support,
-            i.dimensions::text,
-            i.is_cross_point,
-            i.is_cross_layer
-        ORDER BY i.id
+            ) AS global_matched_post_ids,
+            COALESCE(
+                (
+                    SELECT array_agg(DISTINCT p_scope.post_id ORDER BY p_scope.post_id)
+                    FROM pattern_itemset_post p_scope
+                    JOIN post post_scope
+                      ON post_scope.post_id = p_scope.post_id
+                    WHERE p_scope.itemset_id = s.id
+                      AND p_scope.execution_id = s.execution_id
+                      {scope_where_sql.replace('p_scope.merge_leve2', 'post_scope.merge_leve2').replace('p_scope.platform', 'post_scope.platform')}
+                ),
+                ARRAY[]::varchar[]
+            ) AS filtered_matched_post_ids
+        FROM selected s
+        ORDER BY s.id
         """,
-        (TOPIC_SCOPE, int(execution_id), TOPIC_SCOPE, clean_ids),
+        tuple([TOPIC_SCOPE, int(execution_id), TOPIC_SCOPE, clean_ids, *scope_params]),
     )
 
     result: list[dict[str, Any]] = []
@@ -322,7 +361,14 @@ def query_itemset_evidence(execution_id: int, itemset_ids: Iterable[Any]) -> lis
                 itemset["mining_config_params"] = json.loads(itemset["mining_config_params"])
             except json.JSONDecodeError:
                 pass
-        itemset["matched_post_ids"] = _to_post_id_list(itemset.get("matched_post_ids"))
+        global_post_ids = _to_post_id_list(itemset.get("global_matched_post_ids"))
+        filtered_post_ids = _to_post_id_list(itemset.get("filtered_matched_post_ids"))
+        itemset["global_matched_post_ids"] = global_post_ids
+        itemset["filtered_matched_post_ids"] = filtered_post_ids if scoped else []
+        itemset["filtered_absolute_support"] = len(filtered_post_ids) if scoped else None
+        itemset["scoped_post_count"] = len(filtered_post_ids) if scoped else None
+        itemset["scope_filter"] = scope_filter if scoped else {}
+        itemset["matched_post_ids"] = filtered_post_ids if scoped else global_post_ids
         result.append(itemset)
 
     by_id = {itemset["id"]: itemset for itemset in result}
@@ -681,12 +727,17 @@ def query_topic_itemsets(
         max_item_count: int | None = None,
         sort_by: str = "absolute_support",
         limit: int = 100,
+        merge_leve2: Any = None,
+        platform: Any = None,
 ) -> list[dict[str, Any]]:
+    scope_filter = _scope_filter_payload(merge_leve2=merge_leve2, platform=platform)
+    scoped = _scope_is_active(scope_filter)
+
     clauses = [
         "i.execution_id = %s",
         "i.scope = %s",
     ]
-    params: list[Any] = [int(execution_id), TOPIC_SCOPE]
+    where_params: list[Any] = [int(execution_id), TOPIC_SCOPE]
     clean_category_ids = _to_int_list(category_ids or [])
     for category_id in clean_category_ids:
         clauses.append(
@@ -699,26 +750,109 @@ def query_topic_itemsets(
             )
             """
         )
-        params.append(category_id)
+        where_params.append(category_id)
     if dimension_mode:
         clauses.append("cfg.params ->> 'dimension_mode' = %s")
-        params.append(dimension_mode)
+        where_params.append(dimension_mode)
     if min_support is not None:
-        clauses.append("i.absolute_support >= %s")
-        params.append(int(min_support))
+        if scoped:
+            clauses.append("COALESCE(scoped_posts.filtered_absolute_support, 0) >= %s")
+        else:
+            clauses.append("i.absolute_support >= %s")
+        where_params.append(int(min_support))
     if min_item_count is not None:
         clauses.append("i.item_count >= %s")
-        params.append(int(min_item_count))
+        where_params.append(int(min_item_count))
     if max_item_count is not None:
         clauses.append("i.item_count <= %s")
-        params.append(int(max_item_count))
-
-    order_sql = {
-        "support": "i.support DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
-        "item_count": "i.item_count DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
-        "absolute_support": "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST",
-    }.get(str(sort_by or "absolute_support"), "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST")
-    params.append(int(limit))
+        where_params.append(int(max_item_count))
+
+    if scoped:
+        order_sql = {
+            "item_count": "i.item_count DESC NULLS LAST, COALESCE(scoped_posts.filtered_absolute_support, 0) DESC",
+            "support": "COALESCE(scoped_posts.filtered_absolute_support, 0) DESC, i.support DESC NULLS LAST",
+            "absolute_support": "COALESCE(scoped_posts.filtered_absolute_support, 0) DESC, i.absolute_support DESC NULLS LAST",
+        }.get(
+            str(sort_by or "absolute_support"),
+            "COALESCE(scoped_posts.filtered_absolute_support, 0) DESC, i.absolute_support DESC NULLS LAST",
+        )
+    else:
+        order_sql = {
+            "support": "i.support DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
+            "item_count": "i.item_count DESC NULLS LAST, i.absolute_support DESC NULLS LAST",
+            "absolute_support": "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST",
+        }.get(str(sort_by or "absolute_support"), "i.absolute_support DESC NULLS LAST, i.support DESC NULLS LAST")
+
+    scope_clauses: list[str] = []
+    scope_params: list[Any] = []
+    if scope_filter["merge_leve2"]:
+        scope_clauses.append("post_scope.merge_leve2 = ANY(%s)")
+        scope_params.append(scope_filter["merge_leve2"])
+    if scope_filter["platform"]:
+        scope_clauses.append("post_scope.platform = ANY(%s)")
+        scope_params.append(scope_filter["platform"])
+    scope_where_sql = f" AND {' AND '.join(scope_clauses)}" if scope_clauses else ""
+
+    if scoped:
+        return _fetch_all(
+            f"""
+            WITH scoped_posts AS (
+                SELECT
+                    p_scope.itemset_id,
+                    COALESCE(
+                        array_agg(DISTINCT p_scope.post_id ORDER BY p_scope.post_id),
+                        ARRAY[]::varchar[]
+                    ) AS filtered_matched_post_ids,
+                    COUNT(DISTINCT p_scope.post_id) AS filtered_absolute_support
+                FROM pattern_itemset_post p_scope
+                JOIN post post_scope
+                  ON post_scope.post_id = p_scope.post_id
+                WHERE p_scope.execution_id = %s
+                  {scope_where_sql}
+                GROUP BY p_scope.itemset_id
+            )
+            SELECT
+                i.id,
+                i.execution_id,
+                i.mining_config_id,
+                cfg.scope AS mining_config_scope,
+                cfg.name AS mining_config_name,
+                cfg.params AS mining_config_params,
+                cfg.params ->> 'dimension_mode' AS dimension_mode,
+                COALESCE(cfg.params ->> 'target_depth', cfg.params ->> 'depth') AS target_depth,
+                i.scope,
+                i.combination_type,
+                i.item_count,
+                i.support,
+                i.absolute_support,
+                scoped_posts.filtered_matched_post_ids,
+                scoped_posts.filtered_absolute_support,
+                scoped_posts.filtered_absolute_support AS scoped_post_count,
+                i.dimensions,
+                i.is_cross_point,
+                i.is_cross_layer,
+                %s::jsonb AS scope_filter
+            FROM pattern_itemset i
+            JOIN pattern_mining_config cfg
+              ON cfg.id = i.mining_config_id
+             AND cfg.execution_id = i.execution_id
+             AND cfg.scope = %s
+            JOIN scoped_posts
+              ON scoped_posts.itemset_id = i.id
+            WHERE {" AND ".join(clauses)}
+            ORDER BY {order_sql}, i.id
+            LIMIT %s
+            """,
+            tuple([
+                int(execution_id),
+                *scope_params,
+                json.dumps(scope_filter, ensure_ascii=False),
+                TOPIC_SCOPE,
+                *where_params,
+                int(limit),
+            ]),
+        )
+
     return _fetch_all(
         f"""
         SELECT
@@ -735,9 +869,13 @@ def query_topic_itemsets(
             i.item_count,
             i.support,
             i.absolute_support,
+            NULL::varchar[] AS filtered_matched_post_ids,
+            NULL::bigint AS filtered_absolute_support,
+            NULL::bigint AS scoped_post_count,
             i.dimensions,
             i.is_cross_point,
-            i.is_cross_layer
+            i.is_cross_layer,
+            %s::jsonb AS scope_filter
         FROM pattern_itemset i
         JOIN pattern_mining_config cfg
           ON cfg.id = i.mining_config_id
@@ -747,10 +885,135 @@ def query_topic_itemsets(
         ORDER BY {order_sql}, i.id
         LIMIT %s
         """,
-        tuple([TOPIC_SCOPE, *params]),
+        tuple([
+            json.dumps({}, ensure_ascii=False),
+            TOPIC_SCOPE,
+            *where_params,
+            int(limit),
+        ]),
     )
 
 
+def query_seed_points_for_itemsets(
+        execution_id: int,
+        itemset_ids: Iterable[Any],
+        matched_post_ids: Iterable[Any],
+        *,
+        top_k: int = 30,
+) -> list[dict[str, Any]]:
+    """Rank searchable source points for scoped itemset support posts.
+
+    The result is intentionally derived from the same PG element schema used by
+    `element_bindings.sample_elements`, but it is a separate search-seed pool:
+    it spans all validated support posts and ranks point_text by distinct-post
+    coverage.
+    """
+    clean_itemset_ids = _to_int_list(itemset_ids or [])
+    clean_post_ids = _to_post_id_list(list(matched_post_ids or []))
+    if not clean_itemset_ids or not clean_post_ids:
+        return []
+
+    rows = _fetch_all(
+        """
+        WITH item_cats AS (
+            SELECT
+                ii.id AS itemset_item_id,
+                ii.itemset_id,
+                ii.point_type,
+                ii.dimension,
+                ii.category_id,
+                ii.category_path,
+                ii.element_name
+            FROM pattern_itemset_item ii
+            JOIN pattern_itemset i
+              ON i.id = ii.itemset_id
+             AND i.execution_id = %s
+             AND i.scope = %s
+            WHERE ii.itemset_id = ANY(%s)
+              AND ii.category_id IS NOT NULL
+        )
+        SELECT
+            e.id,
+            e.post_id,
+            e.source_table,
+            e.source_element_id,
+            e.point_type,
+            e.point_text,
+            e.element_type,
+            e.name,
+            e.category_id,
+            e.category_path,
+            e.topic_point_id,
+            ic.itemset_id AS matched_itemset_id,
+            ic.itemset_item_id AS matched_itemset_item_id,
+            ic.category_id AS matched_category_id
+        FROM pattern_mining_element e
+        JOIN item_cats ic
+          ON ic.category_id = e.category_id
+         AND (ic.point_type IS NULL OR ic.point_type = e.point_type)
+         AND (ic.dimension NOT IN ('实质', '形式', '意图') OR ic.dimension = e.element_type)
+         AND (ic.element_name IS NULL OR ic.element_name = e.name)
+        WHERE e.execution_id = %s
+          AND e.source_table = %s
+          AND e.post_id = ANY(%s)
+          AND e.point_type IN ('灵感点', '目的点')
+          AND e.point_text IS NOT NULL
+          AND e.point_text <> ''
+        ORDER BY e.point_text, e.point_type, e.post_id, e.id
+        """,
+        (
+            int(execution_id),
+            TOPIC_SCOPE,
+            clean_itemset_ids,
+            int(execution_id),
+            TOPIC_ELEMENT_SOURCE_TABLE,
+            clean_post_ids,
+        ),
+    )
+
+    grouped: dict[tuple[str, str], dict[str, Any]] = {}
+    for row in rows:
+        point_text = str(row.get("point_text") or "").strip()
+        point_type = str(row.get("point_type") or "").strip()
+        if not point_text or point_type not in {"灵感点", "目的点"}:
+            continue
+        key = (point_text, point_type)
+        group = grouped.setdefault(
+            key,
+            {
+                "representative": dict(row),
+                "post_ids": set(),
+                "matched_category_ids": set(),
+                "matched_itemset_item_ids": set(),
+            },
+        )
+        if row.get("post_id") is not None:
+            group["post_ids"].add(str(row["post_id"]))
+        if row.get("matched_category_id") is not None:
+            group["matched_category_ids"].add(int(row["matched_category_id"]))
+        if row.get("matched_itemset_item_id") is not None:
+            group["matched_itemset_item_ids"].add(int(row["matched_itemset_item_id"]))
+
+    ranked = sorted(
+        grouped.items(),
+        key=lambda item: (-len(item[1]["post_ids"]), item[0][0], item[0][1]),
+    )
+    result: list[dict[str, Any]] = []
+    for rank, ((point_text, point_type), group) in enumerate(ranked[: max(int(top_k or 30), 0)], start=1):
+        representative = dict(group["representative"])
+        representative.pop("matched_itemset_id", None)
+        representative.pop("matched_itemset_item_id", None)
+        representative.pop("matched_category_id", None)
+        representative["point_text"] = point_text
+        representative["point_type"] = point_type
+        representative["coverage_post_count"] = len(group["post_ids"])
+        representative["rank"] = rank
+        representative["matched_category_ids"] = sorted(group["matched_category_ids"])
+        representative["matched_itemset_item_ids"] = sorted(group["matched_itemset_item_ids"])
+        result.append(representative)
+    return result
+
+
 def query_weight_score_rows(
         execution_id: int,
         *,

+ 31 - 4
examples/demand/run.py

@@ -61,7 +61,7 @@ MYSQL_DEMAND_CONTENT_MODE = "mysql_demand_content"
 LOCAL_ENTRYPOINT_ENV = "DEMAND_LOCAL_ENTRYPOINT"
 LOCAL_ENTRYPOINT_NAME = "run_existing_execution_local"
 MYSQL_ENTRYPOINT_ENV = "DEMAND_MYSQL_ENTRYPOINT"
-MYSQL_ENTRYPOINT_NAME = "run_existing_execution_mysql"
+MYSQL_ENTRYPOINT_NAMES = {"run_existing_execution_mysql", "run_hive_gap_mysql"}
 
 
 def _is_local_json_mode() -> bool:
@@ -95,10 +95,11 @@ def _require_mysql_entrypoint() -> None:
     if not _is_mysql_demand_content_mode():
         return
     entrypoint = os.getenv(MYSQL_ENTRYPOINT_ENV, "").strip()
-    if entrypoint != MYSQL_ENTRYPOINT_NAME:
+    if entrypoint not in MYSQL_ENTRYPOINT_NAMES:
         raise RuntimeError(
             "DEMAND_OUTPUT_MODE=mysql_demand_content 只能通过 "
-            "examples.demand.run_existing_execution_mysql 入口运行"
+            "examples.demand.run_existing_execution_mysql 或 "
+            "examples.demand.run_hive_gap_mysql 入口运行"
         )
 
 
@@ -216,6 +217,8 @@ def _enabled_tools_for_run(configured_tools: list[str]) -> list[str]:
         allowed = {
             "get_frequent_itemsets",
             "get_itemset_detail",
+            "get_weight_score_topn",
+            "get_weight_score_by_name",
             "create_demand_item",
             "create_demand_items",
         }
@@ -287,7 +290,7 @@ def _load_name_score_map(execution_id: int) -> dict:
 
 def _ensure_pg_weight_score_files(execution_id: int) -> None:
     """Build local PG weight files when local JSON mode needs them."""
-    if not _is_local_json_mode():
+    if not (_is_local_json_mode() or _is_mysql_demand_content_mode()):
         return
     from examples.demand.pg_weight_score_builder import build_pg_weight_score_files
 
@@ -381,6 +384,7 @@ def _build_demand_content_rows(
         task_id: Optional[int] = None,
         serialize_ext_data: bool = True,
         require_evidence: bool = False,
+        demand_scope: Optional[dict[str, Any]] = None,
 ) -> tuple[list[dict], list[dict], list[dict]]:
     from examples.demand.evidence_pack_builder import build_evidence_pack
 
@@ -440,6 +444,9 @@ def _build_demand_content_rows(
                 trace_id=trace_id or "",
                 demand_task_id=task_id,
                 demand_content_id=content_id,
+                demand_scope=demand_scope or TopicBuildAgentContext.get_metadata("demand_scope", {}),
+                merge_leve2=merge_level2,
+                platform=TopicBuildAgentContext.get_metadata("platform"),
             )
             if not evidence_result.get("success"):
                 if require_evidence:
@@ -575,6 +582,7 @@ def write_demand_items_to_mysql(
         *,
         trace_id: Optional[str] = None,
         task_id: Optional[int] = None,
+        demand_scope: Optional[dict[str, Any]] = None,
 ) -> int:
     """Write DB-validated demand_content rows to the test MySQL sink only."""
     if not _is_mysql_demand_content_mode():
@@ -589,6 +597,7 @@ def write_demand_items_to_mysql(
         task_id=task_id,
         serialize_ext_data=False,
         require_evidence=True,
+        demand_scope=demand_scope,
     )
     run_label = os.getenv("DEMAND_RUN_LABEL", "").strip()
     result = write_demand_content_rows(rows, run_label=run_label)
@@ -613,6 +622,7 @@ def write_demand_items_to_local_json(
         *,
         trace_id: Optional[str] = None,
         task_id: Optional[int] = None,
+        demand_scope: Optional[dict[str, Any]] = None,
 ) -> int:
     """把普通需求池输出镜像到本地 JSON,不写 demand_content / Hive。"""
     from examples.demand.local_output_sink import LocalOutputSink
@@ -625,6 +635,7 @@ def write_demand_items_to_local_json(
         task_id=task_id,
         serialize_ext_data=False,
         require_evidence=True,
+        demand_scope=demand_scope,
     )
 
     sink = LocalOutputSink(root)
@@ -733,6 +744,7 @@ async def run_once(
         count: int = 30,
         task_id: Optional[int] = None,
         platform_type: Optional[str] = None,
+        demand_scope: Optional[dict[str, Any]] = None,
 ) -> dict:
     from agent.core.runner import AgentRunner
     from agent.llm import create_openrouter_llm_call
@@ -748,10 +760,21 @@ async def run_once(
 
     if _is_local_json_mode():
         _ensure_local_output_paths(execution_id)
+
+    if _is_local_json_mode() or _is_mysql_demand_content_mode():
         _ensure_pg_weight_score_files(int(execution_id))
 
     TopicBuildAgentContext.set_execution_id(execution_id)
     TopicBuildAgentContext.set_metadata("result_base_dir", str(_get_result_base_dir()))
+    TopicBuildAgentContext.set_metadata("merge_leve2", merge_level2)
+    TopicBuildAgentContext.set_metadata("platform", platform_type)
+    resolved_demand_scope = dict(demand_scope or {})
+    resolved_demand_scope.setdefault("scope_source", "manual_cli")
+    resolved_demand_scope.setdefault("merge_leve2", merge_level2)
+    if platform_type:
+        resolved_demand_scope.setdefault("platform", platform_type)
+    resolved_demand_scope.setdefault("pattern_execution_id", int(execution_id))
+    TopicBuildAgentContext.set_metadata("demand_scope", resolved_demand_scope)
 
     base_dir = Path(__file__).parent
     output_dir = _get_output_base_dir()
@@ -847,6 +870,7 @@ async def run_once(
                             merge_level2=merge_level2,
                             trace_id=trace_id,
                             task_id=task_id,
+                            demand_scope=resolved_demand_scope,
                         )
                     else:
                         result_write_count = write_demand_items_to_mysql(
@@ -854,6 +878,7 @@ async def run_once(
                             merge_level2=merge_level2,
                             trace_id=trace_id,
                             task_id=task_id,
+                            demand_scope=resolved_demand_scope,
                         )
             except Exception as e:
                 result_write_error = str(e)
@@ -921,6 +946,7 @@ async def main(
         count,
         execution_id: Optional[int] = None,
         task_id: Optional[int] = None,
+        demand_scope: Optional[dict[str, Any]] = None,
 ) -> dict:
     if not (_is_local_json_mode() or _is_mysql_demand_content_mode()):
         _raise_db_write_disabled("非受控运行入口")
@@ -957,6 +983,7 @@ async def main(
         count=count,
         task_id=task_id,
         platform_type=platform_type,
+        demand_scope=demand_scope,
     )
     result = {
         "execution_id": execution_id,

+ 1 - 0
examples/demand/run_existing_execution_mysql.py

@@ -37,6 +37,7 @@ def _prepare_run_output_paths(execution_id: int, run_label: str) -> None:
     os.environ["DEMAND_RESULT_BASE_DIR"] = str(result_base_dir)
     os.environ["DEMAND_OUTPUT_BASE_DIR"] = str(run_root / "output")
     os.environ["DEMAND_TRACE_STORE_PATH"] = str(run_root / ".trace")
+    os.environ["DEMAND_WEIGHT_DATA_DIR"] = str(run_root / "intermediate" / "data")
 
     demand_items_path = result_base_dir / str(execution_id) / f"execution_id_{execution_id}_demand_items.json"
     if demand_items_path.exists():

+ 164 - 0
examples/demand/run_hive_gap_mysql.py

@@ -0,0 +1,164 @@
+"""Run DemandAgent from Hive supply-gap rows and write only MySQL demand_content.
+
+This entrypoint restores the old business starting point (ODPS/Hive supply
+gap) without restoring prepare/run_mining or any Hive writes.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+
+def _load_project_env() -> None:
+    try:
+        from dotenv import load_dotenv
+    except ImportError:
+        return
+    load_dotenv(Path(__file__).parent.parent.parent / ".env")
+
+
+def _safe_path_segment(value: str) -> str:
+    return "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in value).strip("_") or "run"
+
+
+def _prepare_run_output_paths(execution_id: int, run_label: str) -> None:
+    run_root = Path(__file__).parent / "test_output_data" / "mysql_runs" / _safe_path_segment(run_label)
+    result_base_dir = run_root / "intermediate" / "result"
+    os.environ["DEMAND_RESULT_BASE_DIR"] = str(result_base_dir)
+    os.environ["DEMAND_OUTPUT_BASE_DIR"] = str(run_root / "output")
+    os.environ["DEMAND_TRACE_STORE_PATH"] = str(run_root / ".trace")
+    os.environ["DEMAND_WEIGHT_DATA_DIR"] = str(run_root / "intermediate" / "data")
+
+    demand_items_path = result_base_dir / str(execution_id) / f"execution_id_{execution_id}_demand_items.json"
+    if demand_items_path.exists():
+        demand_items_path.unlink()
+
+
+def _configure_mysql_env(run_label: str) -> None:
+    os.environ["DEMAND_OUTPUT_MODE"] = "mysql_demand_content"
+    os.environ["DEMAND_MYSQL_ENTRYPOINT"] = "run_hive_gap_mysql"
+    os.environ["DEMAND_RUN_LABEL"] = run_label
+    os.environ.setdefault("DEMAND_ITEMSET_DETAIL_MAX_IDS", "12")
+    os.environ.setdefault("DEMAND_ITEMSET_DETAIL_MAX_POST_IDS", "20")
+
+
+def _validate_execution_success(execution_id: int) -> None:
+    from examples.demand.db_manager import query_execution_for_evidence
+
+    execution = query_execution_for_evidence(execution_id)
+    if not execution:
+        raise ValueError(f"execution_id={execution_id} 不存在,Hive gap 入口只允许跑已有 PG execution")
+    if str(execution.get("status") or "").strip().lower() != "success":
+        raise ValueError(
+            f"execution_id={execution_id} status={execution.get('status')},不是 success,拒绝生成需求"
+        )
+
+
+def _build_scope(row: dict[str, Any], execution_id: int, count: int) -> dict[str, Any]:
+    return {
+        "scope_source": "odps_gap",
+        "merge_leve2": row["cluster_name"],
+        "platform": row.get("platform_type") or "piaoquan",
+        "gap_dt": row.get("gap_dt"),
+        "requested_count": int(count),
+        "lack_count": row.get("lack_count"),
+        "pattern_execution_id": int(execution_id),
+    }
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Run DemandAgent from Hive gap rows and write only MySQL demand_content."
+    )
+    parser.add_argument("--execution-id", type=int, default=None, help="Existing PG pattern_mining_execution.id")
+    parser.add_argument("--max-categories", type=int, default=None, help="Only run the first N gap categories")
+    parser.add_argument("--max-total-count", type=int, default=None, help="Cap total requested demand count")
+    parser.add_argument("--run-label-prefix", default=None, help="Prefix stored in ext_data.run_label")
+    parser.add_argument("--dry-run", action="store_true", help="Print gap plan only; do not call the agent")
+    return parser.parse_args()
+
+
+async def async_main() -> dict[str, Any]:
+    _load_project_env()
+    args = parse_args()
+
+    from examples.demand.data_query_tools import get_demand_merge_level2_names
+    from examples.demand.db_manager import query_latest_success_execution_id
+
+    execution_id = args.execution_id or query_latest_success_execution_id()
+    if not execution_id:
+        raise ValueError("未找到 PG Pattern V2 success execution")
+    _validate_execution_success(int(execution_id))
+
+    gap_rows = get_demand_merge_level2_names()
+    if args.max_categories is not None:
+        gap_rows = gap_rows[: max(int(args.max_categories), 0)]
+
+    remaining = int(args.max_total_count) if args.max_total_count is not None else None
+    prefix = args.run_label_prefix or f"hive_gap_mysql_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+    results: list[dict[str, Any]] = []
+    planned: list[dict[str, Any]] = []
+
+    for index, row in enumerate(gap_rows, start=1):
+        requested = int(row.get("count") or 0)
+        if remaining is not None:
+            if remaining <= 0:
+                break
+            requested = min(requested, remaining)
+        if requested <= 0:
+            continue
+
+        cluster_name = str(row["cluster_name"])
+        platform_type = str(row.get("platform_type") or "piaoquan")
+        run_label = f"{prefix}_{index:02d}_{_safe_path_segment(cluster_name)}"
+        scope = _build_scope(row, int(execution_id), requested)
+        planned.append({"run_label": run_label, "count": requested, "demand_scope": scope})
+
+        if args.dry_run:
+            if remaining is not None:
+                remaining -= requested
+            continue
+
+        from examples.demand.run import main as run_main
+
+        _configure_mysql_env(run_label)
+        _prepare_run_output_paths(int(execution_id), run_label)
+        result = await run_main(
+            cluster_name=cluster_name,
+            platform_type=platform_type,
+            count=requested,
+            execution_id=int(execution_id),
+            task_id=None,
+            demand_scope=scope,
+        )
+        result["run_label"] = run_label
+        result["demand_scope"] = scope
+        results.append(result)
+        if remaining is not None:
+            remaining -= requested
+
+    return {
+        "execution_id": int(execution_id),
+        "dry_run": bool(args.dry_run),
+        "planned": planned,
+        "results": results,
+    }
+
+
+def main() -> None:
+    result = asyncio.run(async_main())
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 58 - 0
examples/demand/tests/test_mysql_demand_content_sink.py

@@ -71,6 +71,22 @@ def _valid_row():
         "video_ids": ["p1", "p2"],
         "case_ids": ["p1", "p2"],
         "seed_terms": ["x"],
+        "query_seed_points": [
+            {
+                "point_text": "搜索点",
+                "point_type": "灵感点",
+                "coverage_post_count": 2,
+                "rank": 1,
+            }
+        ],
+        "demand_scope": {
+            "scope_source": "manual_cli",
+            "merge_leve2": "PG Pattern V2 需求测试",
+            "platform": "piaoquan",
+            "pattern_execution_id": 581,
+        },
+        "filtered_absolute_support": 2,
+        "scoped_post_count": 2,
         "trace_id": "trace-1",
     }
     return {
@@ -133,11 +149,53 @@ class MySQLDemandContentSinkTest(unittest.TestCase):
 
         row = _valid_row()
         row["ext_data"]["evidence_pack"]["absolute_support"] = 3
+        row["ext_data"]["evidence_pack"].pop("filtered_absolute_support")
+        row["ext_data"]["evidence_pack"].pop("scoped_post_count")
 
         with patch.object(sink, "_connect", return_value=_FakeConnection()):
             with self.assertRaisesRegex(ValueError, "absolute_support"):
                 sink.write_demand_content_rows([row], run_label="batch01")
 
+    def test_rejects_rows_without_query_seed_points(self):
+        from examples.demand import mysql_demand_content_sink as sink
+
+        row = _valid_row()
+        row["ext_data"]["evidence_pack"].pop("query_seed_points")
+
+        with patch.object(sink, "_connect", return_value=_FakeConnection()):
+            with self.assertRaisesRegex(ValueError, "query_seed_points"):
+                sink.write_demand_content_rows([row], run_label="batch01")
+
+    def test_rejects_invalid_query_seed_point_type(self):
+        from examples.demand import mysql_demand_content_sink as sink
+
+        row = _valid_row()
+        row["ext_data"]["evidence_pack"]["query_seed_points"][0]["point_type"] = "关键点"
+
+        with patch.object(sink, "_connect", return_value=_FakeConnection()):
+            with self.assertRaisesRegex(ValueError, "point_type"):
+                sink.write_demand_content_rows([row], run_label="batch01")
+
+    def test_rejects_mismatched_demand_scope(self):
+        from examples.demand import mysql_demand_content_sink as sink
+
+        row = _valid_row()
+        row["ext_data"]["evidence_pack"]["demand_scope"]["merge_leve2"] = "其他品类"
+
+        with patch.object(sink, "_connect", return_value=_FakeConnection()):
+            with self.assertRaisesRegex(ValueError, "demand_scope"):
+                sink.write_demand_content_rows([row], run_label="batch01")
+
+    def test_rejects_multiple_itemset_ids(self):
+        from examples.demand import mysql_demand_content_sink as sink
+
+        row = _valid_row()
+        row["ext_data"]["evidence_pack"]["itemset_ids"] = [11, 12]
+
+        with patch.object(sink, "_connect", return_value=_FakeConnection()):
+            with self.assertRaisesRegex(ValueError, "exactly one"):
+                sink.write_demand_content_rows([row], run_label="batch01")
+
 
 if __name__ == "__main__":
     unittest.main()

+ 19 - 4
examples/demand/tests/test_pg_evidence_builder.py

@@ -83,6 +83,7 @@ class PgEvidenceBuilderTest(unittest.TestCase):
             patch("examples.demand.evidence_pack_builder.query_itemset_items_with_categories", return_value=[_item()]),
             patch("examples.demand.evidence_pack_builder.query_element_bindings_for_items", return_value=[_binding()]),
             patch("examples.demand.evidence_pack_builder.query_case_ids_by_post_ids", return_value=[]),
+            patch("examples.demand.evidence_pack_builder.query_seed_points_for_itemsets", return_value=[]),
         ]
         for patcher in patches:
             patcher.start()
@@ -96,7 +97,7 @@ class PgEvidenceBuilderTest(unittest.TestCase):
                 "source_tool": "get_itemset_detail",
                 "itemset_ids": [1607313],
                 "source_post_id": "p1",
-                "seed_terms": ["综合性腐败"],
+                "seed_terms": ["LLM伪造词"],
             }
         )
 
@@ -106,6 +107,9 @@ class PgEvidenceBuilderTest(unittest.TestCase):
         self.assertEqual(evidence_pack["validation_status"], "passed")
         self.assertEqual(evidence_pack["source_certainty"], "db_validated")
         self.assertEqual(evidence_pack["matched_post_ids"], ["p1", "p2"])
+        self.assertEqual(evidence_pack["seed_terms"], ["综合性腐败"])
+        self.assertIn("query_seed_points", evidence_pack)
+        self.assertIn("demand_scope", evidence_pack)
 
     def test_non_topic_scope_is_rejected(self):
         self._patch_success(itemsets=[_itemset(scope="topic_element")])
@@ -114,7 +118,6 @@ class PgEvidenceBuilderTest(unittest.TestCase):
                 "source_kind": "pattern_itemset",
                 "itemset_ids": [1607313],
                 "source_post_id": "p1",
-                "seed_terms": ["综合性腐败"],
             }
         )
 
@@ -128,7 +131,6 @@ class PgEvidenceBuilderTest(unittest.TestCase):
                 "source_kind": "pattern_itemset",
                 "itemset_ids": [1607313],
                 "source_post_id": "not-a-source-post",
-                "seed_terms": ["综合性腐败"],
             }
         )
 
@@ -147,19 +149,32 @@ class PgEvidenceBuilderTest(unittest.TestCase):
             patch("examples.demand.evidence_pack_builder.query_itemset_items_with_categories", return_value=[_item()]),
             patch("examples.demand.evidence_pack_builder.query_element_bindings_for_items", side_effect=bindings),
             patch("examples.demand.evidence_pack_builder.query_case_ids_by_post_ids", return_value=[]),
+            patch("examples.demand.evidence_pack_builder.query_seed_points_for_itemsets", return_value=[]),
         ):
             result = self._build(
                 {
                     "source_kind": "pattern_itemset",
                     "itemset_ids": [1607313],
                     "source_post_id": "p1",
-                    "seed_terms": ["综合性腐败"],
                 }
             )
 
         self.assertTrue(result["success"])
         self.assertEqual(result["evidence_pack"]["source_post_id"], "p2")
 
+    def test_multiple_itemsets_are_rejected(self):
+        self._patch_success()
+        result = self._build(
+            {
+                "source_kind": "pattern_itemset",
+                "itemset_ids": [1607313, 1607314],
+                "source_post_id": "p1",
+            }
+        )
+
+        self.assertFalse(result["success"])
+        self.assertIn("exactly one itemset_id", result["reject_reason"])
+
 
 if __name__ == "__main__":
     unittest.main()

+ 64 - 1
examples/demand/tests/test_pg_pattern_repository.py

@@ -1,6 +1,7 @@
 import unittest
+from unittest.mock import patch
 
-from examples.demand.pg_pattern_repository import _is_element_type_dimension
+from examples.demand.pg_pattern_repository import _is_element_type_dimension, query_seed_points_for_itemsets
 
 
 class PgPatternRepositoryTest(unittest.TestCase):
@@ -12,6 +13,68 @@ class PgPatternRepositoryTest(unittest.TestCase):
         self.assertTrue(_is_element_type_dimension("形式"))
         self.assertTrue(_is_element_type_dimension("意图"))
 
+    def test_query_seed_points_filters_and_ranks(self):
+        rows = [
+            {
+                "id": 1,
+                "post_id": "p1",
+                "source_table": "post_decode_topic_point_element",
+                "source_element_id": 11,
+                "point_type": "灵感点",
+                "point_text": "A",
+                "element_type": "实质",
+                "name": "A",
+                "category_id": 10,
+                "category_path": "/A",
+                "topic_point_id": 101,
+                "matched_itemset_item_id": 1001,
+                "matched_category_id": 10,
+            },
+            {
+                "id": 2,
+                "post_id": "p2",
+                "source_table": "post_decode_topic_point_element",
+                "source_element_id": 12,
+                "point_type": "灵感点",
+                "point_text": "A",
+                "element_type": "实质",
+                "name": "A",
+                "category_id": 10,
+                "category_path": "/A",
+                "topic_point_id": 102,
+                "matched_itemset_item_id": 1001,
+                "matched_category_id": 10,
+            },
+            {
+                "id": 3,
+                "post_id": "p1",
+                "source_table": "post_decode_topic_point_element",
+                "source_element_id": 13,
+                "point_type": "目的点",
+                "point_text": "B",
+                "element_type": "实质",
+                "name": "B",
+                "category_id": 10,
+                "category_path": "/B",
+                "topic_point_id": 103,
+                "matched_itemset_item_id": 1002,
+                "matched_category_id": 10,
+            },
+            {
+                "id": 4,
+                "post_id": "p1",
+                "point_type": "关键点",
+                "point_text": "C",
+            },
+        ]
+        with patch("examples.demand.pg_pattern_repository._fetch_all", return_value=rows):
+            result = query_seed_points_for_itemsets(581, [1], ["p1", "p2"], top_k=10)
+
+        self.assertEqual([item["point_text"] for item in result], ["A", "B"])
+        self.assertEqual(result[0]["coverage_post_count"], 2)
+        self.assertEqual(result[0]["rank"], 1)
+        self.assertEqual(result[1]["rank"], 2)
+
 
 if __name__ == "__main__":
     unittest.main()

+ 182 - 0
examples/demand/validate_v2_demand_content.py

@@ -0,0 +1,182 @@
+"""Validate DemandAgent V2 demand_content JSON contracts."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+
+ALLOWED_POINT_TYPES = {"灵感点", "目的点"}
+
+
+def _load_rows(path: Path) -> list[dict[str, Any]]:
+    loaded = json.loads(path.read_text(encoding="utf-8"))
+    if isinstance(loaded, list):
+        return [row for row in loaded if isinstance(row, dict)]
+    if isinstance(loaded, dict) and isinstance(loaded.get("rows"), list):
+        return [row for row in loaded["rows"] if isinstance(row, dict)]
+    raise ValueError(f"{path} is not a demand_content row array")
+
+
+def _as_ext_data(row: dict[str, Any]) -> dict[str, Any]:
+    value = row.get("ext_data")
+    if isinstance(value, dict):
+        return value
+    if isinstance(value, str) and value.strip():
+        parsed = json.loads(value)
+        if isinstance(parsed, dict):
+            return parsed
+    return {}
+
+
+def _derive_seed_candidates(itemset_items: list[dict[str, Any]]) -> set[str]:
+    result: set[str] = set()
+    for item in itemset_items:
+        for field in ("element_name", "category_name", "category_path", "category_full_path"):
+            value = str(item.get(field) or "").strip()
+            if not value:
+                continue
+            result.add(value.replace(" ", ""))
+            for part in value.replace(">", "/").split("/"):
+                part = part.strip()
+                if part:
+                    result.add(part.replace(" ", ""))
+    return result
+
+
+def validate_row(row: dict[str, Any], *, require_scope: bool = True) -> list[str]:
+    errors: list[str] = []
+    for field in ("name", "merge_leve2", "dt", "ext_data"):
+        if not row.get(field):
+            errors.append(f"missing top-level {field}")
+
+    ext_data = _as_ext_data(row)
+    evidence_pack = ext_data.get("evidence_pack")
+    if not isinstance(evidence_pack, dict):
+        return [*errors, "missing ext_data.evidence_pack"]
+
+    expected = {
+        "pattern_source_system": "pg_pattern_v2",
+        "source_kind": "pattern_itemset",
+        "case_id_type": "post_id",
+        "source_certainty": "db_validated",
+        "validation_status": "passed",
+    }
+    for field, expected_value in expected.items():
+        if evidence_pack.get(field) != expected_value:
+            errors.append(f"invalid evidence_pack.{field}: {evidence_pack.get(field)!r}")
+
+    required_non_empty = [
+        "source_post_id",
+        "pattern_execution_id",
+        "mining_config_id",
+        "itemset_ids",
+        "itemset_items",
+        "category_bindings",
+        "element_bindings",
+        "support",
+        "absolute_support",
+        "matched_post_ids",
+        "video_ids",
+        "case_ids",
+        "seed_terms",
+        "trace_id",
+    ]
+    for field in required_non_empty:
+        value = evidence_pack.get(field)
+        if value is None or value == "" or value == []:
+            errors.append(f"missing evidence_pack.{field}")
+    if not isinstance(evidence_pack.get("itemset_ids"), list) or len(evidence_pack["itemset_ids"]) != 1:
+        errors.append("itemset_ids must contain exactly one itemset_id")
+
+    matched_post_ids = [str(post_id) for post_id in evidence_pack.get("matched_post_ids") or []]
+    if str(evidence_pack.get("source_post_id") or "") not in set(matched_post_ids):
+        errors.append("source_post_id not in matched_post_ids")
+    if [str(post_id) for post_id in evidence_pack.get("video_ids") or []] != matched_post_ids:
+        errors.append("video_ids must equal matched_post_ids")
+    if [str(post_id) for post_id in evidence_pack.get("case_ids") or []] != matched_post_ids:
+        errors.append("case_ids must equal matched_post_ids")
+
+    query_seed_points = evidence_pack.get("query_seed_points")
+    if not isinstance(query_seed_points, list):
+        errors.append("query_seed_points must exist as array")
+    else:
+        previous_coverage: int | None = None
+        for index, point in enumerate(query_seed_points, start=1):
+            if not isinstance(point, dict):
+                errors.append(f"query_seed_points[{index}] must be object")
+                continue
+            if point.get("point_type") not in ALLOWED_POINT_TYPES:
+                errors.append(f"query_seed_points[{index}].point_type invalid")
+            coverage = int(point.get("coverage_post_count") or 0)
+            if coverage <= 0:
+                errors.append(f"query_seed_points[{index}].coverage_post_count invalid")
+            if int(point.get("rank") or 0) != index:
+                errors.append(f"query_seed_points[{index}].rank invalid")
+            if previous_coverage is not None and coverage > previous_coverage:
+                errors.append("query_seed_points not sorted by coverage_post_count desc")
+            previous_coverage = coverage
+
+    itemset_items = evidence_pack.get("itemset_items") or []
+    seed_candidates = _derive_seed_candidates(itemset_items)
+    for term in evidence_pack.get("seed_terms") or []:
+        if str(term).replace(" ", "") not in seed_candidates:
+            errors.append(f"seed_term not derived from itemset_items: {term}")
+
+    demand_scope = evidence_pack.get("demand_scope")
+    if require_scope and not isinstance(demand_scope, dict):
+        errors.append("missing demand_scope")
+    elif isinstance(demand_scope, dict):
+        if demand_scope.get("merge_leve2") and demand_scope.get("merge_leve2") != row.get("merge_leve2"):
+            errors.append("demand_scope.merge_leve2 mismatch")
+        if demand_scope.get("scope_source") == "odps_gap":
+            for field in ("gap_dt", "requested_count", "lack_count"):
+                if demand_scope.get(field) in (None, ""):
+                    errors.append(f"missing demand_scope.{field}")
+
+    scoped_count = evidence_pack.get("scoped_post_count")
+    if require_scope and scoped_count is None:
+        errors.append("missing scoped_post_count")
+    if scoped_count is not None:
+        scoped_count = int(scoped_count)
+        if scoped_count <= 0:
+            errors.append("scoped_post_count must be positive")
+        if len(matched_post_ids) != scoped_count:
+            errors.append("matched_post_ids length must equal scoped_post_count")
+        if scoped_count > int(evidence_pack.get("absolute_support") or 0):
+            errors.append("scoped_post_count must not exceed absolute_support")
+        if evidence_pack.get("filtered_absolute_support") is not None:
+            if int(evidence_pack["filtered_absolute_support"]) != scoped_count:
+                errors.append("filtered_absolute_support must equal scoped_post_count")
+    return errors
+
+
+def validate_file(path: Path, *, require_scope: bool = True) -> list[str]:
+    errors: list[str] = []
+    rows = _load_rows(path)
+    for index, row in enumerate(rows, start=1):
+        for error in validate_row(row, require_scope=require_scope):
+            errors.append(f"{path}:{index}: {error}")
+    return errors
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Validate DemandAgent V2 demand_content JSON.")
+    parser.add_argument("paths", nargs="+", type=Path)
+    parser.add_argument("--allow-no-scope", action="store_true")
+    args = parser.parse_args()
+
+    errors: list[str] = []
+    for path in args.paths:
+        errors.extend(validate_file(path, require_scope=not args.allow_no_scope))
+    if errors:
+        print("\n".join(errors), file=sys.stderr)
+        raise SystemExit(1)
+    print(json.dumps({"success": True, "files": [str(path) for path in args.paths]}, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+    main()

+ 347 - 0
产品迭代V2.md

@@ -0,0 +1,347 @@
+# DemandAgent 产品迭代 V2:Query Seed 与证据字段确定性升级
+
+## 1. 背景
+
+V1 已经完成 DemandAgent 输出协议的核心升级:正式给下游 FH / ContentFindAgent 消费的主载体是 `demand_content.ext_data.evidence_pack`,Pattern 证据源固定为 PG Pattern V2,且只输出 `pattern_itemset.scope='topic'` 的 exact evidence。
+
+V2 要解决两个新问题:
+
+1. `seed_terms` 不能再由 LLM 候选决定。它是下游 Query 的事实输入,必须从 PG itemset 的真实 items 派生。
+2. CFA 需要一批更适合搜索对标内容的原始票圈点位。现有 `element_bindings[].sample_elements` 在 DemandAgent 设计里原本只是证据样例;CFA 当前因为没有专门的点位池字段,才临时拿它当搜索种子使用,导致可用点位数量太少。
+
+因此,V2 的产品目标是:**保留 LLM 生成需求表达的能力,但把所有搜索种子和证据字段改成代码从真实 DB 确定性派生。**
+
+## 2. V1 现状
+
+### 2.1 LLM 现在负责什么
+
+当前 DemandAgent 仍由 LLM 判断一个 itemset 是否值得变成需求,并输出候选 `DemandItem`:
+
+| 字段 | 当前作用 |
+| --- | --- |
+| `element_names` | 需求名称的组成词,后续拼成 `demand_content.name` |
+| `reason` | 为什么生成这条需求 |
+| `desc` | 给下游或运营看的需求描述 |
+| `type` | 需求来源类型,V1 主要是 `pattern` |
+| `evidence_refs` | LLM 声明的候选证据引用 |
+
+V1 的边界是:`evidence_refs` 只是候选,不是事实。真正进入 `evidence_pack` 的字段必须由代码从 PG DB 强校验和补齐。
+
+### 2.2 现在的问题
+
+#### 问题一:`seed_terms` 仍可能被 LLM 影响
+
+当前代码会优先读取 `evidence_refs.seed_terms`。如果这些词能被 DB evidence 覆盖,就会采用 LLM 给出的 subset;只有 LLM 没给或无法覆盖时,代码才从 `itemset_items` 兜底派生。
+
+这会造成一个产品风险:同一个 itemset 的事实词表是稳定的,但不同 LLM 输出可能让 `seed_terms` 变短、变偏,影响下游 Query 输入。
+
+#### 问题二:`element_bindings.sample_elements` 只是证据样例,不足以承担搜索点位池职责
+
+现有 `element_bindings.sample_elements` 在 DemandAgent 设计里原本不是搜索词池,而是证据样例:它证明某个 `source_post_id` 能闭合 itemset item 与 PG 元素证据。CFA 当前是因为没有更合适的字段,才临时拿这些 sample elements 当搜索种子使用。
+
+这会导致 CFA 只能拿到少量样例点位,而不是该需求背后真正高覆盖的票圈点位。CFA 需要的是另一类数据:从该需求全部票圈支撑帖中,筛出能命中当前 itemset 分类节点的原始点位,再按 `point_text` 覆盖度排序成 Top-K,用于首轮搜索对标内容。
+
+#### 问题三:云端 MySQL 单表入口没有使用高权重线索
+
+当前云端 MySQL 单表写库入口为了快速批量生成,只开放 `get_frequent_itemsets`、`get_itemset_detail` 和创建需求工具。它没有先看高权重元素 / 高权重分类,候选漏斗比完整产品逻辑更窄。
+
+#### 问题四:V2 还缺少老版的供需缺口驱动入口
+
+老版 DemandAgent 的业务起点不是人工指定一个 Pattern execution,而是先从 Hive 供需缺口表里找出“今天哪些二级品类缺内容”,再针对这些 `merge二级品类` 生成需求。新版 V1 为了先打通 PG evidence,采用了手动传入 `pattern_mining_execution.id` 的方式,但这还不是完整生产链路。
+
+V2 需要恢复这层业务入口:Hive 继续负责判断“哪个品类需要需求”,PG Pattern V2 负责提供“这个品类下面有哪些真实 Pattern 可以生成需求”。这里不恢复老版 `run_mining()`,也不新建 MySQL `topic_pattern_execution`。
+
+## 3. V2 产品目标
+
+### 3.1 需求发现逻辑
+
+V2 保留原有 DemandAgent 的业务判断方式,但把候选漏斗补完整:
+
+```text
+Hive 供需缺口表
+        ↓
+筛出 merge二级品类 + count
+        ↓
+选择最新 status=success 的 PG Pattern V2 execution
+        ↓
+按 merge_leve2/platform 过滤该 execution 下的 topic itemset
+        ↓
+高权重元素 / 高权重分类
+        ↓
+作为召回和排序线索
+        ↓
+去 PG topic itemset 里找能支撑它的频繁项集
+        ↓
+get_itemset_detail 拿真实 items / posts
+        ↓
+代码 DB 强校验
+        ↓
+写 demand_content.ext_data.evidence_pack
+```
+
+这里的高权重元素和高权重分类只负责告诉 Agent “哪些方向更值得搜”。最终能入库的需求仍必须绑定到 PG topic itemset。
+
+Hive 缺口和 PG Pattern 的职责边界:
+
+| 模块 | V2 职责 |
+| --- | --- |
+| Hive 供需缺口表 | 判断今天哪些 `merge二级品类` 要生成需求,以及每个品类大约生成多少条 |
+| PG Pattern V2 最新成功 execution | 提供全局 Pattern 事实快照 |
+| `post.merge_leve2/platform` 过滤 | 把全局 Pattern 收窄到当前缺口品类和票圈平台,并按过滤后的支撑帖重算候选强度 |
+| DemandAgent | 在该品类的候选 Pattern 中筛选、组织需求表达、写出 evidence |
+
+供需缺口来源要明确区分:
+
+- 读取入口表:`loghubods.video_dimension_detail_add_column`,用于计算 `merge二级品类` 的缺量。
+- 输出同步表:`loghubods.dwd_multi_demand_pool_di`,是旧链路向 Hive 输出需求池镜像的表,不是 V2 选择缺口品类的来源。
+- V2 当前只读 Hive 缺口入口,不写 Hive 输出表。
+
+### 3.2 LLM 和 DB 的职责边界
+
+| 模块 | V2 职责 |
+| --- | --- |
+| LLM | 判断 itemset 组合是否像真实需求;生成 `element_names/reason/desc/type` |
+| PG DB | 决定 itemset、items、posts、分类树、元素、点位等事实 |
+| 代码 | 强校验、派生、过滤、排序、写入 `evidence_pack` |
+
+V2 不再让 LLM 生成以下字段:
+
+- `seed_terms`
+- `query_seed_points`
+- `source_certainty`
+- `validation_status`
+
+`source_post_id` 在 V2 中仍可由 LLM 候选提供,但代码必须验证它属于 `matched_post_ids`;如果它无法闭合元素证据,代码可以自动切换到能闭合的支撑帖。后续 V2.1 可以进一步把 `source_post_id` 也改成完全代码选择。
+
+## 4. 新增和调整字段
+
+### 4.1 `seed_terms`
+
+调整位置:
+
+```text
+demand_content.ext_data.evidence_pack.seed_terms
+```
+
+V2 语义:保留现有 `seed_terms` 字段,但来源改为代码从 PG `itemset_items` 直接派生,不再读取 LLM 的 `evidence_refs.seed_terms`。
+
+派生来源:
+
+```text
+pattern_itemset_item
+join pattern_mining_category
+```
+
+派生优先级:
+
+1. `element_name`
+2. `category_name`
+3. `category_path` 或 `category_full_path` 的最后一级叶子词
+
+规则:
+
+- 不读取 `evidence_refs.seed_terms`。
+- 去重但保留 itemset item 顺序。
+- 默认最多保留 5-10 个。
+- 可过滤过泛词,例如 `分享`、`内容`、`视频`、`表达` 等。
+- 如果过滤后为空,回退到 DB itemset 词表前若干个有效词;仍为空则 reject。
+
+### 4.2 `query_seed_points`
+
+新增位置:
+
+```text
+demand_content.ext_data.evidence_pack.query_seed_points
+```
+
+业务含义:该需求最值得给 CFA 当首轮搜索词的票圈原始点位 Top-K。
+
+取数范围:
+
+```text
+当前 demand 的 matched_post_ids
+```
+
+语义约束:
+
+```text
+只取能命中当前 itemset_items.category_id 的点位。
+```
+
+也就是说,`query_seed_points` 不是“支撑帖里的所有热门点”,而是“支撑帖里与当前 Pattern item 分类节点能闭合的热门点”。这样可以避免把同一批帖子里和本需求无关的点位混入 CFA 搜索词池。
+
+只保留:
+
+- `post.platform = 'piaoquan'` 或等价票圈标识
+- `pattern_mining_element.source_table = 'post_decode_topic_point_element'`
+- `point_type in ('灵感点', '目的点')`
+- `point_text` 非空
+- `pattern_mining_element.category_id` 命中当前 `pattern_itemset_item.category_id`
+
+不保留:
+
+- `关键点`
+- 非票圈平台点位
+- 空 `point_text`
+
+排序:
+
+```text
+coverage_post_count desc, point_text asc
+```
+
+默认取:
+
+```text
+top_k = 30
+```
+
+单条结构:
+
+```json
+{
+  "rank": 1,
+  "point_text": "分享励志歌曲",
+  "point_type": "目的点",
+  "coverage_post_count": 12,
+  "post_id": "45493439",
+  "id": 3689590,
+  "topic_point_id": 6444,
+  "source_element_id": 11788,
+  "category_id": 76533,
+  "category_path": "/表象/声音/音乐/歌曲/励志情感歌曲",
+  "element_type": "实质",
+  "name": "励志歌曲"
+}
+```
+
+`point_text` 必须保留原始文本,不做动作词清洗。CFA 后续自行清洗。
+
+## 5. 真实 DB 验证样例
+
+已用真实 PG 只读验证样例:
+
+```text
+execution_id = 581
+itemset_id = 1608183
+```
+
+涉及表字段:
+
+| 表 | V2 用到的字段 |
+| --- | --- |
+| `pattern_itemset_post` | `execution_id`, `itemset_id`, `post_id` |
+| `post` | `post_id`, `platform` |
+| `pattern_mining_element` | `id`, `execution_id`, `post_id`, `source_table`, `source_element_id`, `point_type`, `point_text`, `element_type`, `name`, `category_id`, `category_path`, `topic_point_id` |
+| `pattern_itemset_item` | `itemset_id`, `point_type`, `category_id`, `category_path`, `element_name` |
+
+样例 Top 点位:
+
+| rank | point_text | point_type | coverage_post_count | matched_element_names |
+| ---: | --- | --- | ---: | --- |
+| 1 | 分享励志歌曲 | 目的点 | 12 | 励志歌曲 |
+| 2 | 分享正能量歌曲 | 目的点 | 4 | 正能量歌曲 |
+| 3 | 好好爱自己 | 灵感点 | 4 | 好好爱自己 |
+| 4 | 分享建党节祝福歌曲 | 目的点 | 3 | 建党节祝福歌曲 |
+| 5 | 分享祝福歌曲 | 目的点 | 3 | 祝福歌曲 |
+
+这证明 `query_seed_points` 可以由 DemandAgent 在生成 `evidence_pack` 时顺手补齐,不需要 CFA 运行时跨境直连 PG 反查。
+
+## 6. V2 验收标准
+
+### 6.1 单条 demand 验收
+
+每条 accepted `demand_content` 必须满足:
+
+- `evidence_pack.pattern_source_system = "pg_pattern_v2"`
+- `evidence_pack.validation_status = "passed"`
+- `evidence_pack.source_certainty = "db_validated"`
+- `evidence_pack.itemset_ids` 非空
+- `evidence_pack.itemset_items` 非空
+- `evidence_pack.seed_terms` 非空
+- `seed_terms` 必须由 DB itemset 词表派生,不允许来自 `evidence_refs.seed_terms`
+- `evidence_pack.query_seed_points` 可以为空数组,但存在字段
+- `query_seed_points[*].point_type` 只能是 `灵感点` 或 `目的点`
+- `query_seed_points[*].coverage_post_count` 必须是正整数
+- 不出现 `关键点`
+- 不出现旧 MySQL `topic_pattern_*` 来源
+
+### 6.2 样例验收
+
+对 `execution_id=581,itemset_id=1608183`:
+
+- `query_seed_points[0].point_text` 应为 `分享励志歌曲`
+- `query_seed_points[0].point_type` 应为 `目的点`
+- `query_seed_points[0].coverage_post_count` 应约为 `12`
+- 列表内不出现 `关键点`
+
+### 6.3 下游兼容
+
+V2 是纯增量:
+
+- 不删除 `seed_terms`
+- 不删除 `element_bindings`
+- 不删除 `category_bindings`
+- 不删除 `matched_post_ids`
+- 不改变 `demand_content` 顶层字段
+- 不改变 MySQL `demand_content` 表结构
+
+CFA 可以继续读取旧字段,同时优先使用:
+
+```text
+evidence_pack.query_seed_points
+```
+
+作为首轮搜索词池。
+
+### 6.4 供需缺口入口验收
+
+如果通过 Hive 供需缺口批量生成,必须满足:
+
+- Hive 只用于读取 `merge二级品类 + count`,不作为 Pattern evidence 来源。
+- PG `pattern_mining_execution.id` 必须选择最新 `status='success'` 的 execution。
+- 候选 `itemset` 必须通过 `pattern_itemset_post -> post` 按 `merge_leve2/platform` 过滤。
+- `min_support` 和排序必须优先使用过滤后的 `filtered_absolute_support/scoped_post_count`,不能只看全局 `absolute_support`。
+- 入库 `demand_content.merge_leve2` 必须等于该条 Hive 缺口品类。
+- `evidence_pack.demand_scope` 或等价字段必须记录 `merge_leve2/platform/scope_source`。
+- `evidence_pack.demand_scope.scope_source` 固定为 `odps_gap`,并记录 `gap_dt/requested_count/lack_count`。
+- `evidence_pack.filtered_absolute_support` 或 `scoped_post_count` 必填,且必须小于等于全局 `absolute_support`。
+- `matched_post_ids/video_ids/case_ids/query_seed_points` 应优先使用当前品类 scope 下的支撑帖,避免把其他品类帖子混入下游搜索。
+- 不调用 `piaoquan_prepare()`,不调用 `run_mining()`,不新建 MySQL `topic_pattern_execution`。
+
+### 6.5 真实校验门槛
+
+V2 开发完成后,必须同时通过四类校验:
+
+| 校验类型 | 必须证明什么 |
+| --- | --- |
+| 代码校验 | 真实代码链路没有走 `run_mining()`;`get_frequent_itemsets(..., merge_leve2=...)` 在 PG 查询中真实生效,并重算 filtered support |
+| DB 校验 | 真实 PG 只读 SELECT 证明 `post.merge_leve2/platform`、`pattern_itemset_post`、`pattern_itemset` 能闭合过滤 |
+| JSON 校验 | 新输出的 `demand_content.ext_data.evidence_pack` 含 `query_seed_points`、DB 派生 `seed_terms`、PG evidence 和 demand scope |
+| Sub-agent 交叉验证 | 至少三路只读 sub-agent 分别验证代码链路、DB 字段、输出契约,主 agent 合并后再验收 |
+
+## 7. V2 不处理范围
+
+V2 不处理:
+
+- 不改 `agent/` 通用框架。
+- 不改 PG Pattern V2 表结构。
+- 不恢复旧 MySQL Pattern evidence。
+- 不恢复旧版 `run_mining()` 现场挖掘。
+- 不新建或写入 MySQL `topic_pattern_execution/topic_pattern_itemset`。
+- 不输出 `topic_element`、script、paragraph、group scope。
+- 不让 CFA 运行时依赖 PG 反查点位。
+- 不把 rejected DemandItem 下发给下游。
+- 不改 `dwd_multi_demand_pool_di` 和 `feature_point_data` 字段结构。
+
+## 8. 待补充事项
+
+当前仓库和 `/Users/samlee/Documents/works` 三层内未找到独立的 `待决策事项.md` 文件。V2 先按当前已拍板内容推进:
+
+- `seed_terms` 从 DB 派生。
+- 新增 `query_seed_points`。
+- 云端 MySQL 单表入口恢复高权重元素 / 分类作为候选召回线索。
+- 恢复 Hive 供需缺口驱动入口,但只读 Hive,不写 Hive。
+- 用最新 PG success execution 加 `merge_leve2/platform` 过滤替代老版 `run_mining()`。
+
+如果后续提供原始 `待决策事项.md`,需要再逐条对照并补充到 V2 验收清单。

+ 982 - 0
技术迭代V2.md

@@ -0,0 +1,982 @@
+# DemandAgent 技术迭代 V2:DB 派生 Seed Terms 与 Query Seed Points
+
+## 1. 目标
+
+本轮只改 `examples/demand` 业务域,不改 `agent/` 通用框架。
+
+技术目标:
+
+1. `seed_terms` 不再读取 LLM 的 `evidence_refs.seed_terms`,改为从 PG `itemset_items` 确定性派生。
+2. 新增 `query_seed_points`,从当前 demand 的全部票圈支撑帖中筛出能命中当前 itemset 分类节点的点位,再按 `point_text` 覆盖度聚合 Top-K。
+3. 云端 MySQL 单表入口恢复高权重元素 / 分类工具,作为候选召回和排序线索。
+4. 恢复 Hive 供需缺口业务入口:只读 Hive 得到 `merge二级品类 + count`,自动接 PG 最新 success execution。
+5. `get_frequent_itemsets` / PG itemset 查询必须支持按 `merge_leve2/platform` 过滤 `pattern_itemset_post -> post` 的支撑帖,并用过滤后的 support 做候选排序和门槛判断。
+6. 保持 `demand_content` 顶层字段、MySQL 单表 schema、旧 `evidence_pack` 字段全部兼容。
+
+## 2. 当前代码证据
+
+### 2.1 当前 `seed_terms` 会受 LLM 影响
+
+文件:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/evidence_pack_builder.py
+```
+
+现状:
+
+- `build_evidence_pack()` 在构造 `evidence_pack` 前调用 `_resolve_seed_terms(...)`。
+- `_resolve_seed_terms()` 会先读取 `evidence_refs.get("seed_terms")`。
+- 如果 LLM 提供的词能被 DB evidence 覆盖,就直接返回这组词。
+- 只有没有可用 LLM seed 时,才从 `itemset_items` 派生。
+
+需要调整为:
+
+- 完全忽略 `evidence_refs.seed_terms`。
+- 所有 `seed_terms` 都从 PG `itemset_items` 派生。
+
+### 2.2 当前 itemset items 已经能支持 DB 派生
+
+文件:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/pg_pattern_repository.py
+```
+
+函数:
+
+```python
+query_itemset_items_with_categories(execution_id, itemset_ids)
+```
+
+已返回字段:
+
+- `itemset_item_id`
+- `itemset_id`
+- `point_type`
+- `dimension`
+- `category_id`
+- `category_path`
+- `element_name`
+- `category_name`
+- `category_full_path`
+- `category_level`
+- `category_nature`
+
+这些字段足够派生:
+
+```text
+seed_terms
+```
+
+### 2.3 当前 `element_bindings.sample_elements` 只是证据样例,不足以承担搜索点位池职责
+
+文件:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/evidence_pack_builder.py
+```
+
+现状:
+
+```python
+query_element_bindings_for_items(
+    execution_id=execution_id,
+    itemset_items=itemset_items,
+    post_ids=[requested_source_post_id],
+)
+```
+
+它只围绕一个 `source_post_id` 查证据,主要用于证明 itemset item 能闭合到 PG topic element。它不是从全部 `matched_post_ids` 里聚合点位,也不是 DemandAgent 原本设计的搜索词池。
+
+CFA 当前临时把 `element_bindings.sample_elements` 当搜索种子,是因为 V1 还没有专门的 `query_seed_points` 字段;这不是它原本的设计职责。因此 V2 不是“把旧搜索词字段改成非搜索词”,而是补一个专门给 CFA 首轮搜索使用的新字段。
+
+V2 需要新增独立查询:
+
+```python
+query_seed_points_for_itemsets(...)
+```
+
+不要复用 `element_bindings.sample_elements` 当 Top-K 搜索点位。
+
+### 2.4 当前 MySQL 单表入口工具集太窄
+
+文件:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run.py
+```
+
+函数:
+
+```python
+_enabled_tools_for_run()
+```
+
+当前 MySQL 单表模式只允许:
+
+- `get_frequent_itemsets`
+- `get_itemset_detail`
+- `create_demand_item`
+- `create_demand_items`
+
+V2 需要允许:
+
+- `get_weight_score_topn`
+- `get_weight_score_by_name`
+
+并让 MySQL 单表模式也能生成 PG 权重 JSON。
+
+### 2.5 当前 Hive 缺口入口和 PG 品类过滤还没有接通
+
+Hive 供需缺口读取函数仍在:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/data_query_tools.py
+```
+
+函数:
+
+```python
+get_demand_merge_level2_names()
+```
+
+它从 Hive/ODPS `loghubods.video_dimension_detail_add_column` 按 `merge二级品类` 聚合,筛选 `缺量 >= 50`,并返回:
+
+```json
+{
+  "cluster_name": "知识科普",
+  "platform_type": "piaoquan",
+  "count": 40
+}
+```
+
+注意:`loghubods.video_dimension_detail_add_column` 是缺口计算来源表;`loghubods.dwd_multi_demand_pool_di` 是旧链路输出/同步表,字段为 `strategy/demand_id/demand_name/weight/type/video_count/video_list/extend/dt`,不是 V2 缺口入口来源。
+
+但新版当前的 PG execution 选择逻辑仍是全局 fallback:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run.py
+```
+
+```python
+get_execution_id_by_merge_level2(cluster_name) -> query_latest_success_execution_id()
+```
+
+也就是说,`cluster_name/merge_leve2` 目前没有真正进入 PG itemset 候选过滤。
+
+工具层其实已经暴露了 `merge_leve2` 参数:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/demand_pattern_tools.py
+```
+
+```python
+get_frequent_itemsets(..., account_name=None, merge_leve2=None)
+```
+
+但 PG service 当前直接丢弃:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/pattern_builds/pg_pattern_service.py
+```
+
+```python
+del account_name, merge_leve2
+```
+
+V2 必须把这条参数真实接到 PG SQL:
+
+```text
+pattern_itemset -> pattern_itemset_post -> post.merge_leve2/platform
+```
+
+并用过滤后的支撑帖数量重算 `filtered_absolute_support/scoped_post_count`。这样才能恢复老版“从供需缺口品类出发”的业务入口,同时不恢复 `run_mining()`。
+
+## 3. 代码改动计划
+
+### 3.1 修改 prompt,收紧 LLM 职责
+
+涉及文件:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/demand.md`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/demand_mysql.md`
+
+修改点:
+
+1. 删除示例 JSON 中的 `evidence_refs.seed_terms`。
+2. 删除“LLM 需要填写 seed_terms”的描述。
+3. 新增说明:
+
+```text
+seed_terms、query_seed_points 均由代码从 PG DB 派生,LLM 不要填写。
+```
+
+4. MySQL 单表 prompt 不再写“不要查询权重文件”。
+5. MySQL 单表 prompt 的执行顺序改为:
+
+```text
+先用 get_weight_score_topn 看高权重元素/分类
+再用 get_frequent_itemsets 找能支撑这些方向的 topic itemset
+再用 get_itemset_detail 拿详情
+最后 create_demand_items
+```
+
+LLM 仍可输出:
+
+- `element_names`
+- `reason`
+- `desc`
+- `type`
+- `evidence_refs.source_kind`
+- `evidence_refs.source_tool`
+- `evidence_refs.itemset_ids`
+- `evidence_refs.source_post_id`
+- `evidence_refs.case_ids`
+
+### 3.2 恢复 MySQL 单表入口高权重工具
+
+涉及文件:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run.py`
+
+修改函数:
+
+```python
+_enabled_tools_for_run()
+```
+
+当前:
+
+```python
+allowed = {
+    "get_frequent_itemsets",
+    "get_itemset_detail",
+    "create_demand_item",
+    "create_demand_items",
+}
+```
+
+V2:
+
+```python
+allowed = {
+    "get_weight_score_topn",
+    "get_weight_score_by_name",
+    "get_frequent_itemsets",
+    "get_itemset_detail",
+    "create_demand_item",
+    "create_demand_items",
+}
+```
+
+同时修改 `_ensure_pg_weight_score_files()` 的调用条件。
+
+当前只在 `local_json` 模式调用:
+
+```python
+if _is_local_json_mode():
+    _ensure_pg_weight_score_files(int(execution_id))
+```
+
+V2 改为:
+
+```python
+if _is_local_json_mode() or _is_mysql_demand_content_mode():
+    _ensure_pg_weight_score_files(int(execution_id))
+```
+
+并调整 `_ensure_pg_weight_score_files()` 函数注释,不再说只服务 local JSON。
+
+### 3.3 恢复 Hive 供需缺口驱动入口,但不恢复 run_mining
+
+新增入口文件:
+
+```text
+/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run_hive_gap_mysql.py
+```
+
+职责:
+
+1. 调用 `get_demand_merge_level2_names()` 只读 Hive/ODPS,拿到 `cluster_name/platform_type/count`。
+2. 调用 `query_latest_success_execution_id()` 选择最新 `status='success'` 且 topic itemset 非空的 PG Pattern V2 execution;不能只看 `is_current`。
+3. 对每个缺口品类调用现有 `run.main(...)`:
+
+```python
+await main(
+    cluster_name=item["cluster_name"],
+    platform_type=item["platform_type"],
+    count=item["count"],
+    execution_id=latest_success_execution_id,
+    task_id=None,
+)
+```
+
+4. 强制 `DEMAND_OUTPUT_MODE=mysql_demand_content`,只写 MySQL `demand_content`。
+5. 禁止调用 `piaoquan_prepare()`、`prepare()`、`run_mining()`。
+
+建议参数:
+
+```bash
+python -m examples.demand.run_hive_gap_mysql \
+  --run-label hive_gap_v2_YYYYMMDD \
+  --max-categories 10 \
+  --max-total-demands 100
+```
+
+这里的 `execution_id` 不是 DemandAgent 执行 ID,而是 PG `pattern_mining_execution.id`。Hive 只决定品类和数量,PG latest success execution 决定 Pattern 事实快照。
+
+### 3.4 接通 PG itemset 的 merge_leve2/platform 过滤
+
+涉及文件:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/pg_pattern_repository.py`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/pattern_builds/pg_pattern_service.py`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/demand_pattern_tools.py`
+
+#### 3.4.1 repository 改造
+
+修改:
+
+```python
+def query_topic_itemsets(..., merge_leve2=None, platform=None, ...)
+```
+
+当传入 `merge_leve2/platform` 时,追加过滤并重算支撑帖:
+
+```sql
+LEFT JOIN LATERAL (
+  SELECT
+    array_agg(DISTINCT pip_scope.post_id ORDER BY pip_scope.post_id) AS filtered_matched_post_ids,
+    COUNT(DISTINCT pip_scope.post_id) AS filtered_absolute_support
+  FROM pattern_itemset_post pip_scope
+  JOIN post p_scope
+    ON p_scope.post_id = pip_scope.post_id
+  WHERE pip_scope.execution_id = i.execution_id
+    AND pip_scope.itemset_id = i.id
+    AND p_scope.merge_leve2 = ANY(%s)
+    AND p_scope.platform = ANY(%s)
+) scoped ON TRUE
+```
+
+过滤条件:
+
+```sql
+COALESCE(scoped.filtered_absolute_support, 0) >= min_support
+```
+
+注意:
+
+- `merge_leve2` 支持字符串或列表;列表为 OR。
+- `platform_type=piaoquan` 兼容 `piaoquan/票圈`。
+- 返回字段中增加 `scope_filter`、`filtered_matched_post_ids`、`filtered_absolute_support`。
+- 排序优先使用 `filtered_absolute_support DESC, absolute_support DESC`,避免全局高支持度但当前品类支撑很弱的 itemset 抢占候选。
+- 保留全局 `absolute_support/support`,但下游 evidence 默认使用过滤后的支撑帖。
+
+#### 3.4.2 service 改造
+
+当前:
+
+```python
+del account_name, merge_leve2
+```
+
+V2 改为:
+
+```python
+rows = repo.query_topic_itemsets(
+    execution_id=execution_id,
+    category_ids=category_ids,
+    dimension_mode=dimension_mode,
+    min_support=min_support,
+    min_item_count=min_item_count,
+    max_item_count=max_item_count,
+    sort_by=sort_by,
+    merge_leve2=merge_leve2,
+    platform=platform,
+    limit=max(int(top_n or 20) * 10, int(top_n or 20)),
+)
+```
+
+`demand_pattern_tools.get_frequent_itemsets()` 已经有 `merge_leve2` 参数;V2 需要补 `platform` 参数,prompt 要求 LLM 在当前品类任务中调用:
+
+```python
+get_frequent_itemsets(..., merge_leve2="%merge_level2%", platform="%platform_type%")
+```
+
+#### 3.4.3 detail 和 evidence scope 改造
+
+`get_itemset_detail()` 要返回同一 scope 下的过滤后帖子,或至少返回:
+
+```json
+{
+  "scope_filter": {
+    "merge_leve2": "知识科普",
+    "platform_type": "piaoquan"
+  },
+  "filtered_absolute_support": 59,
+  "filtered_matched_post_ids": ["..."]
+}
+```
+
+`evidence_pack` 增加业务 scope:
+
+```json
+{
+  "demand_scope": {
+    "source": "odps_gap",
+    "merge_leve2": "知识科普",
+    "platform_type": "piaoquan",
+    "gap_dt": "20260622",
+    "requested_count": 70,
+    "lack_count": 1443.21,
+    "pattern_execution_id": 581
+  }
+}
+```
+
+并要求:
+
+- `matched_post_ids/video_ids/case_ids` 优先使用当前 `merge_leve2/platform` scope 下的支撑帖。
+- `source_post_id` 必须来自 scoped `matched_post_ids`。
+- `query_seed_points` 必须基于 scoped `matched_post_ids` 聚合。
+- `filtered_absolute_support/scoped_post_count` 必填,并且必须小于等于全局 `absolute_support`。
+- 如果 itemset 全局存在但 scoped posts 为空,当前 DemandItem rejected。
+
+### 3.5 重写 seed_terms 派生函数
+
+涉及文件:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/evidence_pack_builder.py`
+
+新增常量:
+
+```python
+SEED_TERM_MAX_COUNT = int(os.getenv("DEMAND_SEED_TERM_MAX_COUNT", "10"))
+SEED_TERM_STOPWORDS = {...}
+```
+
+建议先写死基础 stopwords,再允许环境变量追加:
+
+```text
+分享,内容,视频,表达,展示,讲述,记录,画面
+```
+
+新增函数:
+
+```python
+def _build_seed_terms_from_itemset_items(itemset_items: list[dict[str, Any]]) -> list[str]:
+    ...
+```
+
+派生规则:
+
+```text
+for item in itemset_items:
+    candidate = element_name
+             or category_name
+             or last(category_path)
+             or last(category_full_path)
+```
+
+要求:
+
+- 去重。
+- 保持 itemset item 顺序。
+- `seed_terms` 只从 DB itemset 字段过滤和裁剪。
+- 不新增完整词表字段。
+
+替换当前逻辑:
+
+```python
+seed_terms = _resolve_seed_terms(evidence_refs, itemset_items, element_bindings)
+```
+
+改为:
+
+```python
+seed_terms = _build_seed_terms_from_itemset_items(itemset_items)
+```
+
+只保留现有 `seed_terms` 字段,不新增完整词表字段:
+
+```python
+"seed_terms": seed_terms,
+```
+
+保留 `_validate_seed_terms()`,但语义改成校验:
+
+- `seed_terms` 非空。
+- `seed_terms` 必须能从 `itemset_items` 的 `element_name/category_name/category_path/category_full_path` 派生。
+
+### 3.6 新增 query_seed_points PG 查询
+
+涉及文件:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/pg_pattern_repository.py`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/db_manager.py`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/evidence_pack_builder.py`
+
+#### 3.6.1 repository 新函数
+
+新增函数:
+
+```python
+def query_seed_points_for_itemsets(
+    execution_id: int,
+    itemset_ids: Iterable[Any],
+    matched_post_ids: Iterable[Any],
+    *,
+    platform: str = "piaoquan",
+    top_k: int = 30,
+) -> list[dict[str, Any]]:
+    ...
+```
+
+SQL 逻辑:
+
+```sql
+WITH item_cats AS (
+  SELECT DISTINCT category_id
+  FROM pattern_itemset_item
+  WHERE itemset_id = ANY(%(itemset_ids)s)
+    AND category_id IS NOT NULL
+),
+demand_posts AS (
+  SELECT DISTINCT ip.post_id
+  FROM pattern_itemset_post ip
+  JOIN post p ON p.post_id = ip.post_id
+  WHERE ip.execution_id = %(execution_id)s
+    AND ip.itemset_id = ANY(%(itemset_ids)s)
+    AND ip.post_id = ANY(%(matched_post_ids)s)
+    AND p.platform IN ('piaoquan', '票圈')
+),
+ranked AS (
+  SELECT
+    e.point_text,
+    e.point_type,
+    COUNT(DISTINCT e.post_id) AS coverage_post_count,
+    MIN(e.post_id) AS post_id,
+    MIN(e.id) AS id,
+    MIN(e.topic_point_id) AS topic_point_id,
+    MIN(e.source_element_id) AS source_element_id,
+    MIN(e.category_id) AS category_id,
+    MIN(e.category_path) AS category_path,
+    MIN(e.element_type) AS element_type,
+    MIN(e.name) AS name,
+    array_agg(DISTINCT e.name ORDER BY e.name) AS matched_element_names,
+    array_agg(DISTINCT e.category_id ORDER BY e.category_id) AS matched_category_ids
+  FROM pattern_mining_element e
+  JOIN demand_posts dp ON dp.post_id = e.post_id
+  JOIN item_cats ic ON ic.category_id = e.category_id
+  WHERE e.execution_id = %(execution_id)s
+    AND e.source_table = 'post_decode_topic_point_element'
+    AND e.point_type IN ('灵感点', '目的点')
+    AND e.point_text IS NOT NULL
+    AND e.point_text <> ''
+  GROUP BY e.point_text, e.point_type
+)
+SELECT *
+FROM ranked
+ORDER BY coverage_post_count DESC, point_text
+LIMIT %(top_k)s
+```
+
+Python 侧补 `rank`:
+
+```python
+for idx, row in enumerate(rows, start=1):
+    row["rank"] = idx
+```
+
+#### 3.6.2 db_manager re-export
+
+在 `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/db_manager.py` 中导入并加入 `__all__`:
+
+```python
+query_seed_points_for_itemsets
+```
+
+#### 3.6.3 evidence_pack 写入
+
+在 `build_evidence_pack()` 中,在 `matched_post_ids` 和 `itemset_items` 都已校验后调用:
+
+```python
+query_seed_points = query_seed_points_for_itemsets(
+    execution_id=int(execution_id),
+    itemset_ids=itemset_ids,
+    matched_post_ids=matched_post_ids,
+    top_k=int(os.getenv("DEMAND_QUERY_SEED_POINTS_TOP_K", "30")),
+)
+```
+
+写入:
+
+```python
+"query_seed_points": query_seed_points,
+```
+
+边界:
+
+- 如果查询失败,当前建议 reject,因为它是下游搜索必需字段。
+- 如果查询成功但没有点位,写空数组并通过,前提是 evidence 本身完整;因为小需求可能没有足够点位。
+- 查询必须用 `pattern_itemset_item.category_id` 精确绑定当前 itemset item;不要默认取支撑帖里的全部点位。
+- 后续如要支持父类节点扩展到子类,必须显式改成 `category_path LIKE item.category_path || '/%'` 并单独验收,V2 不默认混入。
+
+建议实现为:
+
+```text
+DB 查询异常 -> reject
+查询结果为空 -> accepted,但 query_seed_points=[]
+```
+
+### 3.7 MySQL sink 校验增强
+
+涉及文件:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/mysql_demand_content_sink.py`
+
+当前 `_validate_row()` 必填字段中没有 `query_seed_points`。
+
+V2 调整:
+
+- `query_seed_points` 字段必须存在,可以是空数组。
+- `seed_terms` 必填非空。
+- 校验 `seed_terms` 非空即可;其 DB 派生真实性由 `evidence_pack_builder` 保证。
+- 校验 `query_seed_points[*].point_type in {'灵感点', '目的点'}`。
+- 校验 `coverage_post_count` 是正整数。
+
+### 3.8 测试更新
+
+涉及文件:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/tests/test_pg_evidence_builder.py`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/tests/test_pg_pattern_repository.py`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/tests/test_mysql_demand_content_sink.py`
+
+新增测试:
+
+1. `evidence_refs.seed_terms` 被忽略。
+2. `seed_terms` 从 itemset items 派生。
+3. stopwords 会过滤 `seed_terms`,但不能导致空值;空值时回退。
+4. `query_seed_points` 过滤关键点。
+5. `query_seed_points` 按 `coverage_post_count` 降序。
+6. `query_seed_points` 可以为空数组但字段必须存在。
+7. MySQL sink 缺 `query_seed_points` 字段时拒绝。
+8. Hive gap 入口禁止调用 `piaoquan_prepare/prepare/run_mining`。
+9. 最新 success execution 选择测试:failed/current 不能被选中,必须选最新 `status='success'` 且 topic itemset 非空。
+10. `get_frequent_itemsets(merge_leve2=..., platform=...)` 会过滤支撑帖并重算 filtered support。
+11. 未传 `merge_leve2/platform` 时保留全局查询语义。
+12. `get_itemset_detail()` 与 `evidence_pack.matched_post_ids` 使用同一套过滤范围。
+13. MySQL sink 校验 `evidence_pack.demand_scope.merge_leve2/platform`,或至少校验输出行 `merge_leve2` 与过滤参数一致。
+
+## 4. 真实 DB 验证计划
+
+使用真实 PG 只读样例:
+
+```text
+execution_id = 581
+itemset_id = 1608183
+```
+
+验证 SQL:
+
+```sql
+WITH item_cats AS (
+  SELECT DISTINCT category_id
+  FROM pattern_itemset_item
+  WHERE itemset_id = 1608183
+    AND category_id IS NOT NULL
+),
+matched_posts AS (
+  SELECT DISTINCT ip.post_id
+  FROM pattern_itemset_post ip
+  JOIN post p ON p.post_id = ip.post_id
+  WHERE ip.execution_id = 581
+    AND ip.itemset_id = 1608183
+    AND p.platform IN ('piaoquan', '票圈')
+),
+matched_points AS (
+  SELECT e.point_text, e.point_type, e.post_id, e.category_id, e.name
+  FROM pattern_mining_element e
+  JOIN matched_posts mp ON mp.post_id = e.post_id
+  JOIN item_cats ic ON ic.category_id = e.category_id
+  WHERE e.execution_id = 581
+    AND e.source_table = 'post_decode_topic_point_element'
+    AND e.point_type IN ('灵感点', '目的点')
+    AND COALESCE(e.point_text, '') <> ''
+)
+SELECT
+  point_text,
+  MIN(point_type) AS point_type,
+  COUNT(DISTINCT post_id) AS coverage_post_count,
+  array_agg(DISTINCT name ORDER BY name) AS matched_element_names,
+  array_agg(DISTINCT category_id ORDER BY category_id) AS matched_category_ids
+FROM matched_points
+GROUP BY point_text
+ORDER BY COUNT(DISTINCT post_id) DESC, point_text
+LIMIT 5;
+```
+
+当前真实返回头部:
+
+| point_text | point_type | coverage_post_count |
+| --- | --- | ---: |
+| 分享励志歌曲 | 目的点 | 12 |
+| 分享正能量歌曲 | 目的点 | 4 |
+| 好好爱自己 | 灵感点 | 4 |
+| 分享建党节祝福歌曲 | 目的点 | 3 |
+| 分享祝福歌曲 | 目的点 | 3 |
+
+### 4.1 已完成的真实只读校验记录
+
+当前文档更新时已用 `.venv` 和项目 `.env` 执行 PG 只读 SELECT,确认:
+
+```text
+latest_success_execution_id = 581
+```
+
+`post` 表字段存在:
+
+```text
+post_id, merge_leve2, platform
+```
+
+`pattern_itemset_post` 表字段存在:
+
+```text
+execution_id, itemset_id, post_id
+```
+
+`execution_id=581` 下,按 `pattern_itemset_post -> post.merge_leve2/platform` 能过滤出品类 itemset:
+
+| merge_leve2 | platform | itemset_count | post_count |
+| --- | --- | ---: | ---: |
+| 知识科普 | piaoquan | 16088 | 854 |
+| 历史名人 | piaoquan | 12571 | 738 |
+| 当代正能量人物 | piaoquan | 10675 | 582 |
+| 人生忠告 | piaoquan | 9308 | 554 |
+| 社会风气 | piaoquan | 5622 | 149 |
+
+样例 scoped itemset:
+
+| merge_leve2 | itemset_id | absolute_support | scoped_post_count |
+| --- | ---: | ---: | ---: |
+| 知识科普 | 1622463 | 175 | 59 |
+| 历史名人 | 1607313 | 579 | 111 |
+| 社会风气 | 1623199 | 261 | 36 |
+
+sub-agent 只读复核补充:
+
+- Hive 最新分区:`20260622`。
+- Hive 缺口样例:`知识科普` 缺量约 `1443.21`,`罕见画面` 约 `852.13`,`人生感悟音乐` 约 `564.34`,`民生政策` 约 `234.80`。
+- PG latest success execution:`id=581`,`snapshot_date=2026-05-09`,`post_count=13265`,`topic_itemset_count=35406`。
+- execution `581` 计数:`topic_itemsets=35406`,`topic_itemset_items=132904`,`itemset_posts=569849`。
+- `罕见画面/piaoquan` 可过滤命中 `4458` 个 itemset、`545` 个 post。
+- `itemset_id=1607313` 全局 `absolute_support=579`;过滤到 `当代正能量人物/piaoquan` 后 `filtered_absolute_support=115`,过滤到 `历史名人/piaoquan` 后 `111`。
+
+这证明 V2 不需要恢复 `run_mining()`;用最新 PG success execution 加品类过滤即可从真实 Pattern 池里拿到候选。
+
+### 4.2 必须补充的 DB 校验 SQL
+
+实现后必须加入只读 SQL smoke:
+
+```sql
+SELECT id, status, snapshot_date, topic_itemset_count
+FROM pattern_mining_execution
+WHERE status = 'success'
+  AND COALESCE(topic_itemset_count, 0) > 0
+ORDER BY snapshot_date DESC NULLS LAST, id DESC
+LIMIT 1;
+```
+
+```sql
+SELECT
+  i.id AS itemset_id,
+  i.absolute_support AS global_absolute_support,
+  COUNT(DISTINCT pip.post_id) AS filtered_absolute_support
+FROM pattern_itemset i
+JOIN pattern_itemset_post pip
+  ON pip.execution_id = i.execution_id
+ AND pip.itemset_id = i.id
+JOIN post p
+  ON p.post_id = pip.post_id
+WHERE i.execution_id = :execution_id
+  AND i.scope = 'topic'
+  AND p.merge_leve2 = :merge_leve2
+  AND p.platform IN ('piaoquan', '票圈')
+GROUP BY i.id, i.absolute_support
+HAVING COUNT(DISTINCT pip.post_id) >= :min_support
+ORDER BY COUNT(DISTINCT pip.post_id) DESC, i.absolute_support DESC
+LIMIT 20;
+```
+
+还应校验:
+
+- `pattern_itemset_post.post_id` 在 `post.post_id` 可 join。
+- `filtered_absolute_support <= pattern_itemset.absolute_support`。
+- `matched_post_ids` 必须全部满足目标 `merge_leve2/platform`。
+- `pattern_itemset_item.category_id/category_path` 能回连 `pattern_mining_category`;它表示 Pattern 特征分类,不等同于 Hive 的 `merge二级品类`。
+- 老 MySQL `topic_pattern_*` 只能作为 legacy 对照;不能用它承接 V2 的 PG scoped filtering。
+
+## 5. E2E 验证计划
+
+### 5.1 本地单条真实 LLM + 真实 PG + MySQL 测试
+
+命令形态:
+
+```bash
+python -m examples.demand.run_existing_execution_mysql \
+  --execution-id 581 \
+  --merge-level2 "PG Pattern V2 需求测试" \
+  --platform-type piaoquan \
+  --count 1 \
+  --run-label v2_seed_points_smoke
+```
+
+验收:
+
+- exit code 为 0。
+- MySQL `demand_content` 有对应 `run_label` 行。
+- `ext_data.evidence_pack.seed_terms` 非空。
+- `seed_terms` 不受 LLM `evidence_refs.seed_terms` 影响。
+- `ext_data.evidence_pack.query_seed_points` 字段存在。
+- `query_seed_points` 非空时只包含 `灵感点/目的点`。
+- `query_seed_points` 非空时 `coverage_post_count` 降序。
+- 不写 `demand_task`、Hive、PG Pattern、旧 MySQL Pattern。
+
+### 5.2 批量 100 条测试
+
+在单条 smoke 通过后再跑:
+
+```bash
+python -m examples.demand.run_existing_execution_mysql \
+  --execution-id 581 \
+  --merge-level2 "PG Pattern V2 需求测试" \
+  --platform-type piaoquan \
+  --count 10 \
+  --run-label v2_seed_points_100_batch01
+```
+
+重复补跑直到合格入库 100 条。
+
+验收 SQL:
+
+```sql
+SELECT COUNT(*)
+FROM demand_content
+WHERE JSON_UNQUOTE(JSON_EXTRACT(ext_data, '$.run_label')) LIKE 'v2_seed_points_100%';
+```
+
+逐条校验:
+
+```text
+pattern_source_system = pg_pattern_v2
+validation_status = passed
+source_certainty = db_validated
+seed_terms non-empty
+seed_terms ignored from LLM evidence_refs
+query_seed_points field exists
+```
+
+### 5.3 Hive gap 批量入口测试
+
+在单条和 100 条手动模式通过后,执行:
+
+```bash
+python -m examples.demand.run_hive_gap_mysql \
+  --run-label hive_gap_v2_smoke \
+  --max-categories 2 \
+  --max-total-demands 5
+```
+
+验收:
+
+- 真实调用 `get_demand_merge_level2_names()` 读取 Hive/ODPS。
+- 自动选择最新 PG `status='success'` 且 topic itemset 非空的 execution。
+- 每个品类调用 `get_frequent_itemsets(..., merge_leve2=当前品类, platform=当前平台)`。
+- MySQL `demand_content.merge_leve2` 等于 Hive 返回的 `cluster_name`。
+- `evidence_pack.demand_scope.merge_leve2` 等于同一个品类。
+- `source_post_id in matched_post_ids`。
+- `matched_post_ids` 均能在 PG `post` 表查到同一 `merge_leve2/platform`。
+- 不写 `demand_task`,不写 Hive,不卡回旧 MySQL Pattern。
+
+## 6. JSON 契约校验计划
+
+当前已有 V1 样例:
+
+```text
+examples/demand/test_output_data/pg_llm_e2e_581_20260605_100815/demand_content.json
+examples/demand/test_output_data/cfa_100_mix_20260604/combined/demand_content.json
+```
+
+真实检查结果:
+
+- 两份样例都有 `ext_data.evidence_pack`。
+- 两份样例都有 `seed_terms`。
+- 两份样例都没有 `query_seed_points`,因此不能作为 V2 合格输出交付 CFA。
+
+V2 JSON 校验脚本必须逐条检查:
+
+```text
+top-level: id/name/reason/suggestion/score/merge_leve2/dt/ext_data
+ext_data: reason/desc/type/video_ids/trace_id/run_label/evidence_pack
+evidence_pack: pattern_source_system=pg_pattern_v2
+evidence_pack: validation_status=passed
+evidence_pack: source_certainty=db_validated
+evidence_pack: demand_scope.merge_leve2 == demand_content.merge_leve2
+evidence_pack: demand_scope.scope_source == odps_gap
+evidence_pack: demand_scope.gap_dt/requested_count/lack_count present when generated from Hive gap
+evidence_pack: filtered_absolute_support or scoped_post_count present
+evidence_pack: filtered_absolute_support <= absolute_support
+evidence_pack: seed_terms non-empty
+evidence_pack: query_seed_points field exists
+evidence_pack: query_seed_points[*].point_type in {灵感点,目的点}
+evidence_pack: source_post_id in matched_post_ids
+evidence_pack: matched_post_ids scoped by post.merge_leve2/platform
+```
+
+旧 V1 JSON 如果缺 `query_seed_points`,校验必须失败。
+
+## 7. Sub-agent 交叉验证计划
+
+本轮文档更新必须使用三路只读 sub-agent 交叉验证:
+
+| sub-agent | 验证范围 |
+| --- | --- |
+| 代码链路审计 | 检查 Hive gap 入口、PG itemset 过滤、高权重工具、evidence builder、MySQL entrypoint 的真实调用链路 |
+| DB/样例数据验证 | 真实 SELECT PG schema、`post.merge_leve2/platform`、`pattern_itemset_post` 和 scoped itemset 样例 |
+| 输出契约审计 | 对照现有文档、sink 校验、测试 JSON,确认 `query_seed_points/demand_scope` 是纯增量且 V1 JSON 会失败 |
+
+实现阶段继续使用相同分工:
+
+| worker | 写入范围 |
+| --- | --- |
+| Worker A | `pg_pattern_repository.py`, `db_manager.py` |
+| Worker B | `evidence_pack_builder.py`, evidence builder tests |
+| Worker C | `run.py`, `run_hive_gap_mysql.py`, prompts, MySQL sink tests |
+
+主 agent 负责合并和最终验收,不把 sub-agent 结论当唯一证明。
+
+## 8. 风险与边界
+
+| 风险 | 处理方式 |
+| --- | --- |
+| `query_seed_points` 结果为空 | 字段保留为空数组;不伪造点位 |
+| PG `post.platform` 取值不统一 | 支持 `piaoquan` 和 `票圈`,后续可配置 |
+| Hive 品类在 PG latest execution 中没有 scoped itemset | 跳过该品类并记录 rejected/skip reason,不回退到 `run_mining` |
+| `seed_terms` 过滤过严 | 如果 stopwords 过滤后为空,回退到 DB itemset 词表前 N 个 |
+| LLM 仍输出 `seed_terms` | prompt 禁止;代码忽略 |
+| MySQL 单表 JSON 过大 | V2 先接受;如超限再进入 V2.1 拆表或压缩 |
+| CFA 仍用旧字段 | V2 纯增量,不破坏旧字段;CFA 可逐步切到 `query_seed_points` |
+
+## 9. 不处理事项
+
+- 不改 `agent/` 框架。
+- 不改 PG schema。
+- 不改 MySQL `demand_content` schema。
+- 不写 Hive,只读 Hive 供需缺口表。
+- 不恢复旧 MySQL Pattern evidence。
+- 不恢复旧版 `run_mining()`,不新建 MySQL `topic_pattern_execution`。
+- 不让 LLM 生成事实字段。
+- 不让 CFA 运行时反查 PG 才能拿首轮搜索点位。