Explorar o código

feat(demand): add local evidence-pack output MVP

Implement DemandAgent local_json MVP for downstream evidence handoff.\n\n- add DB-validated evidence_pack builder for pattern_itemset lineage\n- add local JSON sink and existing-execution local entrypoint\n- route local outputs to test_output_data while blocking real DB/Hive writes\n- keep demand_content as downstream contract and mirror dwd/feature outputs without evidence_pack\n- document DemandAgent vs framework boundaries and CFA consumption contract
SamLee hai 1 mes
pai
achega
e891fe6e7c

+ 2 - 1
.gitignore

@@ -62,6 +62,7 @@ output
 .trace_test/
 .trace_test/
 .trace_test2/
 .trace_test2/
 examples/**/output*/
 examples/**/output*/
+examples/demand/test_output_data/
 
 
 frontend/htmlTemplate/mock_data
 frontend/htmlTemplate/mock_data
 frontend/react-template/yarn.lock
 frontend/react-template/yarn.lock
@@ -72,4 +73,4 @@ knowhub/knowhub.db-shm
 knowhub/knowhub.db-wal
 knowhub/knowhub.db-wal
 
 
 # Milvus data
 # Milvus data
-knowhub/milvus_data/
+knowhub/milvus_data/

+ 330 - 0
demand_output_improve_plan.md

@@ -0,0 +1,330 @@
+# DemandAgent 输出调整 MVP 技术方案
+
+## Summary
+
+这次不是“DemandAgent 对接 ContentFindingAgent”,而是 **DemandAgent 自身输出结构升级**:在不改真实 DB schema、不真实写 DB 的前提下,核心只调整普通需求池 `demand_content.ext_data`,增加结构化 `evidence_pack`。原本会写入 `dwd_multi_demand_pool_di`、`feature_point_data`、`demand_task` 的数据,MVP 阶段只做本地 JSON 镜像和回归检查,不改它们的业务字段结构。
+
+本次 DemandAgent 侧补齐的是上游 exact evidence 基础:它能让需求单携带真实 DB 校验过的 Pattern/itemset/category 证据,但最终链路还要求 ContentFindAgent 读取、继承并沉淀结构化 `source_evidence`,否则最终结果仍无法从 `post_id/aweme_id` exact 回到 Pattern 和分类树节点。
+
+改动范围判断:**不改 agent 框架层**,只改 `examples/demand` 这个业务领域实现。`agent/` 框架里的 runner、tool registry、trace store、LLM 调用链路都保持不动。本次即使涉及 `@tool` 参数、`trace_id` 透传,也只是修改 Demand 业务工具和业务输出,不修改通用 Agent 框架机制。
+
+## 模块改动归类
+
+| 模块 | 是否改 | 原因 |
+| --- | --- | --- |
+| `agent/` 框架 | 不改 | 不改 runner、tool registry、trace store、LLM 调用链路;本次只消费现有框架能力,把 Demand 业务输出补充为本地 JSON 和 evidence_pack。 |
+| `examples/demand/demand_build_agent_tools.py` | 改 | DemandItem 需要新增可选证据引用字段,保留原字段兼容旧能力。 |
+| `examples/demand/demand.md` | 改 | Prompt 要求 LLM 在创建需求时带上输出证据引用。 |
+| `examples/demand/run.py` | 改 | 当前真实写库入口集中在这里,需要加本地 JSON 输出模式。 |
+| `examples/demand/data_query_tools.py` | 改 | 不改 `dwd_multi_demand_pool_di` / `feature_point_data` 业务字段;把现有 Hive 输出拆成可复用的纯映射函数,local 模式只调用映射不写 Hive。 |
+| `examples/demand/db_manager.py` | 改 | 新增只读查询函数,补齐 `case_ids/category/element/itemset` 证据。 |
+| 新增 `examples/demand/evidence_pack_builder.py` | 新增 | 专门构造并强校验 `ext_data.evidence_pack`;校验失败的 DemandItem 进入 reject,不进入 `demand_content`。 |
+| 新增 `examples/demand/local_output_sink.py` | 新增 | 统一本地 JSON sink,稳定输出 7 个本地文件;不进入通用 Agent 框架。 |
+| 新增 `examples/demand/run_existing_execution_local.py` | 新增 | MVP 测试入口:用已有 `execution_id` 只读 DB,禁止跑写库 prepare。 |
+
+## 输出表改动收敛
+
+虽然 DemandAgent 当前有三类业务结果输出,但本次面向下游证据链的结构性改动只落在 `demand_content`。
+
+| 输出 | 当前业务角色 | 本次是否改业务字段 | MVP 处理方式 |
+| --- | --- | --- | --- |
+| `demand_content` | 普通需求池;ContentFindAgent 当前直接领取的需求卡片 | 改 | 在 `ext_data` 中新增 `evidence_pack`,保留原 `reason/desc/type/video_ids` 兼容旧消费。 |
+| `dwd_multi_demand_pool_di` | 数仓侧需求池同步 / 策略表现参考 | 不改 | 不新增 evidence 字段,不作为下游主证据链载体;MVP 只把原两类策略行镜像到本地 JSON。 |
+| `feature_point_data` | `全局树` 特征点输出 / 特征表现参考 | 不改 | 和 ContentFindAgent 当前主链路无关;MVP 只保留原字段并镜像到本地 JSON。 |
+
+`demand_task` 是任务台账,不算业务结果输出表。本地模式只模拟它的创建和完成状态,方便串起 `demand_task_id`,不改变业务输出结构。
+
+## 改动归属:Agent 框架 vs examples/demand 业务域
+
+### 1. 不涉及 Agent 通用框架的改动
+
+本次 MVP 不修改 `agent/` 通用框架目录。
+
+不改范围包括:
+
+- `agent/core/runner.py`
+  - 不改 AgentRunner 执行循环。
+  - 不改 trace 创建、消息持久化、工具调用调度逻辑。
+  - `trace_id` 只从现有 runner 结果中读取和透传,不改变框架生成 trace 的方式。
+
+- `agent/tools` / tool registry
+  - 不改工具注册框架。
+  - 不改 `@tool` 装饰器能力。
+  - 不改工具 schema 生成机制。
+  - 本次只是修改 `examples/demand` 下业务工具函数的参数和写出内容。
+
+- `agent/trace`
+  - 不改 trace store。
+  - 不改 `.trace` 文件结构。
+  - 只把现有 `trace_id` 写入 DemandAgent 本地输出 JSON,作为业务输出字段。
+
+- LLM 调用框架
+  - 不改模型选择、LLM client、OpenRouter 调用逻辑。
+  - 不改 `RunConfig` 的通用字段含义。
+
+结论:本次不是 Agent 框架能力升级,而是 DemandAgent 业务输出协议升级。
+
+### 2. 仅涉及 examples/demand 业务域的改动
+
+本次实际改动都落在 `examples/demand`,属于 DemandAgent 示例业务域,也就是当前 Demand 生成链路的业务实现层。
+
+当前改动已经完成业务入口、DB 强校验和本地 sink 的 MVP 闭环:`demand_build_agent_tools.py` 产出候选 `evidence_refs`,`evidence_pack_builder.py` 用真实 DB 只读校验并补齐 `evidence_pack`,`run.py` 只把 passed rows 写入本地 `demand_content.json`,失败项写入 `rejected_demand_items.json`。
+
+具体包括:
+
+- `examples/demand/demand_build_agent_tools.py`
+  - 增强 `create_demand_item` / `create_demand_items`。
+  - 新增可选 `evidence_refs` 字段。
+  - 这是业务工具输出结构调整,不是 Agent 工具框架调整。
+
+- `examples/demand/demand.md`
+  - 修改 DemandAgent prompt。
+  - 要求模型在生成 DemandItem 时带上来源证据引用。
+  - 这是业务 prompt 约束,不是框架 prompt 系统改造。
+
+- `examples/demand/run.py`
+  - 调整结果写出路径。
+  - 增加 `local_json` 本地输出模式。
+  - 避免 MVP 测试时真实写 `demand_task` / `demand_content`。
+  - 这是 DemandAgent 业务 orchestration 改动。
+
+- `examples/demand/data_query_tools.py`
+  - 把 Hive 写入逻辑拆成“构造输出行”和“真实写入”两层。
+  - 本地模式只调用纯映射生成 `dwd_multi_demand_pool_di.json` / `feature_point_data.json` 镜像。
+  - 不向这两张表增加 `evidence_pack`,不改变它们的业务字段。
+
+- `examples/demand/evidence_pack_builder.py`
+  - 新增 `evidence_pack` 构造逻辑。
+  - 从真实 DB 只读补齐 itemset/category/element/case 证据。
+  - 这是 DemandAgent 输出字段补齐逻辑。
+
+- `examples/demand/local_output_sink.py`
+  - 新增本地 JSON sink。
+  - 由 `run.py` 统一模拟 `demand_task`、`demand_content`、Hive 输出表。
+  - 其中只有 `demand_content` 的 `ext_data.evidence_pack` 是新增业务结构;Hive 输出表只是原样镜像。
+  - 这是 MVP 测试输出设施,不进入通用框架。
+
+- `examples/demand/run_existing_execution_local.py`
+  - 新增只读测试入口。
+  - 只允许传已有 `execution_id`。
+  - 禁止调用 `run_mining`,避免写 Pattern 中间表。
+
+## Key Changes
+
+1. DemandItem 输出结构增强
+
+在 `create_demand_item/create_demand_items` 增加可选 `evidence_refs`,原有 `element_names/reason/desc/type` 不变。
+
+```python
+record = {
+    "element_names": element_names,
+    "reason": reason,
+    "desc": desc,
+    "type": type,
+    "evidence_refs": evidence_refs or {},
+}
+```
+
+推荐 `evidence_refs` 格式:
+
+```json
+{
+  "source_kind": "pattern_itemset",
+  "source_tool": "get_itemset_detail",
+  "itemset_ids": [123],
+  "category_ids": [456, 789],
+  "source_post_id": "55157577",
+  "case_ids": {
+    "pattern_itemset": ["55157577"]
+  },
+  "seed_terms": ["猫咪", "拟人化"],
+  "notes": "source_post_id 必须来自 get_itemset_detail.post_ids / matched_post_ids"
+}
+```
+
+`case_ids` 在候选阶段按 `source_kind` 分组,而不是一个裸数组。示例:`{"pattern_itemset": ["post_id"], "direct_case": ["case_id"]}`。若当前来源没有 Case seed,对应 source_kind 可以填空数组,但不能把其他来源的 Case 混入本来源。
+
+2. `demand_content.ext_data` 增加 `evidence_pack`
+
+本地输出的 `demand_content.json` 保持真实表字段形态:
+
+当前落地阶段已经接入 DB 强校验 builder:`ext_data.evidence_refs` 只作为 LLM 候选输入;只有校验通过后才会在 `ext_data.evidence_pack` 中写入 `source_certainty=db_validated`、`validation_status=passed`。校验失败的 DemandItem 不进入 `demand_content.json`。
+
+```json
+{
+  "id": 1,
+  "merge_leve2": "贪污腐败",
+  "name": "权钱交易,利益输送",
+  "reason": "...",
+  "suggestion": "...",
+  "score": 0.73,
+  "ext_data": {
+    "reason": "...",
+    "desc": "...",
+    "type": "pattern",
+    "video_ids": ["..."],
+    "evidence_pack": {
+      "pattern_source_system": "mysql_topic_pattern",
+      "pattern_execution_id": 756,
+      "mining_config_id": 1301,
+      "source_kind": "pattern_itemset",
+      "case_id_type": "post_id",
+      "source_post_id": "55157577",
+      "category_bindings": [],
+      "element_bindings": [],
+      "itemset_ids": [123],
+      "itemset_items": [
+        {
+          "itemset_id": 123,
+          "category_id": 456,
+          "category_path": "目的点>实质>祝福表达>祝福词句",
+          "point_type": "关键点",
+          "dimension": "实质",
+          "element_name": "祝福词句"
+        }
+      ],
+      "support": 0.18,
+      "absolute_support": 12,
+      "matched_post_ids": ["..."],
+      "case_ids": ["..."],
+      "decode_case_ids": [],
+      "seed_terms": ["权钱交易", "利益输送"],
+      "trace_id": "...",
+      "demand_task_id": 1,
+      "demand_content_id": 1,
+      "source_certainty": "db_validated",
+      "validation_status": "passed"
+    }
+  },
+  "dt": "20260604"
+}
+```
+
+3. `dwd_multi_demand_pool_di` / `feature_point_data` 不改业务结构,仅做本地镜像
+
+本地目录仍保留多文件,目的是完整复刻本次 run 的所有输出,便于回归验证“不真实写 DB / Hive”:
+
+```text
+test_output_data/{run_id}/
+  run_manifest.json
+  demand_task.json
+  demand_items.json
+  rejected_demand_items.json
+  demand_content.json
+  dwd_multi_demand_pool_di.json
+  feature_point_data.json
+```
+
+`dwd_multi_demand_pool_di.json` 只镜像当前两种策略,不新增证据字段:
+
+- `当下供需gap`
+- `当下供需gap-分词`
+
+`feature_point_data.json` 始终作为本地契约文件存在;仅在 `merge_leve2 == "全局树"` 时有非空业务行,字段仍沿用当前实现中的 `特征点`、`总分发曝光pv`、`质bn_rovn`、`dt`。注意:真实表/代码里的历史字段拼写是 `merge_leve2`,CLI 参数可以叫 `--merge-level2`,但输出 JSON 和文档中的表字段应保持 `merge_leve2`,不要误改成 `merge_level2`。
+
+4. `evidence_pack` 采用强校验:LLM 只提供候选引用,代码校验失败则拒绝该需求
+
+`evidence_refs` 只是 DemandAgent LLM 在生成需求时给出的候选证据引用,不直接下发给 ContentFindAgent。真正进入 `demand_content.ext_data.evidence_pack` 的,只能是代码按当前 `execution_id` 从 DB 校验通过并补齐后的证据。
+
+原则:
+
+- 下游消费的 `evidence_pack` 必须是真实、可追溯、DB 校验通过的证据链。
+- 如果 `itemset_ids/category_ids/element_bindings/video_ids` 任一必需证据无法校验,当前 DemandItem 失败,不写入 `demand_content`。对 Pattern itemset 来源,`case_ids` 按 `post_id` 语义等于已校验的 `matched_post_ids`,可为空的是 `decode_case_ids`;`source_post_id/itemset_ids/itemset_items/mining_config_id/matched_post_ids/support/absolute_support` 必须强校验通过。若 LLM 漏填 `source_kind`,但已给出 `itemset_ids` 或 `source_tool=get_itemset_detail`,DemandAgent 可内部补齐为 `pattern_itemset` 后继续 DB 强校验。
+- 失败的 DemandItem 只进入本地 `rejected_demand_items.json` 供排查,Agent 继续生成下一条需求。
+- 不允许把候选反查、跨 execution 命中、DB 错误、LLM 抄错 ID 的结果伪装成下游可消费证据。
+
+字段来源和校验口径如下:
+
+| 字段 | 主要来源 | 真实性判断 | 是否可能受 LLM 影响 |
+| --- | --- | --- | --- |
+| `pattern_source_system` | 代码按当前读取链路设置,MVP 固定为 `mysql_topic_pattern` | 标记只能追到 DemandAgent 当前消费的 MySQL Pattern Tree;若要追 PG Pattern V2,必须另补 PG ID 或同步映射 | 否 |
+| `pattern_execution_id` | 当前运行参数 / `topic_pattern_execution.id` | DB 可验证事实 | 否 |
+| `mining_config_id` | `topic_pattern_itemset.mining_config_id` | 必须来自已校验 itemset | 否 |
+| `case_id_type` | 代码按证据类型设置,Pattern case 链路固定为 `post_id` | 用于标准化 `source_post_id/matched_post_ids/aweme_id` 的 ID 语义 | 否 |
+| `source_post_id` | LLM 候选 + `topic_pattern_itemset.matched_post_ids` 校验 | 必须出现在已校验 itemset 的 `matched_post_ids` 中,否则拒绝该需求 | 候选阶段受影响,最终输出必须 DB 校验通过 |
+| `itemset_ids` | LLM 在 `evidence_refs` 中给出候选,代码按 `execution_id` 回查校验 | 只有属于当前 execution 且和需求证据匹配时才进入 evidence_pack;否则该需求拒绝 | 候选阶段受影响,最终输出必须 DB 校验通过 |
+| `itemset_items[]` | `topic_pattern_itemset_item` + `topic_pattern_category` | 必须来自已校验 itemset,且每个 `category_id` 能 join 到当前 execution 的分类节点 | 否 |
+| `support` / `absolute_support` | `topic_pattern_itemset` | DB 可验证事实 | 否 |
+| `matched_post_ids` | `topic_pattern_itemset.matched_post_ids` | DB 可验证事实;数量必须等于 `absolute_support` | 否 |
+| `category_bindings` | `topic_pattern_itemset_item` + `topic_pattern_category` | 必须来自已校验 itemset/category;查不到则拒绝该需求 | 否 |
+| `element_bindings` | `topic_pattern_element` | 必须能在当前 execution 下精确绑定到需求 seed 或 itemset item;只查到候选时拒绝该需求 | 否 |
+| `video_ids` | 优先来自已校验 itemset 的 `matched_post_ids` | 必须来自已校验证据链;不再把宽松反查结果作为下游证据 | 否 |
+| `case_ids` | 已校验 itemset 的 `matched_post_ids` | Pattern 来源下固定是 `post_id` 语义,和 `case_id_type=post_id` 对齐 | 否 |
+| `decode_case_ids` | `workflow_decode_task_result.channel_content_id IN video_ids` | 可选补充字段;命中不到不影响 Pattern itemset 来源通过 | 否 |
+| `seed_terms` | LLM 候选 + 代码校验后的 element/category/itemset 反推 | 最终输出必须能被已校验证据覆盖;否则拒绝该需求 | 候选阶段受影响,最终输出必须被证据覆盖 |
+| `source_kind` | LLM 候选明确声明;若缺失但已有 `itemset_ids` 或 `source_tool=get_itemset_detail`,DemandAgent 内部可安全补齐为 `pattern_itemset` | MVP 只支持 `pattern_itemset`;补齐后仍必须走 DB 强校验,不是 Pattern itemset 则拒绝 | 候选阶段受影响,最终输出必须 DB 校验通过 |
+| `trace_id` | 现有 Agent runner / trace store | 框架事实 | 否 |
+| `demand_content_id` | 生产写库后为真实 `demand_content.id`;MVP 本地为模拟 ID | 生产真实,MVP 测试模拟 | 否 |
+
+因此最终进入 `demand_content` 的 `evidence_pack` 只能出现:
+
+```json
+{
+  "source_certainty": "db_validated",
+  "validation_status": "passed"
+}
+```
+
+如果 LLM 给出的 `itemset_ids/category_ids/source_post_id` 查不到、跨 execution,`source_post_id` 不在已校验 itemset 的 `matched_post_ids` 中,`itemset_items[]` 或 `mining_config_id` 补不齐,和 `element_names/seed_terms` 没有强匹配,或者 DB 校验失败,则当前 DemandItem 不生成 `demand_content`,只记录到本地拒绝清单:
+
+```json
+{
+  "element_names": ["地貌水文", "互动设计"],
+  "reject_reason": "itemset_id 377530 not found under execution_id 1987",
+  "validation_status": "failed"
+}
+```
+
+业务含义:给下游 ContentFindAgent 的任务证据链必须真实。只要 DemandAgent 无法确认证据真实性,这条需求就不进入需求池;Agent 应继续生成下一条可强校验的需求。
+
+5. 输入仍从真实 DB 拉
+
+MVP 入口只接受已有 `execution_id`:
+
+```bash
+DEMAND_OUTPUT_MODE=local_json python -m examples.demand.run_existing_execution_local \
+  --execution-id 756 \
+  --merge-level2 贪污腐败 \
+  --platform-type piaoquan \
+  --count 5 \
+  --run-id local_756_smoke
+```
+
+local 模式只能通过 `examples.demand.run_existing_execution_local` 进入;`run.py` 会校验 `DEMAND_LOCAL_ENTRYPOINT=run_existing_execution_local`。该入口必须传已有 `execution_id`,禁止调用 `run_mining` 或各平台 prepare,避免写 `topic_pattern_*`。
+
+本地入口会把三类运行产物统一重定向到 `examples/demand/test_output_data/{run_id}` 下。可传 `--run-id` 指定目录名;如果传 `--output-root`,也必须位于 `examples/demand/test_output_data/` 之内。
+
+```text
+{output_root}/
+  intermediate/result/
+  .trace/
+  output/
+  run_manifest.json
+  demand_task.json
+  demand_items.json
+  rejected_demand_items.json
+  demand_content.json
+  dwd_multi_demand_pool_di.json
+  feature_point_data.json
+```
+
+若未传 `--output-root`,默认写到 `examples/demand/test_output_data/execution_{execution_id}_{timestamp}/`。本 MVP 不刷新本地权重缓存;现有 `get_weight_score_*` 仍依赖 `examples/demand/data/{execution_id}` 已存在。
+
+## Test Plan
+
+- 写库屏障测试:本地模式下 monkeypatch `mysql_db.insert/update/insert_many` 和 `execute_odps_sql`,若被调用则测试失败。
+- 输出结构测试:校验本地 JSON 文件存在;重点校验 `demand_content.ext_data.evidence_pack`,同时确认 `dwd_multi_demand_pool_di.json` / `feature_point_data.json` 没有新增业务字段。
+- evidence 测试:校验所有进入 `demand_content` 的 `evidence_pack.pattern_execution_id/source_kind/seed_terms/trace_id/source_certainty/validation_status` 必填,`validation_status` 必须为 `passed`,`source_certainty` 必须为 `db_validated`。
+- Pattern itemset evidence 测试:当 `source_kind=pattern_itemset` 时,`pattern_source_system/case_id_type/source_post_id/mining_config_id/itemset_ids/itemset_items/matched_post_ids/support/absolute_support` 必填,且 `source_post_id` 必须在 `matched_post_ids` 中,`len(matched_post_ids)` 必须等于 `absolute_support`。
+- 严格拒绝测试:构造 LLM 声明错误 itemset、跨 execution itemset、缺少 `matched_post_ids/source_post_id/itemset_items/mining_config_id`、DB 校验失败等样本,验证这些 DemandItem 不进入 `demand_content`,只进入 `rejected_demand_items.json`。
+- 回归测试:默认不设置 `DEMAND_OUTPUT_MODE` 时,旧生产路径仍保持原来的函数入口和字段映射。
+- 多 subagent 验证:分别做 DB 血缘审计、输出契约审计、旧能力回归审计,交叉确认没有改坏原能力。
+
+## Assumptions
+
+- MVP 不做真实 DB 写入,也不做 DB schema migration。
+- 本次只调整 DemandAgent 输出,不改 `agent/` 通用框架。
+- 本次下游证据链只以 `demand_content.ext_data.evidence_pack` 为载体;`dwd_multi_demand_pool_di` 和 `feature_point_data` 不承载 evidence_pack。
+- DemandAgent 当前消费的是 MySQL `open_aigc_pattern.topic_pattern_*` 兼容层,没有 `topic_pattern_itemset_post`;post 支撑关系通过 `topic_pattern_itemset.matched_post_ids` 表达。
+- 本 MVP 只保证追到 DemandAgent 当前消费的 MySQL Pattern Tree;若产品要求追到 PG Pattern V2 主事实,需要补 PG ID 或 PG -> MySQL 映射证明。
+- 对纯 Pattern itemset 来源,`case_ids` 按 `post_id` 语义直接等于已校验的 `matched_post_ids`,`decode_case_ids` 再按 `workflow_decode_task_result.channel_content_id IN video_ids` 只读补齐且允许为空。`source_post_id`、`matched_post_ids`、`itemset_ids`、`itemset_items[]`、`mining_config_id`、`support`、`absolute_support` 必须强校验通过。

+ 435 - 0
demandagent给下游的迭代需求.md

@@ -0,0 +1,435 @@
+# DemandAgent 给下游的迭代需求
+
+## 1. 结论先行
+
+新版 ContentFindAgent 不能只靠旧版反查机制来恢复 Pattern、Case 和分类树血缘。
+
+旧版能拿到一些候选线索,例如用需求词去查历史 Pattern 元素,再回查 Case 解构点。但这条链路不是精确血缘,也不能稳定闭合到唯一的 Pattern、itemset 或 Case。
+
+为了新版能从需求出发稳定跑通数据源、Query、判断、游走、资产沉淀和策略学习,DemandAgent 或上游证据层需要把生成需求时已经知道的证据一起传给下游。
+
+更准确地说:DemandAgent 补 `demand_content.ext_data.evidence_pack` 是 Exact Mode 的必要条件,但不是充分条件。ContentFindAgent 还必须在领取需求、生成候选、判断筛选、最终入库时读取、继承并沉淀结构化 `source_evidence`,最终结果才能从 `post_id/aweme_id` 回到具体 Pattern、itemset 和分类树节点。
+
+下游必须新增或扩展的核心字段:
+
+| 字段 | 作用 |
+| --- | --- |
+| `pattern_source_system` | 标记 Pattern 证据来自 DemandAgent 当前 MySQL 兼容层,还是 PG Pattern V2 主事实 |
+| `pattern_execution_id` | 标记需求来自哪次 Pattern 挖掘执行 |
+| `mining_config_id` | 标记来源 Pattern 使用的具体挖掘配置 |
+| `source_kind` | 标记需求来源是聚类结果、Pattern 频繁项集、直接 Case,还是其他来源 |
+| `case_id_type` | 标准化 `caseid` 的语义,例如本链路中应明确为 `post_id` |
+| `source_post_id` | 标记本条需求证据中的原始支撑帖子 |
+| `category_bindings` | 绑定分类节点,例如分类 ID、路径、层级、父节点、实质/形式/意图 |
+| `element_bindings` | 绑定具体元素,例如元素名、元素类型、点类型、所属 post |
+| `itemset_ids` | 绑定 Pattern 频繁项集 |
+| `itemset_items[]` | 绑定 itemset 内每个 item 对应的分类节点、路径、点类型和维度 |
+| `support` / `absolute_support` | 记录 Pattern 支持度,判断它是不是稳定组合 |
+| `matched_post_ids` | 记录支撑 Pattern 的原始帖子或视频 |
+| `video_ids` / `case_ids` | 记录可作为 Case seed 的素材 ID;Pattern 来源下二者都是 `post_id` 语义 |
+| `decode_case_ids` | 可选补充 decode 表行 ID,命中不到时可为空 |
+| `seed_terms` | 记录真正给 Query 使用的下层特征或元素词 |
+| `source_evidence` | ContentFindAgent 最终入库时继承的结构化来源证据 |
+| `source_certainty` / `validation_status` | 标记证据是否已经 DB 强校验通过 |
+| `trace_id` / `demand_task_id` / `demand_content_id` | 串起需求生成、内容寻找、判断、沉淀和策略学习 |
+
+V1 不一定马上改复杂 DB schema。可以先在 `demand_content.ext_data` 里增加一个结构化 `evidence_pack`,先把证据传下来。
+
+## 2. 新版下游到底需要什么
+
+新版 ContentFindAgent 不是只拿一个需求词去搜内容。它需要知道这个需求从哪里来、为什么成立、应该怎么生成 Query、判断结果应该绑定回哪棵分类树,以及最终资产入库前是否可追溯。
+
+下游需要的对象主要有五类。
+
+| 对象 | 下游含义 | 为什么需要 |
+| --- | --- | --- |
+| Pattern | 分类或元素的稳定组合 | 用来判断需求是不是有结构支撑,而不是单条素材偶然出现 |
+| Case | 支撑 Pattern 或需求的原始素材 | 用来查看素材原文、解构点、平台调性和后续判断证据 |
+| 分类 / 元素绑定 | 需求对应分类树的哪个父节点或子节点 | 所有游走、判断和入库结果都要能绑定回分类树 |
+| 支撑素材 | `matched_post_ids`、`video_ids`、Case ID 等 | 用来做 Query 输入、Case 回扣、判断规则证据和策略学习 |
+| trace | 一路记录从需求到结果的路径 | 策略学习、失败复盘、入库追溯都需要它 |
+
+三类“有需求”的数据源都应该作为 DemandAgent 下游。它们不能只给一个自然语言需求名,还要给来源证据。不然后续即使找到内容,也说不清这个内容为什么服务于当初那个需求。
+
+所有资产入库前至少要保留:
+
+| 必留信息 | 用途 |
+| --- | --- |
+| 来源路径 | 证明资产从哪个需求、Pattern、Case 或平台线索来 |
+| 判断结果 | 证明为什么继续、入池、观察、淘汰 |
+| 分类或元素绑定 | 证明资产属于分类树中的哪个节点 |
+| 平台调性说明 | 证明资产适合当前平台,不是跨平台硬套 |
+| trace | 支持策略学习和后续复盘 |
+
+## 3. DemandAgent 当前给了什么
+
+### 3.1 DemandItem
+
+DemandAgent 当前生成的 `DemandItem` 字段很窄。
+
+代码证据:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/demand_build_agent_tools.py:37`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/demand_build_agent_tools.py:113`
+
+当前保留字段:
+
+| 字段 | 当前含义 |
+| --- | --- |
+| `element_names` | 需求名称来源,后续会拼成 `demand_content.name` |
+| `reason` | 生成需求的理由 |
+| `desc` | 需求描述,后续写入 `suggestion` |
+| `type` | 来源类型,例如元素、关系、分类 |
+
+这里没有保留 `execution_id`、分类 ID、元素 ID、itemset、support 或 matched posts。
+
+### 3.2 demand_content
+
+`write_demand_items_to_mysql()` 会把 DemandItem 写入 `demand_content`。
+
+代码证据:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run.py:330`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run.py:590`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run.py:635`
+
+当前写入字段:
+
+| `demand_content` 字段 | 来源 |
+| --- | --- |
+| `merge_leve2` | 当前执行品类 |
+| `name` | `element_names` 逗号拼接 |
+| `reason` | DemandItem.reason |
+| `suggestion` | DemandItem.desc |
+| `score` | 按 `name` 拆分后查权重并求平均 |
+| `ext_data` | JSON,当前包含 `reason`、`desc`、`type`、`video_ids` |
+| `dt` | 当天日期 |
+
+注意:这里的字段名是历史真实表字段 `merge_leve2`,不是 `merge_level2`。CLI 或函数参数可以使用 `merge_level2` 作为变量名,但落到 `demand_content`、本地 JSON、下游读取契约时必须保持 `merge_leve2`。
+
+`video_ids` 在旧逻辑中主要通过 `execution_id + name` 反查出来;本 MVP 对 Pattern itemset 来源会优先采用 DB 校验后的 `evidence_pack.video_ids/matched_post_ids`。相关代码在 `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/run.py:257` 和 `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/db_manager.py:41`。
+
+但写入 `demand_content` 后,`execution_id` 自身没有留下。下游只看到 `video_ids`,看不到这些 video 是从哪个 Pattern execution、哪个分类节点、哪个 itemset 推出来的。
+
+### 3.3 dwd_multi_demand_pool_di
+
+DemandAgent 还会尽力写入 `loghubods.dwd_multi_demand_pool_di`。
+
+代码证据:
+
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/data_query_tools.py:128`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/data_query_tools.py:194`
+- `/Users/samlee/Documents/works/DemandAgentNew/examples/demand/data_query_tools.py:227`
+
+当前映射字段:
+
+| 字段 | 当前含义 |
+| --- | --- |
+| `strategy` | 策略名 |
+| `demand_id` | 派生 ID |
+| `demand_name` | 需求名 |
+| `weight` | 分数 |
+| `type` | 从 `ext_data.type` 解析 |
+| `video_count` | `video_ids` 数量 |
+| `video_list` | `video_ids` |
+| `extend` | 当前主要保留品类 |
+| `dt` | 分区日期 |
+
+这一层也没有保留 Pattern execution、itemset、分类路径、support 或 matched posts。
+
+## 4. 为什么旧版反查不够
+
+旧版 ContentFindAgent 的 Case 反查工具是 `get_goodcase_topic_point`。
+
+代码证据:
+
+- `/Users/samlee/Documents/works/ContentFindAgent/examples/content_finder/tools/get_goodcase_topic_point.py:91`
+- `/Users/samlee/Documents/works/ContentFindAgent/examples/content_finder/tools/get_goodcase_topic_point.py:96`
+- `/Users/samlee/Documents/works/ContentFindAgent/examples/content_finder/tools/get_goodcase_topic_point.py:144`
+
+旧链路是:
+
+```text
+features
+-> topic_pattern_element.name = feature
+-> post_id 交集
+-> workflow_decode_task_result.channel_content_id
+-> purpose_points / key_points / inspiration_points
+```
+
+这条链路有三个问题。
+
+| 问题 | 说明 |
+| --- | --- |
+| 不带 `execution_id` | 同一个词可能出现在多个 Pattern execution,不能证明来自当前需求那次执行 |
+| 只查元素名 | DemandAgent 当前有些需求名其实是分类节点,旧 CFA 只查 `topic_pattern_element.name` 会漏掉 |
+| 不查 itemset | 无法恢复 `itemset_id`、support、absolute support、matched posts |
+
+DemandAgent 自己在生成 `video_ids` 时,用的是 `execution_id + name/category`。旧 CFA 事后反查用的是裸 `name`。两条链路不是同一种机制。
+
+所以旧版反查只能作为 fallback,用来找候选 Case;不能作为新版的主血缘。
+
+## 5. 必须补给下游的字段清单
+
+| 字段 | 字段意义 | 下游用途 | 为什么不能靠 CFA 自己补 | 缺失风险 |
+| --- | --- | --- | --- | --- |
+| `pattern_source_system` | Pattern 证据来源系统,MVP 固定为 `mysql_topic_pattern` | 判断反查目标是 DemandAgent MySQL Pattern Tree 还是 PG Pattern V2 | CFA 只看需求单无法知道证据来自哪套 Pattern 表 | 把 MySQL 兼容层误当 PG 主事实 |
+| `pattern_execution_id` | 当前需求来源的 Pattern 执行 ID | 数据源详情、Pattern 回查、策略学习 | CFA 只能按词反查,无法知道当时 execution | 同名词跨 execution 混淆 |
+| `mining_config_id` | 当前 itemset 所属挖掘配置 ID | 区分同一 execution 下不同 Pattern 配置 | CFA 事后只靠 post 反查会得到多个配置下的候选 itemset | 无法确定具体 Pattern 配置 |
+| `source_kind` | 来源类型:聚类结果、Pattern 频繁项集、直接 Case 等 | 决定后续流程:是否进 Query,是否先游走或先判断 | 只看 `type` 不够,不能区分真实来源路径 | 不同来源走错流程 |
+| `case_id_type` | `caseid` 的 ID 语义,Pattern case 链路中应明确为 `post_id` | 稳定对齐 `post_id/channel_content_id/aweme_id` | CFA 只能猜这个 ID 是结果表 id、decode id 还是 post id | join 语义混乱 |
+| `source_post_id` | 本条证据里的原始支撑帖子 ID | 从最终 case 回到上游 matched post | 单个 post 可能支撑很多 itemset,CFA 不能知道哪条是来源 post | 只能候选回查,不能 exact |
+| `category_bindings` | 分类节点绑定,包含 `category_id/path/level/parent_id/source_type` | 所有结果绑定回分类树父节点 | CFA 按元素名查不到分类节点,且同名分类可能多处存在 | 入库后无法解释属于哪个分类 |
+| `element_bindings` | 元素绑定,包含 `name/element_type/point_type/post_id/category_path` | 作为 Query 输入素材、判断回扣证据 | CFA 可以按词查候选元素,但不知道哪个是 DemandAgent 采用的 | Query seed 可能跑偏 |
+| `itemset_ids` | Pattern 频繁项集 ID | 回查 Pattern 组合和稳定性 | CFA 旧链路不查 itemset | 不能证明 Pattern 是组合,不是散词 |
+| `itemset_items[]` | itemset 内每个 item 对应的 `category_id/category_path/point_type/dimension/element_name` | 直接还原 Pattern chip 到分类树节点 | CFA 只靠 `itemset_id` 还要二次猜 item 和节点 | 不能稳定解释具体命中的分类树节点 |
+| `support` | 相对支持度 | 判断 Pattern 是否稳定 | 需求池当前没有保存 | 无法按稳定性排序或过滤 |
+| `absolute_support` | 绝对支撑素材数 | 判断有多少素材支撑 | 需求池当前没有保存 | 不能区分单例和稳定模式 |
+| `matched_post_ids` | 支撑 itemset 的帖子 ID 列表 | 找 Case、看原始素材、做回扣判断 | `video_ids` 不等价于 itemset 的 matched posts | Case 来源不准,支撑证据不准 |
+| `video_ids` / `case_ids` | 可直接作为 Case seed 的素材 ID;Pattern 来源下固定是 `post_id` 语义 | 数据源 Case、Query 输入、判断证据 | CFA 可偶尔从 decode 表命中,但不稳定 | 如果语义不清,容易把 post_id 和 decode row id 混用 |
+| `decode_case_ids` | `workflow_decode_task_result.id` 的可选补充 | 有 decode 原文时可快速定位 decode 行 | 并非所有 Pattern matched post 都能命中 decode 表 | 不能把 decode 缺失误判成 Pattern 证据失败 |
+| `seed_terms` | 真正用于 Query 的下层特征或元素词 | Query 输入素材 | `name` 可能是分类、关系或宽泛表达,不一定适合作 Query | Query 输入不稳定 |
+| `source_evidence` | 下游最终结果继承的结构化证据 | 写入 `demand_find_content_result` 或 source edge artifact | DemandAgent 只能给需求证据,无法替下游结果落库 | 最终结果又断在 `process_trace` 文本 |
+| `source_certainty` | 证据可信度,进入下游时必须为 `db_validated` | 下游判断是否能 exact 回溯 | CFA 无法判断 LLM 声明是否已被 DB 校验 | 把候选证据误当真实证据 |
+| `validation_status` | DemandAgent 证据校验状态,进入需求池时必须为 `passed` | 下游只消费通过校验的任务 | CFA 事后补查无法恢复生成时的拒绝原因 | 失败证据流入下游 |
+| `trace_id` | 贯穿下游任务的 trace | 策略学习、失败复盘 | CFA 可创建自己的 trace,但不能补上游生成过程 | 上下游链路断开 |
+| `demand_task_id` | DemandAgent 侧任务 ID | 回查生成日志和任务状态 | CFA 无法凭需求名可靠找到唯一任务 | 无法定位上游运行 |
+| `demand_content_id` | 下游领取的需求 ID | CFA 主任务绑定 | 这个字段 CFA 已能拿到,但应进入统一证据包 | Trace 不统一 |
+
+## 6. 检查过程与证据
+
+### 6.1 DemandAgent 文档审计
+
+`/Users/samlee/Documents/works/DemandAgent工程文档集/demand-agent-analysis.md:432` 已写明 DemandItem 到 `demand_content` 的映射:
+
+| 目标字段 | 来源 |
+| --- | --- |
+| `merge_leve2` | 当前执行品类 |
+| `name` | `element_names` 拼接 |
+| `reason` | DemandItem.reason |
+| `suggestion` | DemandItem.desc |
+| `score` | 权重 JSON 查分求平均 |
+| `ext_data` | `reason/desc/type/video_ids` |
+| `dt` | 当天日期 |
+
+同一份文档也指出:`workflow_decode_task_result` 是辅助回填逻辑,不是主生成 loop 默认步骤;Agent 输出主要靠 Prompt 约束,没有后置校验器强制每条需求都有权重或支持度。
+
+### 6.2 DemandAgent 代码审计
+
+DemandAgent 代码里确实有 Pattern 分类、元素和 itemset 字段。
+
+代码证据:
+
+- `TopicPatternCategory`:`/Users/samlee/Documents/works/DemandAgentNew/examples/demand/models.py:45`
+- `TopicPatternElement`:`/Users/samlee/Documents/works/DemandAgentNew/examples/demand/models.py:63`
+- `TopicPatternItemset`:`/Users/samlee/Documents/works/DemandAgentNew/examples/demand/models.py:93`
+- `TopicPatternItemsetItem`:`/Users/samlee/Documents/works/DemandAgentNew/examples/demand/models.py:109`
+
+关键字段包括:
+
+| 表 | 关键字段 |
+| --- | --- |
+| `topic_pattern_category` | `execution_id`, `source_type`, `name`, `path`, `level`, `parent_id` |
+| `topic_pattern_element` | `execution_id`, `post_id`, `point_type`, `element_type`, `name`, `category_id`, `category_path` |
+| `topic_pattern_itemset` | `execution_id`, `combination_type`, `item_count`, `support`, `absolute_support`, `dimensions`, `matched_post_ids` |
+| `topic_pattern_itemset_item` | `itemset_id`, `point_type`, `dimension`, `category_id`, `category_path`, `element_name` |
+
+也就是说,DemandAgent 内部能接触到这些字段。问题不是上游完全没有,而是写给下游时没有保留下来。
+
+### 6.3 旧 ContentFindAgent 代码审计
+
+旧 CFA 的 `get_goodcase_topic_point` 只按 `topic_pattern_element.name` 精确匹配。
+
+关键 SQL:
+
+```sql
+SELECT DISTINCT post_id
+FROM topic_pattern_element
+WHERE name = %s
+```
+
+这个 SQL 不带 `execution_id`、`category_id`、`itemset_id`,也不查 `topic_pattern_category`。因此它无法还原 DemandAgent 生成需求时的真实 Pattern 上下文。
+
+### 6.4 真实 DB 只读抽样
+
+本轮只做了只读查询,没有写 DB,也没有输出 secret。
+
+DB 侧验证结论:
+
+| 验证项 | 结果 |
+| --- | --- |
+| `demand_content` 行数 | 29284 |
+| 最近样本 `ext_data` | 有 `reason/desc/type/video_ids` |
+| 最新 10 条需求 | 共 104 个 `video_id` |
+| 这 104 个 `video_id` 命中 `workflow_decode_task_result` | 0 |
+| 这 104 个 `video_id` 命中 `topic_pattern_element.post_id` | 0 |
+| 最近 5000 条非空 `ext_data` | 4920 条有 `video_ids` |
+| distinct `video_id` | 922 |
+| 能命中 `workflow_decode_task_result` 的 `video_id` | 38 |
+
+这说明 `ext_data.video_ids` 可以作为候选 Case seed,但不是稳定闭合通道。
+
+对照样本里,`demand_content.id=48563` 的 12 个 video_id 中有 2 个命中 decode Case;但同一需求词能命中多个 Pattern execution,且没有任何 itemset 的 `matched_post_ids` 与整组 `video_ids` 精确相等。
+
+所以真实 DB 也支持这个结论:当前字段不能精确闭合到唯一 Pattern、itemset 或 Case。
+
+### 6.5 subagent 交叉验证
+
+本轮拆了两个 subagent 交叉验证。
+
+| subagent | 验证范围 | 结论 |
+| --- | --- | --- |
+| DemandAgent 代码与文档侧 | DemandItem、`demand_content`、`dwd_multi_demand_pool_di`、Pattern 模型字段 | DemandAgent 当前下游字段很窄,大量 Pattern lineage 在写需求池时丢失 |
+| DB 只读侧 | `demand_content`、`workflow_decode_task_result`、`topic_pattern_*` 抽样闭合 | 只能做到候选回查,不能唯一闭合 Pattern / itemset / Case |
+
+两个方向结论一致。
+
+## 7. 建议的交付方式
+
+V1 建议先不要大改表结构,先扩展 `demand_content.ext_data`。
+
+推荐结构:
+
+```json
+{
+  "reason": "...",
+  "desc": "...",
+  "type": "...",
+  "video_ids": ["55157577"],
+  "evidence_pack": {
+    "pattern_source_system": "mysql_topic_pattern",
+    "source_kind": "pattern_itemset",
+    "case_id_type": "post_id",
+    "source_post_id": "55157577",
+    "pattern_execution_id": 1987,
+    "mining_config_id": 1301,
+    "itemset_ids": [377530],
+    "itemset_items": [
+      {
+        "itemset_id": 377530,
+        "category_id": 43925,
+        "category_path": "目的点>实质>祝福表达>祝福词句",
+        "point_type": "关键点",
+        "dimension": "实质",
+        "element_name": "祝福词句"
+      }
+    ],
+    "category_bindings": [],
+    "element_bindings": [],
+    "support": 0.18,
+    "absolute_support": 12,
+    "matched_post_ids": ["55157577"],
+    "video_ids": ["55157577"],
+    "case_ids": ["55157577"],
+    "decode_case_ids": [],
+    "seed_terms": ["祝福词句"],
+    "trace_id": "...",
+    "demand_task_id": 123,
+    "demand_content_id": 456,
+    "source_certainty": "db_validated",
+    "validation_status": "passed"
+  }
+}
+```
+
+字段可以分批补齐:
+
+| 阶段 | 建议 |
+| --- | --- |
+| V1 必须 | `pattern_source_system`, `source_kind`, `case_id_type`, `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`, `source_certainty`, `validation_status` |
+| V1 可选补充 | `decode_case_ids`,用于保存能命中 `workflow_decode_task_result` 的 decode 行 ID |
+| V1.1 增强 | 独立证据表、PG Pattern V2 ID 映射、下游完整候选池判断日志 |
+| 后续再拆表 | 如果 `evidence_pack` 变大,再考虑独立证据表或关系表 |
+
+注意:如果来源不是 Pattern 频繁项集,而是聚类结果或直接 Case,`itemset_ids/support/matched_post_ids` 可以为空,但最终下发给下游的 `evidence_pack.source_kind` 必须明确,不能让下游猜。
+
+### 7.1 DemandAgent 必须保证的真实性边界
+
+给下游 ContentFindAgent 的任务证据链必须真实。LLM 只能在 `evidence_refs` 里声明候选引用,不能直接决定最终证据。
+
+当前 MVP 中,LLM 生成需求时仍只写 `evidence_refs` 候选引用;DemandAgent 代码会基于真实 DB 只读校验并升级为 `evidence_pack`。推荐 `evidence_refs` 形态如下:
+
+```json
+{
+  "source_kind": "pattern_itemset",
+  "source_tool": "get_itemset_detail",
+  "itemset_ids": [377530],
+  "category_ids": [43925],
+  "source_post_id": "55157577",
+  "case_ids": {
+    "pattern_itemset": ["55157577"]
+  },
+  "seed_terms": ["祝福词句"]
+}
+```
+
+LLM 最好显式声明 `source_kind=pattern_itemset`;如果漏填但已经给出 `itemset_ids` 或 `source_tool=get_itemset_detail`,DemandAgent 内部可以安全补齐为 `pattern_itemset`,然后继续真实 DB 强校验。`source_post_id` 必须来自 `get_itemset_detail` 的 `post_ids/matched_post_ids` 或已查询过的 `get_post_elements` 结果。`case_ids` 在候选阶段可以按 `source_kind` 分组;最终 `evidence_pack.case_ids` 对 Pattern 来源固定为 post_id 列表,decode 表行 ID 另放 `decode_case_ids`。
+
+DemandAgent 代码必须按真实 DB 校验并补齐 `evidence_pack`。对于 `source_kind=pattern_itemset` 的需求,必须确认:
+
+| 校验项 | 必须满足 |
+| --- | --- |
+| `pattern_execution_id` | `topic_pattern_execution.id` 必须存在且 `status='success'` |
+| `source_kind` | 最终 `evidence_pack` 必须是 `pattern_itemset`;LLM 漏填时可由 `itemset_ids/source_tool` 安全推断,不是 Pattern itemset 则拒绝 |
+| `itemset_ids` | 每个 itemset 都属于当前 `pattern_execution_id`,且能查到 `mining_config_id/support/absolute_support/matched_post_ids` |
+| `mining_config_id` | 必须来自 `topic_pattern_itemset.mining_config_id`,并能 join 到同一 execution 的 `topic_pattern_mining_config` |
+| `source_post_id` | 必须在对应 itemset 的 `matched_post_ids` 中 |
+| `matched_post_ids` | 必须非空,数量必须等于 `absolute_support` |
+| `itemset_items[]` | 必须能从 `topic_pattern_itemset_item` 查到,并能 join 到当前 execution 的 `topic_pattern_category` |
+| `category_bindings` | `category_id` 必须属于当前 `pattern_execution_id`,不能跨 execution 借节点 |
+| `element_bindings` | 必须能用 `execution_id + source_post_id + category_id + point_type + dimension/element_type + element_name(如有)` 精确绑定到 `topic_pattern_element`,只查到候选词或其他 matched post 不算通过 |
+| `source_certainty` / `validation_status` | 进入 `demand_content` 时只能是 `db_validated` / `passed` |
+
+任一必需字段查不到、跨 execution、post 不在 matched posts、category 不在当前 execution、source post 精确 element binding 不成立,当前 DemandItem 都失败,不写入 `demand_content`。失败项只进入本地 `rejected_demand_items.json`,Agent 继续生成下一条需求。
+
+对 `source_kind=pattern_itemset` 的需求,`case_ids` 按 `post_id` 语义直接等于已校验的 `matched_post_ids`,因为当前 MySQL Pattern Tree 的主支撑关系来自 `topic_pattern_itemset.matched_post_ids`;可为空的是 `decode_case_ids`。`source_post_id/itemset_ids/itemset_items/mining_config_id/matched_post_ids/support/absolute_support` 不可为空且必须 DB 校验通过。
+
+### 7.2 本地 MVP 输出方式
+
+本次 MVP 不真实写 `demand_task`、`demand_content`、Hive 或 `topic_pattern_*`,只通过 `examples.demand.run_existing_execution_local` 读取已有 `execution_id` 并把输出写到本地:
+
+```text
+examples/demand/test_output_data/{run_id}/
+  run_manifest.json
+  demand_task.json
+  demand_items.json
+  rejected_demand_items.json
+  demand_content.json
+  dwd_multi_demand_pool_di.json
+  feature_point_data.json
+  intermediate/result/
+  .trace/
+  output/
+```
+
+其中只有 `demand_content.json[].ext_data.evidence_pack` 是给下游 exact evidence 的新增载体;`dwd_multi_demand_pool_di.json` 和 `feature_point_data.json` 只做原业务字段镜像,不增加 `evidence_pack`。
+
+### 7.3 ContentFindAgent 接到需求后必须做什么
+
+ContentFindAgent 后续领取 `demand_content` 时,不能只取 `id/name/suggestion/score/merge_leve2/dt`,还必须解析 `ext_data.evidence_pack`。
+
+它需要把 `source_kind/source_post_id/pattern_execution_id/mining_config_id/itemset_ids/itemset_items/category_bindings/matched_post_ids/source_certainty` 注入数据源、Query、判断、游走、入库上下文。最终结果必须保存结构化 `source_evidence` 到 `demand_find_content_result` 或 sidecar/source edge artifact。
+
+如果新找到的 `aweme_id/post_id` 不在 DemandAgent 的 `source_post_id/matched_post_ids` 中,只能标记为 `derived` 或 `candidate`,不能声称它 exact 属于某个 Pattern 节点。除非下游额外完成新 post 的解构和分类绑定,否则 DemandAgent 的证据只能证明“这个需求来源于某 Pattern”,不能证明“这个新 post 属于这个 Pattern”。
+
+## 8. 下游使用方式
+
+| 下游阶段 | 怎么使用这些字段 |
+| --- | --- |
+| 数据源 | 展示 Pattern、Case、分类/元素绑定和原始支撑素材 |
+| Query | 使用 `seed_terms` 作为 Query 输入素材,不直接把宽泛 `name` 当 Query |
+| Platform | 根据来源平台和 seed 生成平台适配 Query |
+| 判断 | 用分类/元素绑定、Case 原文和 support 做回扣判断 |
+| 游走 | 每次游走结果绑定回分类或元素节点,避免无限漂移 |
+| 资产清洗沉淀 | 入库前保留来源路径、判断结果、分类或元素绑定、平台调性说明、trace 和结构化 `source_evidence` |
+| 策略学习 | 用 trace 回看哪类来源、哪类 seed、哪类规则包有效 |
+
+## 9. 最终判断
+
+旧版 ContentFindAgent 可以做补充反查,但不能承担新版主血缘。
+
+如果新版要求精准找到 Pattern、Case、分类或元素绑定,并让所有入库资产可追溯,DemandAgent 必须把生成需求时已经知道的证据一并传给下游。
+
+最小可行做法是:先在 `demand_content.ext_data` 中增加 `evidence_pack`。这样 V1 可以先让需求单携带真实上游证据,不需要马上重构 DB schema,也能避免下游继续靠猜。
+
+但这只是 Exact Mode 的上游基础。最终 `demand_find_content_result` 能不能从 `aweme_id/post_id` 回到具体 Pattern 和分类树节点,还取决于 ContentFindAgent 是否读取、继承并沉淀结构化 `source_evidence`。

+ 3 - 3
examples/demand/agent_tools.py

@@ -1,5 +1,5 @@
 from agent.tools import ToolResult, tool
 from agent.tools import ToolResult, tool
-from examples.demand.demand_pattern_tools import _log_tool_input, _log_tool_output
+from examples.demand.tool_logging import log_tool_input, log_tool_output
 
 
 
 
 @tool(
 @tool(
@@ -23,7 +23,7 @@ def think_and_plan(thought: str, thought_number: int, action: str, plan: str) ->
         "action": action,
         "action": action,
         "plan": plan,
         "plan": plan,
     }
     }
-    _log_tool_input("think_and_plan", params)
+    log_tool_input("think_and_plan", params)
 
 
     result = (
     result = (
         f"[思考 #{thought_number}]\n"
         f"[思考 #{thought_number}]\n"
@@ -32,4 +32,4 @@ def think_and_plan(thought: str, thought_number: int, action: str, plan: str) ->
         f"下一步: {action}\n"
         f"下一步: {action}\n"
         f"(此工具仅用于记录思考过程,不会修改任何数据)"
         f"(此工具仅用于记录思考过程,不会修改任何数据)"
     )
     )
-    return _log_tool_output("think_and_plan", result)
+    return log_tool_output("think_and_plan", result)

+ 110 - 49
examples/demand/data_query_tools.py

@@ -1,16 +1,15 @@
 import hashlib
 import hashlib
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
-from odps import ODPS
-from odps.errors import ODPSError
 from datetime import date, datetime, timedelta
 from datetime import date, datetime, timedelta
 import json
 import json
 from pathlib import Path
 from pathlib import Path
 
 
-from examples.demand.mysql import mysql_db
-
 
 
 def get_odps_data(sql):
 def get_odps_data(sql):
+    from odps import ODPS
+    from odps.errors import ODPSError
+
     # 配置信息
     # 配置信息
     access_id = 'LTAI9EBa0bd5PrDa'
     access_id = 'LTAI9EBa0bd5PrDa'
     access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
     access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
@@ -35,6 +34,9 @@ def get_odps_data(sql):
 
 
 
 
 def execute_odps_sql(sql) -> bool:
 def execute_odps_sql(sql) -> bool:
+    from odps import ODPS
+    from odps.errors import ODPSError
+
     # 配置信息
     # 配置信息
     access_id = 'LTAI9EBa0bd5PrDa'
     access_id = 'LTAI9EBa0bd5PrDa'
     access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
     access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
@@ -85,26 +87,28 @@ def _parse_ext_data(ext_data_raw: object) -> dict:
     return {}
     return {}
 
 
 
 
+def _get_mysql_db():
+    from examples.demand.mysql import mysql_db
+
+    return mysql_db
+
+
 def _build_hive_select_part(
 def _build_hive_select_part(
-        strategy: str,
-        demand_id: str,
-        demand_name: str,
-        weight: float,
-        type_str: str,
-        video_count: int,
-        video_ids: list[str],
-        extend_json: str,
+        row: dict,
 ) -> str:
 ) -> str:
+    video_ids = row.get("video_list") or []
+    if not isinstance(video_ids, list):
+        video_ids = []
     return (
     return (
         "SELECT "
         "SELECT "
-        f"'{_escape_odps_string(strategy)}' AS strategy, "
-        f"'{_escape_odps_string(demand_id)}' AS demand_id, "
-        f"'{_escape_odps_string(demand_name)}' AS demand_name, "
-        f"{weight} AS weight, "
-        f"'{_escape_odps_string(type_str)}' AS `type`, "
-        f"{video_count} AS video_count, "
+        f"'{_escape_odps_string(row.get('strategy', ''))}' AS strategy, "
+        f"'{_escape_odps_string(row.get('demand_id', ''))}' AS demand_id, "
+        f"'{_escape_odps_string(row.get('demand_name', ''))}' AS demand_name, "
+        f"{float(row.get('weight') or 0.0)} AS weight, "
+        f"'{_escape_odps_string(row.get('type', ''))}' AS `type`, "
+        f"{int(row.get('video_count') or 0)} AS video_count, "
         f"{_format_odps_string_array(video_ids)} AS video_list, "
         f"{_format_odps_string_array(video_ids)} AS video_list, "
-        f"'{_escape_odps_string(extend_json)}' AS extend"
+        f"'{_escape_odps_string(row.get('extend', ''))}' AS extend"
     )
     )
 
 
 
 
@@ -121,22 +125,17 @@ PARTITION (dt='{partition_dt}')
     return execute_odps_sql(insert_sql)
     return execute_odps_sql(insert_sql)
 
 
 
 
-def write_dwd_multi_demand_pool_di_to_hive(rows: list[dict]) -> int:
+def build_dwd_multi_demand_pool_di_rows(rows: list[dict], partition_dt: str) -> list[dict]:
     """
     """
-    将行数据映射并写入 loghubods.dwd_multi_demand_pool_di(尽力插入,不校验结果)
+    将 demand_content 形态的行纯映射为 dwd_multi_demand_pool_di 输出行
 
 
-    分区与 demand_id 的日期均为中国时区当天(yyyymmdd),不使用行内 dt 字段。
-    执行两次 INSERT(同表、同分区),策略不同:
-    1) 当下供需gap: demand_name=merge_leve2+' '+name, demand_id=md5(strategy+demand_name+type+dt)
-    2) 当下供需gap-分词: demand_name=name, demand_id=md5(strategy+name+品类+type+dt)
+    不执行 ODPS/Hive 写入;partition_dt 同时用于 demand_id 哈希和本地镜像 dt 字段。
     """
     """
     if not rows:
     if not rows:
-        return 0
-
-    china_today = _hive_partition_dt()
-    gap_parts: list[str] = []
-    fenci_parts: list[str] = []
+        return []
 
 
+    gap_rows: list[dict] = []
+    fenci_rows: list[dict] = []
     for row in rows:
     for row in rows:
         merge_leve2 = str(row.get("merge_leve2") or "").strip()
         merge_leve2 = str(row.get("merge_leve2") or "").strip()
         name = str(row.get("name") or "").strip()
         name = str(row.get("name") or "").strip()
@@ -156,31 +155,91 @@ def write_dwd_multi_demand_pool_di_to_hive(rows: list[dict]) -> int:
 
 
         demand_name_gap = f"{merge_leve2} {name}"
         demand_name_gap = f"{merge_leve2} {name}"
         demand_id_gap = hashlib.md5(
         demand_id_gap = hashlib.md5(
-            f"{_STRATEGY_GAP}{demand_name_gap}{type_str}{china_today}".encode("utf-8")
+            f"{_STRATEGY_GAP}{demand_name_gap}{type_str}{partition_dt}".encode("utf-8")
         ).hexdigest()
         ).hexdigest()
-        gap_parts.append(
-            _build_hive_select_part(
-                _STRATEGY_GAP, demand_id_gap, demand_name_gap,
-                weight, type_str, video_count, video_ids, extend_json,
-            )
+        gap_rows.append(
+            {
+                "strategy": _STRATEGY_GAP,
+                "demand_id": demand_id_gap,
+                "demand_name": demand_name_gap,
+                "weight": weight,
+                "type": type_str,
+                "video_count": video_count,
+                "video_list": video_ids,
+                "extend": extend_json,
+                "dt": partition_dt,
+            }
         )
         )
 
 
         demand_id_fenci = hashlib.md5(
         demand_id_fenci = hashlib.md5(
-            f"{_STRATEGY_GAP_FENCI}{name}{merge_leve2}{type_str}{china_today}".encode("utf-8")
+            f"{_STRATEGY_GAP_FENCI}{name}{merge_leve2}{type_str}{partition_dt}".encode("utf-8")
         ).hexdigest()
         ).hexdigest()
-        fenci_parts.append(
-            _build_hive_select_part(
-                _STRATEGY_GAP_FENCI, demand_id_fenci, name,
-                weight, type_str, video_count, video_ids, extend_json,
-            )
+        fenci_rows.append(
+            {
+                "strategy": _STRATEGY_GAP_FENCI,
+                "demand_id": demand_id_fenci,
+                "demand_name": name,
+                "weight": weight,
+                "type": type_str,
+                "video_count": video_count,
+                "video_list": video_ids,
+                "extend": extend_json,
+                "dt": partition_dt,
+            }
         )
         )
 
 
+    return gap_rows + fenci_rows
+
+
+def write_dwd_multi_demand_pool_di_to_hive(rows: list[dict]) -> int:
+    """
+    将行数据映射并写入 loghubods.dwd_multi_demand_pool_di(尽力插入,不校验结果)。
+
+    分区与 demand_id 的日期均为中国时区当天(yyyymmdd),不使用行内 dt 字段。
+    执行两次 INSERT(同表、同分区),策略不同:
+    1) 当下供需gap: demand_name=merge_leve2+' '+name, demand_id=md5(strategy+demand_name+type+dt)
+    2) 当下供需gap-分词: demand_name=name, demand_id=md5(strategy+name+品类+type+dt)
+    """
+    if not rows:
+        return 0
+
+    china_today = _hive_partition_dt()
+    output_rows = build_dwd_multi_demand_pool_di_rows(rows=rows, partition_dt=china_today)
+    gap_parts = [
+        _build_hive_select_part(row)
+        for row in output_rows
+        if row.get("strategy") == _STRATEGY_GAP
+    ]
+    fenci_parts = [
+        _build_hive_select_part(row)
+        for row in output_rows
+        if row.get("strategy") == _STRATEGY_GAP_FENCI
+    ]
+
     if not gap_parts:
     if not gap_parts:
         return 0
         return 0
 
 
     _insert_hive_select_parts(gap_parts, china_today)
     _insert_hive_select_parts(gap_parts, china_today)
     _insert_hive_select_parts(fenci_parts, china_today)
     _insert_hive_select_parts(fenci_parts, china_today)
-    return len(gap_parts) + len(fenci_parts)
+    return len(output_rows)
+
+
+def build_feature_point_data_rows(names: list[str], dt: str) -> list[dict]:
+    """
+    将需求名称纯映射为 feature_point_data 输出行。
+
+    不执行 Hive 写入;字段保持当前 Hive 表写入逻辑。
+    """
+    normalized_names = [str(name).strip() for name in names if name is not None and str(name).strip()]
+    return [
+        {
+            "特征点": name,
+            "总分发曝光pv": 5000,
+            "质bn_rovn": 0.1,
+            "dt": dt,
+        }
+        for name in normalized_names
+    ]
 
 
 
 
 def write_feature_point_data_to_hive(names: list[str]) -> int:
 def write_feature_point_data_to_hive(names: list[str]) -> int:
@@ -191,14 +250,14 @@ def write_feature_point_data_to_hive(names: list[str]) -> int:
     - 总分发曝光pv(固定 5000)
     - 总分发曝光pv(固定 5000)
     - 质bn_rovn(固定 0.1)
     - 质bn_rovn(固定 0.1)
     """
     """
-    normalized_names = [str(name).strip() for name in names if name is not None and str(name).strip()]
-    if not normalized_names:
+    dt = _hive_partition_dt()
+    output_rows = build_feature_point_data_rows(names=names, dt=dt)
+    if not output_rows:
         return 0
         return 0
 
 
-    dt = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
     select_parts = []
     select_parts = []
-    for name in normalized_names:
-        safe_name = name.replace("'", "''")
+    for row in output_rows:
+        safe_name = _escape_odps_string(row.get("特征点", ""))
         select_parts.append(
         select_parts.append(
             "SELECT "
             "SELECT "
             f"'{safe_name}' AS `特征点`, "
             f"'{safe_name}' AS `特征点`, "
@@ -216,7 +275,7 @@ PARTITION (dt='{dt}')
     ok = execute_odps_sql(insert_sql)
     ok = execute_odps_sql(insert_sql)
     if not ok:
     if not ok:
         return 0
         return 0
-    return len(normalized_names)
+    return len(output_rows)
 
 
 
 
 def get_demand_merge_level2_names():
 def get_demand_merge_level2_names():
@@ -794,6 +853,7 @@ def get_merge_leve2_by_video_ids(video_ids, batch_size=2000):
 
 
 
 
 def get_all_decode_task_result_rows():
 def get_all_decode_task_result_rows():
+    mysql_db = _get_mysql_db()
     return mysql_db.select(
     return mysql_db.select(
         "workflow_decode_task_result",
         "workflow_decode_task_result",
         columns="id, channel_content_id, merge_leve2",
         columns="id, channel_content_id, merge_leve2",
@@ -801,6 +861,7 @@ def get_all_decode_task_result_rows():
 
 
 
 
 def update_decode_task_result_merge_leve2(channel_content_id, merge_leve2):
 def update_decode_task_result_merge_leve2(channel_content_id, merge_leve2):
+    mysql_db = _get_mysql_db()
     return mysql_db.update(
     return mysql_db.update(
         "workflow_decode_task_result",
         "workflow_decode_task_result",
         {"merge_leve2": str(merge_leve2)},
         {"merge_leve2": str(merge_leve2)},

+ 445 - 1
examples/demand/db_manager.py

@@ -1,4 +1,6 @@
-from typing import Iterable
+import json
+from datetime import date, datetime
+from typing import Any, Iterable, Mapping, Sequence
 
 
 from sqlalchemy import bindparam, create_engine, and_, or_, desc, text
 from sqlalchemy import bindparam, create_engine, and_, or_, desc, text
 from sqlalchemy.orm import sessionmaker, Session
 from sqlalchemy.orm import sessionmaker, Session
@@ -120,6 +122,448 @@ def query_category_level(execution_id: int, name: str) -> int | None:
     finally:
     finally:
         session.close()
         session.close()
 
 
+
+def _serialize_db_value(value: Any) -> Any:
+    if isinstance(value, (datetime, date)):
+        return value.isoformat()
+    return value
+
+
+def _row_to_dict(row: Any) -> dict[str, Any]:
+    if row is None:
+        return {}
+    mapping = getattr(row, "_mapping", row)
+    return {key: _serialize_db_value(value) for key, value in dict(mapping).items()}
+
+
+def _jsonish_to_list(value: Any) -> list[Any]:
+    if value is None:
+        return []
+    if isinstance(value, list):
+        return value
+    if isinstance(value, (tuple, set)):
+        return list(value)
+    if isinstance(value, bytes):
+        value = value.decode("utf-8")
+    if isinstance(value, str):
+        raw = value.strip()
+        if not raw:
+            return []
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            return [part.strip() for part in raw.split(",") if part.strip()]
+        if isinstance(parsed, list):
+            return parsed
+        if parsed is None:
+            return []
+        return [parsed]
+    return [value]
+
+
+def _to_post_id_list(value: Any) -> list[str]:
+    post_ids: list[str] = []
+    seen: set[str] = set()
+    for item in _jsonish_to_list(value):
+        post_id = str(item).strip() if item is not None else ""
+        if post_id and post_id not in seen:
+            seen.add(post_id)
+            post_ids.append(post_id)
+    return post_ids
+
+
+def _to_int_list(values: Any) -> list[int]:
+    result: list[int] = []
+    seen: set[int] = set()
+    for value in _jsonish_to_list(values):
+        if value is None or value == "":
+            continue
+        try:
+            int_value = int(value)
+        except (TypeError, ValueError):
+            continue
+        if int_value not in seen:
+            seen.add(int_value)
+            result.append(int_value)
+    return result
+
+
+def _chunked(values: Sequence[Any], size: int = 500) -> Iterable[list[Any]]:
+    for i in range(0, len(values), size):
+        yield list(values[i:i + size])
+
+
+def _quote_mysql_identifier(identifier: str) -> str:
+    return "`" + str(identifier).replace("`", "``") + "`"
+
+
+def _resolve_table_ref(session: Session, table_name: str) -> str | None:
+    row = session.execute(
+        text(
+            """
+            SELECT
+                table_schema AS resolved_table_schema,
+                table_name AS resolved_table_name
+            FROM information_schema.tables
+            WHERE table_name = :table_name
+            ORDER BY
+              CASE
+                WHEN table_schema = DATABASE() THEN 0
+                WHEN table_schema = 'content-deconstruction-supply' THEN 1
+                ELSE 2
+              END,
+              table_schema
+            LIMIT 1
+            """
+        ),
+        {"table_name": table_name},
+    ).mappings().first()
+    if not row:
+        return None
+    return (
+        f"{_quote_mysql_identifier(row['resolved_table_schema'])}."
+        f"{_quote_mysql_identifier(row['resolved_table_name'])}"
+    )
+
+
+def query_execution_for_evidence(execution_id: int) -> dict[str, Any] | None:
+    """只读查询 Pattern execution 基础信息,用于 evidence_pack 校验。"""
+    manager = DatabaseManager()
+    session = manager.get_session()
+    try:
+        row = session.execute(
+            text(
+                """
+                SELECT
+                    id,
+                    cluster_name,
+                    merge_leve2,
+                    platform,
+                    account_name,
+                    post_limit,
+                    min_absolute_support,
+                    classify_execution_id,
+                    mining_configs,
+                    post_count,
+                    itemset_count,
+                    status,
+                    error_message,
+                    start_time,
+                    end_time
+                FROM topic_pattern_execution
+                WHERE id = :execution_id
+                LIMIT 1
+                """
+            ),
+            {"execution_id": int(execution_id)},
+        ).mappings().first()
+        if not row:
+            return None
+        result = _row_to_dict(row)
+        result["mining_configs"] = _jsonish_to_list(result.get("mining_configs"))
+        return result
+    finally:
+        session.close()
+
+
+def query_itemset_evidence(execution_id: int, itemset_ids: Iterable[Any]) -> list[dict[str, Any]]:
+    """只读查询当前 execution 下的 itemset 事实。"""
+    clean_ids = _to_int_list(itemset_ids or [])
+    if not clean_ids:
+        return []
+
+    manager = DatabaseManager()
+    session = manager.get_session()
+    try:
+        rows = session.execute(
+            text(
+                """
+                SELECT
+                    i.id,
+                    i.execution_id,
+                    i.mining_config_id,
+                    cfg.execution_id AS mining_config_execution_id,
+                    cfg.dimension_mode AS mining_config_dimension_mode,
+                    cfg.target_depth AS mining_config_target_depth,
+                    i.combination_type,
+                    i.item_count,
+                    i.support,
+                    i.absolute_support,
+                    i.dimensions,
+                    i.is_cross_point,
+                    i.matched_post_ids
+                FROM topic_pattern_itemset i
+                JOIN topic_pattern_mining_config cfg
+                  ON cfg.id = i.mining_config_id
+                 AND cfg.execution_id = :execution_id
+                WHERE i.execution_id = :execution_id
+                  AND i.id IN :itemset_ids
+                ORDER BY i.id
+                """
+            ).bindparams(bindparam("itemset_ids", expanding=True)),
+            {
+                "execution_id": int(execution_id),
+                "itemset_ids": clean_ids,
+            },
+        ).mappings().all()
+
+        result: list[dict[str, Any]] = []
+        for row in rows:
+            itemset = _row_to_dict(row)
+            itemset["id"] = int(itemset["id"])
+            itemset["execution_id"] = int(itemset["execution_id"])
+            itemset["mining_config_id"] = (
+                int(itemset["mining_config_id"])
+                if itemset.get("mining_config_id") is not None
+                else None
+            )
+            itemset["mining_config_execution_id"] = (
+                int(itemset["mining_config_execution_id"])
+                if itemset.get("mining_config_execution_id") is not None
+                else None
+            )
+            itemset["item_count"] = (
+                int(itemset["item_count"])
+                if itemset.get("item_count") is not None
+                else None
+            )
+            itemset["absolute_support"] = (
+                int(itemset["absolute_support"])
+                if itemset.get("absolute_support") is not None
+                else None
+            )
+            itemset["support"] = (
+                float(itemset["support"])
+                if itemset.get("support") is not None
+                else None
+            )
+            itemset["is_cross_point"] = bool(itemset.get("is_cross_point"))
+            itemset["dimensions"] = _jsonish_to_list(itemset.get("dimensions"))
+            itemset["matched_post_ids"] = _to_post_id_list(itemset.get("matched_post_ids"))
+            result.append(itemset)
+        by_id = {itemset["id"]: itemset for itemset in result}
+        return [by_id[itemset_id] for itemset_id in clean_ids if itemset_id in by_id]
+    finally:
+        session.close()
+
+
+def query_itemset_items_with_categories(
+        execution_id: int,
+        itemset_ids: Iterable[Any],
+) -> list[dict[str, Any]]:
+    """只读查询 itemset item,并校验其 category 属于当前 execution。"""
+    clean_ids = _to_int_list(itemset_ids or [])
+    if not clean_ids:
+        return []
+
+    manager = DatabaseManager()
+    session = manager.get_session()
+    try:
+        rows = session.execute(
+            text(
+                """
+                SELECT
+                    i.id AS itemset_item_id,
+                    i.itemset_id,
+                    i.point_type,
+                    i.dimension,
+                    i.category_id,
+                    i.category_path,
+                    i.element_name,
+                    c.id AS bound_category_id,
+                    c.execution_id AS category_execution_id,
+                    c.source_type AS category_source_type,
+                    c.name AS category_name,
+                    c.path AS category_full_path,
+                    c.level AS category_level,
+                    c.parent_id AS category_parent_id
+                FROM topic_pattern_itemset_item i
+                JOIN topic_pattern_itemset s
+                  ON s.id = i.itemset_id
+                 AND s.execution_id = :execution_id
+                LEFT JOIN topic_pattern_category c
+                  ON c.id = i.category_id
+                 AND c.execution_id = :execution_id
+                WHERE i.itemset_id IN :itemset_ids
+                ORDER BY i.itemset_id, i.id
+                """
+            ).bindparams(bindparam("itemset_ids", expanding=True)),
+            {"execution_id": int(execution_id), "itemset_ids": clean_ids},
+        ).mappings().all()
+
+        result: list[dict[str, Any]] = []
+        for row in rows:
+            item = _row_to_dict(row)
+            for key in (
+                    "itemset_item_id",
+                    "itemset_id",
+                    "category_id",
+                    "bound_category_id",
+                    "category_execution_id",
+                    "category_level",
+                    "category_parent_id",
+            ):
+                if item.get(key) is not None:
+                    item[key] = int(item[key])
+            item["category_found"] = item.get("bound_category_id") is not None
+            result.append(item)
+        return result
+    finally:
+        session.close()
+
+
+def query_element_bindings_for_items(
+        execution_id: int,
+        itemset_items: Sequence[Mapping[str, Any]],
+        post_ids: Iterable[Any] | None = None,
+        limit_per_item: int = 200,
+) -> list[dict[str, Any]]:
+    """只读按 itemset item 精确绑定 topic_pattern_element。"""
+    clean_post_ids = _to_post_id_list(post_ids)
+    if not itemset_items:
+        return []
+
+    manager = DatabaseManager()
+    session = manager.get_session()
+    try:
+        bindings: list[dict[str, Any]] = []
+        for raw_item in itemset_items:
+            item = dict(raw_item)
+            category_id = item.get("bound_category_id") or item.get("category_id")
+            if category_id is None:
+                continue
+
+            clauses = [
+                "execution_id = :execution_id",
+                "category_id = :category_id",
+            ]
+            params: dict[str, Any] = {
+                "execution_id": int(execution_id),
+                "category_id": int(category_id),
+            }
+
+            if item.get("point_type"):
+                clauses.append("point_type = :point_type")
+                params["point_type"] = item["point_type"]
+            if item.get("dimension"):
+                clauses.append("element_type = :dimension")
+                params["dimension"] = item["dimension"]
+            if item.get("element_name"):
+                clauses.append("name = :element_name")
+                params["element_name"] = item["element_name"]
+            if clean_post_ids:
+                clauses.append("post_id IN :post_ids")
+                params["post_ids"] = clean_post_ids
+
+            where_sql = " AND ".join(clauses)
+            count_stmt = text(
+                f"""
+                SELECT
+                    COUNT(*) AS matched_element_count,
+                    COUNT(DISTINCT post_id) AS matched_post_count
+                FROM topic_pattern_element
+                WHERE {where_sql}
+                """
+            )
+            posts_stmt = text(
+                f"""
+                SELECT DISTINCT post_id
+                FROM topic_pattern_element
+                WHERE {where_sql}
+                ORDER BY post_id
+                LIMIT :limit_per_item
+                """
+            )
+            samples_stmt = text(
+                f"""
+                SELECT
+                    id,
+                    post_id,
+                    point_type,
+                    point_text,
+                    element_type,
+                    name,
+                    category_id,
+                    category_path
+                FROM topic_pattern_element
+                WHERE {where_sql}
+                ORDER BY post_id, id
+                LIMIT :sample_limit
+                """
+            )
+            if clean_post_ids:
+                count_stmt = count_stmt.bindparams(bindparam("post_ids", expanding=True))
+                posts_stmt = posts_stmt.bindparams(bindparam("post_ids", expanding=True))
+                samples_stmt = samples_stmt.bindparams(bindparam("post_ids", expanding=True))
+
+            count_row = session.execute(count_stmt, params).mappings().first() or {}
+            post_rows = session.execute(
+                posts_stmt,
+                {**params, "limit_per_item": int(limit_per_item)},
+            ).mappings().all()
+            sample_rows = session.execute(
+                samples_stmt,
+                {**params, "sample_limit": min(int(limit_per_item), 20)},
+            ).mappings().all()
+
+            bindings.append(
+                {
+                    "itemset_id": item.get("itemset_id"),
+                    "itemset_item_id": item.get("itemset_item_id"),
+                    "category_id": int(category_id),
+                    "point_type": item.get("point_type"),
+                    "dimension": item.get("dimension"),
+                    "element_name": item.get("element_name"),
+                    "matched_element_count": int(count_row.get("matched_element_count") or 0),
+                    "matched_post_count": int(count_row.get("matched_post_count") or 0),
+                    "matched_post_ids": _to_post_id_list([row["post_id"] for row in post_rows]),
+                    "sample_elements": [_row_to_dict(row) for row in sample_rows],
+                }
+            )
+        return bindings
+    finally:
+        session.close()
+
+
+def query_case_ids_by_post_ids(post_ids: Iterable[Any]) -> list[dict[str, Any]]:
+    """只读按 post_id 补齐 workflow_decode_task_result.id。"""
+    clean_post_ids = _to_post_id_list(post_ids)
+    if not clean_post_ids:
+        return []
+
+    manager = DatabaseManager()
+    session = manager.get_session()
+    try:
+        table_ref = _resolve_table_ref(session, "workflow_decode_task_result")
+        if not table_ref:
+            return []
+
+        result: list[dict[str, Any]] = []
+        for chunk in _chunked(clean_post_ids):
+            rows = session.execute(
+                text(
+                    f"""
+                    SELECT
+                        id AS case_id,
+                        channel_content_id AS post_id
+                    FROM {table_ref}
+                    WHERE channel_content_id IN :post_ids
+                    ORDER BY id
+                    """
+                ).bindparams(bindparam("post_ids", expanding=True)),
+                {"post_ids": chunk},
+            ).mappings().all()
+            for row in rows:
+                item = _row_to_dict(row)
+                if item.get("case_id") is not None:
+                    item["case_id"] = int(item["case_id"])
+                if item.get("post_id") is not None:
+                    item["post_id"] = str(item["post_id"]).strip()
+                result.append(item)
+        return result
+    finally:
+        session.close()
+
 db2 = DatabaseManager2()
 db2 = DatabaseManager2()
 
 
 
 

+ 27 - 0
examples/demand/demand.md

@@ -58,6 +58,31 @@ $system$
 - `reason`: 产生该需求的理由
 - `reason`: 产生该需求的理由
 - `desc`: 需求的描述
 - `desc`: 需求的描述
 - `type`: 需求的来源类型(元素/分类/关系/pattern)
 - `type`: 需求的来源类型(元素/分类/关系/pattern)
+- `evidence_refs`: 候选证据引用对象。LLM 只能填写从工具返回中看到的来源,不代表最终已通过 DB 校验。
+
+`evidence_refs` 推荐结构:
+
+```json
+{
+  "source_kind": "pattern_itemset",
+  "source_tool": "get_itemset_detail",
+  "itemset_ids": [123],
+  "category_ids": [456],
+  "source_post_id": "55157577",
+  "case_ids": {
+    "pattern_itemset": ["55157577"]
+  },
+  "seed_terms": ["祝福词句"]
+}
+```
+
+约束:
+
+- `source_kind` 只能按真实来源填写:`element` / `category` / `co_occurrence` / `pattern_itemset`
+- `source_tool` 必须是支撑本条需求的主要工具名
+- `source_post_id` 必须来自 `get_itemset_detail` 返回的 `post_ids` 或 `get_post_elements` 查询过的帖子;没有明确帖子时不要编造
+- `case_ids` 按 `source_kind` 分组,例如 `{"pattern_itemset": ["post_id"]}`;没有 Case seed 时填空对象或对应空数组
+- `itemset_ids/category_ids/seed_terms` 只能来自工具返回或当前 DemandItem 的真实元素/分类词,不允许凭空补 ID
 
 
 ## 工具概览
 ## 工具概览
 
 
@@ -87,6 +112,8 @@ $system$
 - category 级 item 必须来自分类树的真实节点(通过 `search_categories` 查到对应的 `category_id`),不允许凭空编造分类
 - category 级 item 必须来自分类树的真实节点(通过 `search_categories` 查到对应的 `category_id`),不允许凭空编造分类
 - 正确的创建顺序:先 element 后 category
 - 正确的创建顺序:先 element 后 category
 - result 中出现的每一个具体内容,都必须有对应的 DemandItem
 - result 中出现的每一个具体内容,都必须有对应的 DemandItem
+- 每个 DemandItem 都必须包含 `evidence_refs`;如果证据来自 pattern,必须优先用 `get_itemset_detail` 拿到 `itemset_ids`、`post_ids`,并把一个真实 `post_id` 写入 `source_post_id`
+- `evidence_refs` 是候选引用,不要写 `source_certainty=db_validated` 或 `validation_status=passed`,最终校验由代码完成
 - `search_elements` / `search_categories`只能用于查询单元素/单分类,不能用于查询完整树,完整树查询用`get_category_tree`
 - `search_elements` / `search_categories`只能用于查询单元素/单分类,不能用于查询完整树,完整树查询用`get_category_tree`
 
 
 $user$
 $user$

+ 46 - 17
examples/demand/demand_build_agent_tools.py

@@ -1,25 +1,48 @@
 import json
 import json
+import os
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
 
 
 from agent import tool
 from agent import tool
 from examples.demand.demand_agent_context import TopicBuildAgentContext
 from examples.demand.demand_agent_context import TopicBuildAgentContext
-from examples.demand.demand_pattern_tools import _log_tool_output, _log_tool_input
+from examples.demand.tool_logging import log_tool_input, log_tool_output
 
 
 
 
 def _get_result_base_dir() -> Path:
 def _get_result_base_dir() -> Path:
-    """输出到“当前工作目录/result/”下。"""
+    """输出到结果目录;local_json 模式可通过环境变量重定向。"""
+    metadata_dir = TopicBuildAgentContext.get_metadata("result_base_dir")
+    if metadata_dir:
+        return Path(metadata_dir)
+    redirected = os.getenv("DEMAND_RESULT_BASE_DIR")
+    if redirected:
+        return Path(redirected)
     return Path.cwd() / "result"
     return Path.cwd() / "result"
 
 
 
 
+def _normalize_evidence_refs(evidence_refs: Any) -> Dict[str, Any]:
+    if evidence_refs is None:
+        return {}
+    if isinstance(evidence_refs, dict):
+        return evidence_refs
+    if isinstance(evidence_refs, str) and evidence_refs.strip():
+        try:
+            loaded = json.loads(evidence_refs)
+            if isinstance(loaded, dict):
+                return loaded
+        except json.JSONDecodeError:
+            pass
+    return {"raw": evidence_refs}
+
+
 @tool(
 @tool(
-    "存储需求到结果集。 - element_names - reason(原因)- desc(需求描述)- type(来源类型)"
+    "存储需求到结果集。 - element_names - reason(原因)- desc(需求描述)- type(来源类型)- evidence_refs(候选证据引用)"
 )
 )
 def create_demand_item(
 def create_demand_item(
         element_names: List[str] = None,
         element_names: List[str] = None,
         reason: str = None,
         reason: str = None,
         desc: str = None,
         desc: str = None,
-        type: str = None) -> str:
+        type: str = None,
+        evidence_refs: Optional[Dict[str, Any]] = None) -> str:
     """
     """
     每次调用向“execution_id 对应的本地 JSON 文件”追加一条记录。
     每次调用向“execution_id 对应的本地 JSON 文件”追加一条记录。
 
 
@@ -28,25 +51,29 @@ def create_demand_item(
       - reason(原因)
       - reason(原因)
       - desc(需求描述)
       - desc(需求描述)
       - type(来源类型)
       - type(来源类型)
+      - evidence_refs(候选证据引用,后续由 orchestration/校验逻辑决定是否能升级为 evidence_pack)
     """
     """
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
+    normalized_evidence_refs = _normalize_evidence_refs(evidence_refs)
     params: Dict[str, Any] = {
     params: Dict[str, Any] = {
         "execution_id": execution_id,
         "execution_id": execution_id,
         "element_names": element_names,
         "element_names": element_names,
         "reason": reason,
         "reason": reason,
         "desc": desc,
         "desc": desc,
         "type": type,
         "type": type,
+        "evidence_refs": normalized_evidence_refs,
     }
     }
-    _log_tool_input("create_demand_item", params)
+    log_tool_input("create_demand_item", params)
 
 
     if not execution_id:
     if not execution_id:
-        return _log_tool_output("create_demand_item", "错误: 未设置 execution_id")
+        return log_tool_output("create_demand_item", "错误: 未设置 execution_id")
 
 
     record: Dict[str, Any] = {
     record: Dict[str, Any] = {
         "element_names": element_names,
         "element_names": element_names,
         "reason": reason,
         "reason": reason,
         "desc": desc,
         "desc": desc,
-        "type": type
+        "type": type,
+        "evidence_refs": normalized_evidence_refs,
     }
     }
 
 
     # 按 execution_id 区分文件,避免不同执行互相污染。
     # 按 execution_id 区分文件,避免不同执行互相污染。
@@ -80,11 +107,11 @@ def create_demand_item(
         {"success": True, "execution_id": execution_id, "written_to": str(output_path)},
         {"success": True, "execution_id": execution_id, "written_to": str(output_path)},
         ensure_ascii=False,
         ensure_ascii=False,
     )
     )
-    return _log_tool_output("create_demand_item", result)
+    return log_tool_output("create_demand_item", result)
 
 
 
 
 @tool(
 @tool(
-    "批量存储需求到结果集。 - element_names - reason(原因)- desc(需求描述)- type(来源类型)"
+    "批量存储需求到结果集。 - element_names - reason(原因)- desc(需求描述)- type(来源类型)- evidence_refs(候选证据引用)"
 )
 )
 def create_demand_items(demand_items: List[Dict[str, Any]] = None) -> str:
 def create_demand_items(demand_items: List[Dict[str, Any]] = None) -> str:
     """
     """
@@ -95,17 +122,18 @@ def create_demand_items(demand_items: List[Dict[str, Any]] = None) -> str:
       - reason(原因)
       - reason(原因)
       - desc(需求描述)
       - desc(需求描述)
       - type(来源类型)
       - type(来源类型)
+      - evidence_refs(候选证据引用)
     """
     """
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
     params: Dict[str, Any] = {"execution_id": execution_id, "count": len(demand_items or []),
     params: Dict[str, Any] = {"execution_id": execution_id, "count": len(demand_items or []),
                               "demand_items": demand_items}
                               "demand_items": demand_items}
-    _log_tool_input("create_demand_items", params)
+    log_tool_input("create_demand_items", params)
 
 
     if not execution_id:
     if not execution_id:
-        return _log_tool_output("create_demand_items", "错误: 未设置 execution_id")
+        return log_tool_output("create_demand_items", "错误: 未设置 execution_id")
 
 
     if not demand_items or not isinstance(demand_items, list):
     if not demand_items or not isinstance(demand_items, list):
-        return _log_tool_output("create_demand_items", "错误: demand_items 必须为非空列表")
+        return log_tool_output("create_demand_items", "错误: demand_items 必须为非空列表")
 
 
     output_dir = _get_result_base_dir() / f"{execution_id}"
     output_dir = _get_result_base_dir() / f"{execution_id}"
     output_path = output_dir / f"execution_id_{execution_id}_demand_items.json"
     output_path = output_dir / f"execution_id_{execution_id}_demand_items.json"
@@ -128,12 +156,13 @@ def create_demand_items(demand_items: List[Dict[str, Any]] = None) -> str:
     written_records: List[Dict[str, Any]] = []
     written_records: List[Dict[str, Any]] = []
     for i, di in enumerate(demand_items):
     for i, di in enumerate(demand_items):
         if not isinstance(di, dict):
         if not isinstance(di, dict):
-            return _log_tool_output("create_demand_items", f"错误: demand_items[{i}] 必须为对象(dict)")
+            return log_tool_output("create_demand_items", f"错误: demand_items[{i}] 必须为对象(dict)")
         record = {
         record = {
             "element_names": di.get("element_names"),
             "element_names": di.get("element_names"),
             "reason": di.get("reason"),
             "reason": di.get("reason"),
             "desc": di.get("desc"),
             "desc": di.get("desc"),
             "type": di.get("type"),
             "type": di.get("type"),
+            "evidence_refs": _normalize_evidence_refs(di.get("evidence_refs")),
         }
         }
         written_records.append(record)
         written_records.append(record)
 
 
@@ -150,7 +179,7 @@ def create_demand_items(demand_items: List[Dict[str, Any]] = None) -> str:
         },
         },
         ensure_ascii=False,
         ensure_ascii=False,
     )
     )
-    return _log_tool_output("create_demand_items", result)
+    return log_tool_output("create_demand_items", result)
 
 
 
 
 @tool(
 @tool(
@@ -170,8 +199,8 @@ def write_execution_summary(summary: str) -> str:
     """
     """
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
     params: Dict[str, str] = {"summary": summary}
     params: Dict[str, str] = {"summary": summary}
-    _log_tool_input("write_execution_summary", params)
+    log_tool_input("write_execution_summary", params)
     if not execution_id:
     if not execution_id:
-        return _log_tool_output("write_execution_summary", "错误: 未设置 execution_id")
+        return log_tool_output("write_execution_summary", "错误: 未设置 execution_id")
     result = json.dumps({"success": True, "execution_id": execution_id}, ensure_ascii=False)
     result = json.dumps({"success": True, "execution_id": execution_id}, ensure_ascii=False)
-    return _log_tool_output("write_execution_summary", result)
+    return log_tool_output("write_execution_summary", result)

+ 566 - 0
examples/demand/evidence_pack_builder.py

@@ -0,0 +1,566 @@
+"""Build DB-validated evidence packs for DemandAgent outputs."""
+
+from __future__ import annotations
+
+import json
+import re
+from collections import defaultdict
+from collections.abc import Iterable, Mapping
+from typing import Any
+
+from examples.demand.db_manager import (
+    query_case_ids_by_post_ids,
+    query_element_bindings_for_items,
+    query_execution_for_evidence,
+    query_itemset_evidence,
+    query_itemset_items_with_categories,
+)
+
+
+SOURCE_KIND_PATTERN_ITEMSET = "pattern_itemset"
+PATTERN_SOURCE_SYSTEM = "mysql_topic_pattern"
+
+
+def build_evidence_pack(
+        execution_id: int,
+        demand_item: Any,
+        trace_id: str,
+        demand_task_id: int | None,
+        demand_content_id: int | None,
+) -> dict[str, Any]:
+    """Return a DB-validated evidence pack or a reject result.
+
+    This function is intentionally read-only. It never writes Demand rows or
+    reject rows; callers can route the returned result into their own sink.
+    """
+    try:
+        return _build_evidence_pack(
+            execution_id=execution_id,
+            demand_item=demand_item,
+            trace_id=trace_id,
+            demand_task_id=demand_task_id,
+            demand_content_id=demand_content_id,
+        )
+    except Exception as exc:
+        return _reject(f"db evidence validation failed: {exc}")
+
+
+def _build_evidence_pack(
+        execution_id: int,
+        demand_item: Any,
+        trace_id: str,
+        demand_task_id: int | None,
+        demand_content_id: int | None,
+) -> dict[str, Any]:
+    evidence_refs = _extract_evidence_refs(demand_item)
+    itemset_ids, invalid_itemset_ids = _normalize_int_list(
+        _first_present(
+            evidence_refs.get("itemset_ids"),
+            evidence_refs.get("itemset_id"),
+            _read_field(demand_item, "itemset_ids"),
+            _read_field(demand_item, "itemset_id"),
+        )
+    )
+    if invalid_itemset_ids:
+        return _reject(f"invalid itemset_ids: {invalid_itemset_ids}")
+
+    source_kind = _resolve_source_kind(evidence_refs, demand_item, itemset_ids)
+    if source_kind and source_kind != SOURCE_KIND_PATTERN_ITEMSET:
+        return _reject(f"unsupported source_kind={source_kind}; only pattern_itemset is supported")
+    if not source_kind:
+        return _reject("missing source_kind=pattern_itemset")
+    if not itemset_ids:
+        return _reject("missing itemset_ids for source_kind=pattern_itemset")
+
+    execution = query_execution_for_evidence(int(execution_id))
+    if not execution:
+        return _reject(f"execution_id {execution_id} not found")
+    execution_status = _clean_str(execution.get("status")).lower()
+    if execution_status != "success":
+        return _reject(
+            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)
+    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:
+        return _reject(
+            f"itemset_id {missing_itemset_ids[0]} not found under execution_id {execution_id}"
+        )
+
+    invalid_itemset_reason = _validate_itemset_facts(itemsets)
+    if invalid_itemset_reason:
+        return _reject(invalid_itemset_reason)
+
+    mining_config_ids = _unique_ints(itemset.get("mining_config_id") for itemset in itemsets)
+    if len(mining_config_ids) != 1:
+        return _reject(
+            "itemset_ids must resolve to exactly one mining_config_id; "
+            f"got {mining_config_ids}"
+        )
+
+    itemset_items = query_itemset_items_with_categories(
+        execution_id=int(execution_id),
+        itemset_ids=itemset_ids,
+    )
+    items_by_itemset: dict[int, list[dict[str, Any]]] = defaultdict(list)
+    for item in itemset_items:
+        items_by_itemset[int(item["itemset_id"])].append(item)
+
+    item_reason = _validate_itemset_items(
+        execution_id=int(execution_id),
+        itemsets=itemsets,
+        items_by_itemset=items_by_itemset,
+    )
+    if item_reason:
+        return _reject(item_reason)
+
+    matched_post_ids = _merge_post_ids(itemset.get("matched_post_ids") for itemset in itemsets)
+    source_post_id = _choose_source_post_id(evidence_refs, demand_item, matched_post_ids)
+    if not source_post_id:
+        return _reject("source_post_id cannot be resolved from DB-validated matched_post_ids")
+    if source_post_id not in set(matched_post_ids):
+        return _reject(
+            f"source_post_id {source_post_id} is not in matched_post_ids for itemset_ids={itemset_ids}"
+        )
+
+    element_bindings = query_element_bindings_for_items(
+        execution_id=int(execution_id),
+        itemset_items=itemset_items,
+        post_ids=[source_post_id],
+    )
+    binding_reason = _validate_element_bindings(itemset_items, element_bindings)
+    if binding_reason:
+        return _reject(binding_reason)
+
+    seed_terms = _resolve_seed_terms(evidence_refs, itemset_items)
+    seed_reason = _validate_seed_terms(seed_terms, itemset_items, element_bindings)
+    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)
+
+    evidence_pack = {
+        "pattern_source_system": PATTERN_SOURCE_SYSTEM,
+        "pattern_execution_id": int(execution_id),
+        "mining_config_id": mining_config_ids[0],
+        "source_kind": SOURCE_KIND_PATTERN_ITEMSET,
+        "case_id_type": "post_id",
+        "source_post_id": source_post_id,
+        "category_bindings": _build_category_bindings(itemset_items),
+        "element_bindings": _build_element_bindings(element_bindings),
+        "itemset_ids": itemset_ids,
+        "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),
+        "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,
+        "trace_id": trace_id,
+        "demand_task_id": demand_task_id,
+        "demand_content_id": demand_content_id,
+        "source_certainty": "db_validated",
+        "validation_status": "passed",
+    }
+    return {"success": True, "evidence_pack": evidence_pack}
+
+
+def _reject(reason: str) -> dict[str, Any]:
+    return {"success": False, "reject_reason": reason}
+
+
+def _resolve_source_kind(
+        evidence_refs: Mapping[str, Any],
+        demand_item: Any,
+        itemset_ids: list[int],
+) -> str:
+    explicit = _clean_str(
+        _first_present(
+            evidence_refs.get("source_kind"),
+            _read_field(demand_item, "source_kind"),
+        )
+    )
+    if explicit:
+        return explicit
+
+    source_tool = _clean_str(evidence_refs.get("source_tool"))
+    if itemset_ids or source_tool == "get_itemset_detail":
+        return SOURCE_KIND_PATTERN_ITEMSET
+    return ""
+
+
+def _extract_evidence_refs(demand_item: Any) -> dict[str, Any]:
+    value = _read_field(demand_item, "evidence_refs", {})
+    if isinstance(value, str):
+        raw = value.strip()
+        if not raw:
+            return {}
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            return {}
+        return dict(parsed) if isinstance(parsed, Mapping) else {}
+    return dict(value) if isinstance(value, Mapping) else {}
+
+
+def _read_field(source: Any, field: str, default: Any = None) -> Any:
+    if source is None:
+        return default
+    if isinstance(source, Mapping):
+        return source.get(field, default)
+    if hasattr(source, field):
+        return getattr(source, field)
+    if hasattr(source, "model_dump"):
+        dumped = source.model_dump()
+        if isinstance(dumped, Mapping):
+            return dumped.get(field, default)
+    if hasattr(source, "dict"):
+        dumped = source.dict()
+        if isinstance(dumped, Mapping):
+            return dumped.get(field, default)
+    return default
+
+
+def _first_present(*values: Any) -> Any:
+    for value in values:
+        if value is None:
+            continue
+        if isinstance(value, str) and not value.strip():
+            continue
+        if isinstance(value, (list, tuple, set, dict)) and len(value) == 0:
+            continue
+        return value
+    return None
+
+
+def _clean_str(value: Any) -> str:
+    return str(value).strip() if value is not None else ""
+
+
+def _normalize_list(value: Any) -> list[Any]:
+    if value is None:
+        return []
+    if isinstance(value, list):
+        return value
+    if isinstance(value, (tuple, set)):
+        return list(value)
+    if isinstance(value, str):
+        raw = value.strip()
+        if not raw:
+            return []
+        try:
+            parsed = json.loads(raw)
+        except json.JSONDecodeError:
+            return [part.strip() for part in raw.split(",") if part.strip()]
+        if isinstance(parsed, list):
+            return parsed
+        if parsed is None:
+            return []
+        return [parsed]
+    return [value]
+
+
+def _normalize_int_list(value: Any) -> tuple[list[int], list[Any]]:
+    result: list[int] = []
+    invalid: list[Any] = []
+    seen: set[int] = set()
+    for raw in _normalize_list(value):
+        try:
+            parsed = int(raw)
+        except (TypeError, ValueError):
+            invalid.append(raw)
+            continue
+        if parsed not in seen:
+            seen.add(parsed)
+            result.append(parsed)
+    return result, invalid
+
+
+def _unique_ints(values: Iterable[Any]) -> list[int]:
+    result: list[int] = []
+    seen: set[int] = set()
+    for value in values:
+        if value is None:
+            continue
+        parsed = int(value)
+        if parsed not in seen:
+            seen.add(parsed)
+            result.append(parsed)
+    return result
+
+
+def _unique_strings(values: Iterable[Any]) -> list[str]:
+    result: list[str] = []
+    seen: set[str] = set()
+    for value in values:
+        text = _clean_str(value)
+        if text and text not in seen:
+            seen.add(text)
+            result.append(text)
+    return result
+
+
+def _validate_itemset_facts(itemsets: list[dict[str, Any]]) -> str | None:
+    for itemset in itemsets:
+        itemset_id = itemset.get("id")
+        execution_id = itemset.get("execution_id")
+        if itemset.get("mining_config_id") is None:
+            return f"itemset_id {itemset_id} missing mining_config_id"
+        if int(itemset.get("mining_config_execution_id") or -1) != int(execution_id):
+            return (
+                f"itemset_id {itemset_id} mining_config_id {itemset.get('mining_config_id')} "
+                f"does not belong to execution_id {execution_id}"
+            )
+        if itemset.get("support") is None:
+            return f"itemset_id {itemset_id} missing support"
+        if itemset.get("absolute_support") is None:
+            return f"itemset_id {itemset_id} missing absolute_support"
+        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"]):
+            return (
+                f"itemset_id {itemset_id} matched_post_ids count {len(matched_post_ids)} "
+                f"does not equal absolute_support {itemset['absolute_support']}"
+            )
+    return None
+
+
+def _validate_itemset_items(
+        execution_id: int,
+        itemsets: list[dict[str, Any]],
+        items_by_itemset: dict[int, list[dict[str, Any]]],
+) -> str | None:
+    for itemset in itemsets:
+        itemset_id = int(itemset["id"])
+        items = items_by_itemset.get(itemset_id, [])
+        if not items:
+            return f"itemset_id {itemset_id} has no itemset_items"
+
+        expected_count = itemset.get("item_count")
+        if expected_count is not None and int(expected_count) != len(items):
+            return (
+                f"itemset_id {itemset_id} item_count mismatch: "
+                f"expected {expected_count}, got {len(items)}"
+            )
+
+        for item in items:
+            category_id = item.get("category_id")
+            if category_id is None:
+                return f"itemset_id {itemset_id} has item without category_id"
+            if not item.get("category_found"):
+                return (
+                    f"category_id {category_id} for itemset_id {itemset_id} "
+                    f"not found under execution_id {execution_id}"
+                )
+            if int(item.get("category_execution_id")) != int(execution_id):
+                return (
+                    f"category_id {category_id} belongs to execution_id "
+                    f"{item.get('category_execution_id')}, not {execution_id}"
+                )
+    return None
+
+
+def _merge_post_ids(post_id_groups: Iterable[Any]) -> list[str]:
+    merged: list[str] = []
+    seen: set[str] = set()
+    for group in post_id_groups:
+        for raw_post_id in _normalize_list(group):
+            post_id = _clean_str(raw_post_id)
+            if post_id and post_id not in seen:
+                seen.add(post_id)
+                merged.append(post_id)
+    return merged
+
+
+def _choose_source_post_id(
+        evidence_refs: Mapping[str, Any],
+        demand_item: Any,
+        matched_post_ids: list[str],
+) -> str:
+    candidate = _clean_str(
+        _first_present(
+            evidence_refs.get("source_post_id"),
+            evidence_refs.get("post_id"),
+            _read_field(demand_item, "source_post_id"),
+            _read_field(demand_item, "post_id"),
+        )
+    )
+    if candidate:
+        return candidate
+
+    candidate_posts = _normalize_list(
+        _first_present(
+            evidence_refs.get("video_ids"),
+            evidence_refs.get("matched_post_ids"),
+            _read_field(demand_item, "video_ids"),
+        )
+    )
+    matched_set = set(matched_post_ids)
+    for raw_post_id in candidate_posts:
+        post_id = _clean_str(raw_post_id)
+        if post_id in matched_set:
+            return post_id
+    return matched_post_ids[0] if matched_post_ids else ""
+
+
+def _validate_element_bindings(
+        itemset_items: list[dict[str, Any]],
+        element_bindings: list[dict[str, Any]],
+) -> str | None:
+    by_item_id = {
+        int(binding["itemset_item_id"]): binding
+        for binding in element_bindings
+        if binding.get("itemset_item_id") is not None
+    }
+    for item in itemset_items:
+        item_id = int(item["itemset_item_id"])
+        binding = by_item_id.get(item_id)
+        if not binding:
+            return f"itemset_item_id {item_id} has no element binding"
+        if int(binding.get("matched_element_count") or 0) <= 0:
+            return f"itemset_item_id {item_id} has no exact topic_pattern_element binding"
+        if int(binding.get("matched_post_count") or 0) <= 0:
+            return f"itemset_item_id {item_id} has no matched post binding"
+    return None
+
+
+def _resolve_seed_terms(
+        evidence_refs: Mapping[str, Any],
+        itemset_items: list[dict[str, Any]],
+) -> list[str]:
+    provided = _unique_strings(_normalize_list(evidence_refs.get("seed_terms")))
+    if provided:
+        return provided
+
+    derived: list[str] = []
+    for item in itemset_items:
+        for value in (
+                item.get("element_name"),
+                item.get("category_name"),
+                _last_path_part(item.get("category_path")),
+                _last_path_part(item.get("category_full_path")),
+        ):
+            text = _clean_str(value)
+            if text and text not in derived:
+                derived.append(text)
+    return derived
+
+
+def _validate_seed_terms(
+        seed_terms: list[str],
+        itemset_items: list[dict[str, Any]],
+        element_bindings: list[dict[str, Any]],
+) -> str | None:
+    if not seed_terms:
+        return "seed_terms cannot be resolved from itemset evidence"
+
+    covered_terms = _build_covered_terms(itemset_items, element_bindings)
+    uncovered = [term for term in seed_terms if _normalize_term(term) not in covered_terms]
+    if uncovered:
+        return f"seed_terms not covered by DB evidence: {uncovered}"
+    return None
+
+
+def _build_covered_terms(
+        itemset_items: list[dict[str, Any]],
+        element_bindings: list[dict[str, Any]],
+) -> set[str]:
+    terms: set[str] = set()
+    for item in itemset_items:
+        for field in ("element_name", "category_name", "category_path", "category_full_path"):
+            _add_term_variants(terms, item.get(field))
+
+    for binding in element_bindings:
+        _add_term_variants(terms, binding.get("element_name"))
+        for sample in binding.get("sample_elements") or []:
+            _add_term_variants(terms, sample.get("name"))
+            _add_term_variants(terms, sample.get("category_path"))
+    return terms
+
+
+def _add_term_variants(terms: set[str], value: Any) -> None:
+    text = _clean_str(value)
+    if not text:
+        return
+    terms.add(_normalize_term(text))
+    for part in _split_pathish(text):
+        terms.add(_normalize_term(part))
+
+
+def _normalize_term(value: Any) -> str:
+    return re.sub(r"\s+", "", _clean_str(value))
+
+
+def _split_pathish(value: Any) -> list[str]:
+    text = _clean_str(value)
+    if not text:
+        return []
+    return [part.strip() for part in re.split(r"[>/|,,]+", text) if part.strip()]
+
+
+def _last_path_part(value: Any) -> str:
+    parts = _split_pathish(value)
+    return parts[-1] if parts else ""
+
+
+def _build_category_bindings(itemset_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    bindings: list[dict[str, Any]] = []
+    seen: set[tuple[int, int | None]] = set()
+    for item in itemset_items:
+        key = (int(item["category_id"]), item.get("itemset_id"))
+        if key in seen:
+            continue
+        seen.add(key)
+        bindings.append(
+            {
+                "itemset_id": item.get("itemset_id"),
+                "itemset_item_id": item.get("itemset_item_id"),
+                "category_id": item.get("category_id"),
+                "category_name": item.get("category_name"),
+                "category_path": item.get("category_path") or item.get("category_full_path"),
+                "category_full_path": item.get("category_full_path"),
+                "category_source_type": item.get("category_source_type"),
+                "category_level": item.get("category_level"),
+                "point_type": item.get("point_type"),
+                "dimension": item.get("dimension"),
+                "element_name": item.get("element_name"),
+            }
+        )
+    return bindings
+
+
+def _build_element_bindings(element_bindings: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    return [
+        {
+            "itemset_id": binding.get("itemset_id"),
+            "itemset_item_id": binding.get("itemset_item_id"),
+            "category_id": binding.get("category_id"),
+            "point_type": binding.get("point_type"),
+            "dimension": binding.get("dimension"),
+            "element_name": binding.get("element_name"),
+            "matched_element_count": binding.get("matched_element_count"),
+            "matched_post_count": binding.get("matched_post_count"),
+            "matched_post_ids": binding.get("matched_post_ids") or [],
+            "sample_elements": binding.get("sample_elements") or [],
+        }
+        for binding in element_bindings
+    ]
+
+
+def _build_itemset_items(itemset_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    return [
+        {
+            "itemset_id": item.get("itemset_id"),
+            "category_id": item.get("category_id"),
+            "category_path": item.get("category_path") or item.get("category_full_path"),
+            "point_type": item.get("point_type"),
+            "dimension": item.get("dimension"),
+            "element_name": item.get("element_name"),
+        }
+        for item in itemset_items
+    ]

+ 175 - 0
examples/demand/local_output_sink.py

@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from examples.demand.data_query_tools import (
+    build_dwd_multi_demand_pool_di_rows,
+    build_feature_point_data_rows,
+)
+
+
+OUTPUT_FILENAMES = {
+    "run_manifest": "run_manifest.json",
+    "demand_task": "demand_task.json",
+    "demand_items": "demand_items.json",
+    "rejected_demand_items": "rejected_demand_items.json",
+    "demand_content": "demand_content.json",
+    "dwd_multi_demand_pool_di": "dwd_multi_demand_pool_di.json",
+    "feature_point_data": "feature_point_data.json",
+}
+
+_CHINA_TZ = ZoneInfo("Asia/Shanghai")
+_DT_FMT = "%Y%m%d"
+
+
+def _today_dt() -> str:
+    return datetime.now(_CHINA_TZ).strftime(_DT_FMT)
+
+
+def _as_list(value: Any) -> list[Any]:
+    if value is None:
+        return []
+    if isinstance(value, list):
+        return value
+    if isinstance(value, tuple):
+        return list(value)
+    return [value]
+
+
+def _normalize_demand_content_row(row: Any) -> Any:
+    if not isinstance(row, dict):
+        return row
+
+    normalized = dict(row)
+    ext_data = normalized.get("ext_data")
+    if isinstance(ext_data, str) and ext_data.strip():
+        try:
+            normalized["ext_data"] = json.loads(ext_data)
+        except json.JSONDecodeError:
+            pass
+    return normalized
+
+
+def _write_json(path: Path, payload: Any) -> Path:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    tmp_path = path.with_name(f".{path.name}.tmp")
+    tmp_path.write_text(
+        json.dumps(payload, ensure_ascii=False, indent=2, default=str) + "\n",
+        encoding="utf-8",
+    )
+    tmp_path.replace(path)
+    return path
+
+
+class LocalOutputSink:
+    """Writes DemandAgent local-json output mirrors for one run."""
+
+    def __init__(self, output_dir: str | Path):
+        self.output_dir = Path(output_dir)
+
+    @classmethod
+    def for_run(cls, base_dir: str | Path, run_id: str | int) -> "LocalOutputSink":
+        return cls(Path(base_dir) / str(run_id))
+
+    def path_for(self, name: str) -> Path:
+        try:
+            filename = OUTPUT_FILENAMES[name]
+        except KeyError as exc:
+            raise ValueError(f"unknown local output name: {name}") from exc
+        return self.output_dir / filename
+
+    def default_run_manifest(self) -> dict[str, Any]:
+        return {
+            "output_mode": "local_json",
+            "output_dir": str(self.output_dir),
+            "created_at": datetime.now(_CHINA_TZ).isoformat(),
+        }
+
+    def initialize(self, run_manifest: dict[str, Any] | None = None) -> dict[str, Path]:
+        return self.write_all(run_manifest=run_manifest)
+
+    def write_run_manifest(self, payload: dict[str, Any] | None = None) -> Path:
+        if payload is None:
+            payload = self.default_run_manifest()
+        return _write_json(self.path_for("run_manifest"), payload)
+
+    def write_demand_task(self, payload: Any = None) -> Path:
+        if payload is None:
+            payload = {}
+        return _write_json(self.path_for("demand_task"), payload)
+
+    def write_demand_items(self, items: Any = None) -> Path:
+        return _write_json(self.path_for("demand_items"), _as_list(items))
+
+    def write_rejected_demand_items(self, items: Any = None) -> Path:
+        return _write_json(self.path_for("rejected_demand_items"), _as_list(items))
+
+    def write_demand_content(self, rows: Any = None) -> Path:
+        normalized_rows = [_normalize_demand_content_row(row) for row in _as_list(rows)]
+        return _write_json(self.path_for("demand_content"), normalized_rows)
+
+    def write_dwd_multi_demand_pool_di(self, rows: Any = None) -> Path:
+        return _write_json(self.path_for("dwd_multi_demand_pool_di"), _as_list(rows))
+
+    def write_dwd_multi_demand_pool_di_from_demand_content(
+            self,
+            rows: list[dict],
+            partition_dt: str | None = None,
+    ) -> Path:
+        output_rows = build_dwd_multi_demand_pool_di_rows(rows=rows, partition_dt=partition_dt or _today_dt())
+        return self.write_dwd_multi_demand_pool_di(output_rows)
+
+    def write_feature_point_data(self, rows: Any = None) -> Path:
+        return _write_json(self.path_for("feature_point_data"), _as_list(rows))
+
+    def write_feature_point_data_from_names(self, names: list[str], dt: str | None = None) -> Path:
+        output_rows = build_feature_point_data_rows(names=names, dt=dt or _today_dt())
+        return self.write_feature_point_data(output_rows)
+
+    def write_all(
+            self,
+            *,
+            run_manifest: dict[str, Any] | None = None,
+            demand_task: Any = None,
+            demand_items: Any = None,
+            rejected_demand_items: Any = None,
+            demand_content_rows: Any = None,
+            dwd_multi_demand_pool_di_rows: Any = None,
+            feature_point_data_rows: Any = None,
+    ) -> dict[str, Path]:
+        paths = {
+            "run_manifest": self.write_run_manifest(run_manifest),
+            "demand_task": self.write_demand_task(demand_task),
+            "demand_items": self.write_demand_items(demand_items),
+            "rejected_demand_items": self.write_rejected_demand_items(rejected_demand_items),
+            "demand_content": self.write_demand_content(demand_content_rows),
+            "dwd_multi_demand_pool_di": self.write_dwd_multi_demand_pool_di(dwd_multi_demand_pool_di_rows),
+            "feature_point_data": self.write_feature_point_data(feature_point_data_rows),
+        }
+        return paths
+
+
+def write_local_outputs(
+        output_dir: str | Path,
+        *,
+        run_manifest: dict[str, Any] | None = None,
+        demand_task: Any = None,
+        demand_items: Any = None,
+        rejected_demand_items: Any = None,
+        demand_content_rows: Any = None,
+        dwd_multi_demand_pool_di_rows: Any = None,
+        feature_point_data_rows: Any = None,
+) -> dict[str, Path]:
+    return LocalOutputSink(output_dir).write_all(
+        run_manifest=run_manifest,
+        demand_task=demand_task,
+        demand_items=demand_items,
+        rejected_demand_items=rejected_demand_items,
+        demand_content_rows=demand_content_rows,
+        dwd_multi_demand_pool_di_rows=dwd_multi_demand_pool_di_rows,
+        feature_point_data_rows=feature_point_data_rows,
+    )

+ 647 - 134
examples/demand/run.py

@@ -1,27 +1,16 @@
 """demand 示例的最小可运行入口。"""
 """demand 示例的最小可运行入口。"""
 import asyncio
 import asyncio
 import copy
 import copy
+import hashlib
 import importlib
 import importlib
 import json
 import json
 import os
 import os
 import sys
 import sys
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
-from typing import Optional
+from typing import Any, Optional
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
-from dotenv import load_dotenv
-from sqlalchemy import desc, or_
-
-from examples.demand.changwen_prepare import changwen_prepare
-from examples.demand.config import LOG_LEVEL, ENABLED_TOOLS
-from examples.demand.db_manager import DatabaseManager, query_video_ids_by_names, query_category_level
-from examples.demand.models import TopicPatternExecution
-from examples.demand.piaoquan_prepare import prepare, piaoquan_prepare
-from examples.demand.demand_agent_context import TopicBuildAgentContext
-from examples.demand.mysql import mysql_db
-from examples.demand.zengzhang_prepare import zengzhang_prepare
-
 # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
 # Clash Verge TUN 模式兼容:禁止 httpx/urllib 自动检测系统 HTTP 代理
 os.environ.setdefault("no_proxy", "*")
 os.environ.setdefault("no_proxy", "*")
 # 该示例仅使用项目侧能力,禁用框架内置 skills
 # 该示例仅使用项目侧能力,禁用框架内置 skills
@@ -32,21 +21,20 @@ os.environ.setdefault("AGENT_STRICT_TOOL_SELECTION", "1")
 # 禁用所有侧分支(压缩/反思)
 # 禁用所有侧分支(压缩/反思)
 os.environ.setdefault("AGENT_DISABLE_SIDE_BRANCHES", "1")
 os.environ.setdefault("AGENT_DISABLE_SIDE_BRANCHES", "1")
 
 
+from dotenv import load_dotenv
+from sqlalchemy import desc
+
+from examples.demand.db_manager import DatabaseManager, query_video_ids_by_names, query_category_level
+from examples.demand.models import TopicPatternExecution
+from examples.demand.demand_agent_context import TopicBuildAgentContext
+
 # 添加项目根目录到 Python 路径
 # 添加项目根目录到 Python 路径
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 
 
 load_dotenv()
 load_dotenv()
 
 
-from agent.core.runner import AgentRunner
-from agent.llm import create_openrouter_llm_call
-from agent.llm.prompts import SimplePrompt
-from agent.trace import FileSystemTraceStore, Message, Trace
-from agent.utils import setup_logging
 from examples.demand.log_capture import build_log, log
 from examples.demand.log_capture import build_log, log
 
 
-# 导入项目配置
-from examples.demand.config import DEBUG, LOG_FILE, LOG_LEVEL, RUN_CONFIG, TRACE_STORE_PATH
-
 CUSTOM_TOOL_MODULES = {
 CUSTOM_TOOL_MODULES = {
     # demand 示例:严格按工具名白名单加载对应模块
     # demand 示例:严格按工具名白名单加载对应模块
     "think_and_plan": "examples.demand.agent_tools",
     "think_and_plan": "examples.demand.agent_tools",
@@ -68,6 +56,80 @@ CUSTOM_TOOL_MODULES = {
     "write_execution_summary": "examples.demand.demand_build_agent_tools",
     "write_execution_summary": "examples.demand.demand_build_agent_tools",
 }
 }
 
 
+LOCAL_JSON_MODE = "local_json"
+LOCAL_ENTRYPOINT_ENV = "DEMAND_LOCAL_ENTRYPOINT"
+LOCAL_ENTRYPOINT_NAME = "run_existing_execution_local"
+
+
+def _is_local_json_mode() -> bool:
+    return os.getenv("DEMAND_OUTPUT_MODE", "").strip().lower() == LOCAL_JSON_MODE
+
+
+def _require_local_entrypoint() -> None:
+    if not _is_local_json_mode():
+        return
+    entrypoint = os.getenv(LOCAL_ENTRYPOINT_ENV, "").strip()
+    if entrypoint != LOCAL_ENTRYPOINT_NAME:
+        raise RuntimeError(
+            "DEMAND_OUTPUT_MODE=local_json 只能通过 "
+            "examples.demand.run_existing_execution_local 入口运行"
+        )
+
+
+def _default_local_output_root(execution_id: int) -> Path:
+    ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d_%H%M%S")
+    return Path(__file__).parent / "test_output_data" / f"execution_{execution_id}_{ts}"
+
+
+def _ensure_local_output_paths(execution_id: int) -> Path:
+    root_value = os.getenv("DEMAND_LOCAL_OUTPUT_ROOT")
+    root = Path(root_value) if root_value else _default_local_output_root(execution_id)
+    os.environ["DEMAND_LOCAL_OUTPUT_ROOT"] = str(root)
+    os.environ.setdefault("DEMAND_RESULT_BASE_DIR", str(root / "intermediate" / "result"))
+    os.environ.setdefault("DEMAND_TRACE_STORE_PATH", str(root / ".trace"))
+    os.environ.setdefault("DEMAND_OUTPUT_BASE_DIR", str(root / "output"))
+    return root
+
+
+def _get_result_base_dir() -> Path:
+    redirected = os.getenv("DEMAND_RESULT_BASE_DIR")
+    if redirected:
+        return Path(redirected)
+    if _is_local_json_mode():
+        return _ensure_local_output_paths(0) / "result"
+    return Path.cwd() / "result"
+
+
+def _get_output_base_dir() -> Path:
+    redirected = os.getenv("DEMAND_OUTPUT_BASE_DIR")
+    if redirected:
+        return Path(redirected)
+    if _is_local_json_mode():
+        return _ensure_local_output_paths(0) / "output"
+    return Path(__file__).parent / "output"
+
+
+def _get_trace_store_base_dir() -> Path:
+    redirected = os.getenv("DEMAND_TRACE_STORE_PATH")
+    if redirected:
+        return Path(redirected)
+    if _is_local_json_mode():
+        return _ensure_local_output_paths(0) / ".trace"
+    from examples.demand.config import TRACE_STORE_PATH
+
+    return Path(TRACE_STORE_PATH)
+
+
+def _get_local_output_root() -> Optional[Path]:
+    root = os.getenv("DEMAND_LOCAL_OUTPUT_ROOT")
+    return Path(root) if root else None
+
+
+def _write_json_file(path: Path, payload: Any) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(payload, f, ensure_ascii=False, indent=2)
+
 
 
 def get_execution_id_by_merge_level2(cluster_name: str):
 def get_execution_id_by_merge_level2(cluster_name: str):
     """根据二级品类和平台查询最新的 execution_id。"""
     """根据二级品类和平台查询最新的 execution_id。"""
@@ -86,14 +148,14 @@ def get_execution_id_by_merge_level2(cluster_name: str):
         session.close()
         session.close()
 
 
 
 
-def resolve_model(prompt: SimplePrompt) -> str:
+def resolve_model(prompt: Any, run_config: Any) -> str:
     model_from_prompt = prompt.config.get("model")
     model_from_prompt = prompt.config.get("model")
     if model_from_prompt:
     if model_from_prompt:
         return model_from_prompt
         return model_from_prompt
-    return f"anthropic/{RUN_CONFIG.model}" if "/" not in RUN_CONFIG.model else RUN_CONFIG.model
+    return f"anthropic/{run_config.model}" if "/" not in run_config.model else run_config.model
 
 
 
 
-def extract_assistant_text(message: Message) -> str:
+def extract_assistant_text(message: Any) -> str:
     if message.role != "assistant":
     if message.role != "assistant":
         return ""
         return ""
     content = message.content
     content = message.content
@@ -189,13 +251,238 @@ def _resolve_video_ids_by_name_and_execution_id(name: str, execution_id: int) ->
     return query_video_ids_by_names(execution_id=execution_id, names=name_parts)
     return query_video_ids_by_names(execution_id=execution_id, names=name_parts)
 
 
 
 
+def _demand_items_output_path(execution_id: int) -> Path:
+    return _get_result_base_dir() / str(execution_id) / f"execution_id_{execution_id}_demand_items.json"
+
+
+def _resolve_demand_items_path(execution_id: int) -> Optional[Path]:
+    configured_path = _demand_items_output_path(execution_id)
+    if configured_path.exists() or _is_local_json_mode():
+        return configured_path if configured_path.exists() else None
+
+    fallbacks = [
+        Path.cwd() / "result" / str(execution_id) / f"execution_id_{execution_id}_demand_items.json",
+        Path(__file__).parent / "result" / str(execution_id) / f"execution_id_{execution_id}_demand_items.json",
+    ]
+    for path in fallbacks:
+        if path.exists():
+            return path
+    return None
+
+
+def _load_demand_items(execution_id: int, log_prefix: str) -> list[dict]:
+    demand_items_path = _resolve_demand_items_path(execution_id)
+    if not demand_items_path:
+        expected_path = _demand_items_output_path(execution_id)
+        log(f"[{log_prefix}] 未找到需求 JSON:{expected_path},跳过写入")
+        return []
+
+    try:
+        with open(demand_items_path, "r", encoding="utf-8") as f:
+            loaded = json.load(f)
+    except Exception as e:
+        log(f"[{log_prefix}] 读取需求 JSON 失败:{demand_items_path},error={e}")
+        return []
+
+    items = loaded["items"] if isinstance(loaded, dict) and isinstance(loaded.get("items"), list) else loaded
+    if not isinstance(items, list):
+        log(f"[{log_prefix}] 需求 JSON 非数组,跳过写入:type={type(items)}")
+        return []
+    return [item for item in items if isinstance(item, dict)]
+
+
+def _normalize_evidence_refs(value: object) -> dict:
+    if isinstance(value, dict):
+        return value
+    if isinstance(value, str) and value.strip():
+        try:
+            loaded = json.loads(value)
+            if isinstance(loaded, dict):
+                return loaded
+        except json.JSONDecodeError:
+            pass
+    return {}
+
+
+def _parse_ext_data(ext_data_raw: object) -> dict:
+    if isinstance(ext_data_raw, dict):
+        return ext_data_raw
+    if isinstance(ext_data_raw, str) and ext_data_raw.strip():
+        try:
+            loaded = json.loads(ext_data_raw)
+            return loaded if isinstance(loaded, dict) else {}
+        except json.JSONDecodeError:
+            return {}
+    return {}
+
+
+def _build_demand_content_rows(
+        execution_id: int,
+        merge_level2: str,
+        *,
+        trace_id: Optional[str] = None,
+        task_id: Optional[int] = None,
+        serialize_ext_data: bool = True,
+        require_evidence: bool = False,
+) -> tuple[list[dict], list[dict], list[dict]]:
+    from examples.demand.evidence_pack_builder import build_evidence_pack
+
+    items = _load_demand_items(execution_id=execution_id, log_prefix="result")
+    if not items:
+        return [], [], []
+
+    dt_value = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
+    score_map = _load_name_score_map(execution_id)
+    rows: list[dict] = []
+    normalized_items: list[dict] = []
+    rejected_items: list[dict] = []
+    for index, di in enumerate(items, start=1):
+        normalized_item = dict(di)
+        evidence_refs = _normalize_evidence_refs(di.get("evidence_refs"))
+        normalized_item["evidence_refs"] = evidence_refs
+        normalized_items.append(normalized_item)
+
+        name = _join_element_names_to_name(di.get("element_names"))
+        if not name:
+            rejected_items.append(
+                {
+                    "item_index": index,
+                    "reject_reason": "missing element_names/name",
+                    "demand_item": normalized_item,
+                }
+            )
+            continue
+
+        score = _avg_score_for_joined_name(name, score_map)
+        reason = di.get("reason")
+        desc_value = di.get("desc")
+        item_type = di.get("type")
+        type_str = str(item_type).strip() if item_type is not None else ""
+        if type_str == "分类":
+            category_level = query_category_level(execution_id=execution_id, name=name)
+            if category_level:
+                type_str = type_str + f"L{category_level}"
+
+        video_ids = _resolve_video_ids_by_name_and_execution_id(name=name, execution_id=execution_id)
+        ext_data: dict[str, Any] = {
+            "reason": reason,
+            "desc": desc_value,
+            "type": type_str,
+            "video_ids": video_ids,
+        }
+        if trace_id:
+            ext_data["trace_id"] = trace_id
+        if task_id is not None:
+            ext_data["demand_task_id"] = task_id
+
+        content_id = len(rows) + 1 if not serialize_ext_data else None
+        if require_evidence or evidence_refs:
+            evidence_result = build_evidence_pack(
+                execution_id=int(execution_id),
+                demand_item=normalized_item,
+                trace_id=trace_id or "",
+                demand_task_id=task_id,
+                demand_content_id=content_id,
+            )
+            if not evidence_result.get("success"):
+                if require_evidence:
+                    rejected_items.append(
+                        {
+                            "item_index": index,
+                            "reject_reason": evidence_result.get("reject_reason") or "evidence validation failed",
+                            "demand_item": normalized_item,
+                        }
+                    )
+                    continue
+                log(
+                    "[result] evidence 校验未通过,生产兼容路径保留旧输出:"
+                    f"item_index={index}, reason={evidence_result.get('reject_reason')}"
+                )
+            else:
+                evidence_pack = evidence_result["evidence_pack"]
+                ext_data["evidence_pack"] = evidence_pack
+                if evidence_pack.get("video_ids"):
+                    ext_data["video_ids"] = evidence_pack["video_ids"]
+
+        row = {
+            "merge_leve2": _safe_truncate(merge_level2, 32),
+            "name": _safe_truncate(name, 64),
+            "reason": reason,
+            "suggestion": desc_value,
+            "score": float(score),
+            "ext_data": json.dumps(ext_data, ensure_ascii=False) if serialize_ext_data else ext_data,
+            "dt": dt_value,
+        }
+        if not serialize_ext_data:
+            row["id"] = content_id
+        rows.append(row)
+
+    if not rows:
+        log("[result] 生成行为空,跳过写入")
+    if rejected_items:
+        log(f"[result] evidence 校验拒绝 {len(rejected_items)} 条 DemandItem")
+    return rows, normalized_items, rejected_items
+
+
+def _build_local_dwd_multi_rows(rows: list[dict]) -> list[dict]:
+    local_rows: list[dict] = []
+    china_today = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
+    for row in rows:
+        merge_leve2 = str(row.get("merge_leve2") or "").strip()
+        name = str(row.get("name") or "").strip()
+        if not merge_leve2 or not name:
+            continue
+
+        ext_data = _parse_ext_data(row.get("ext_data"))
+        type_str = str(ext_data.get("type") or "").strip()
+        video_ids = ext_data.get("video_ids") or []
+        if not isinstance(video_ids, list):
+            video_ids = []
+        video_ids = [str(v).strip() for v in video_ids if v is not None and str(v).strip()]
+        weight = round(float(row.get("score") or 0.0), 6)
+        extend = {"品类": merge_leve2}
+
+        for strategy, demand_name, id_source in [
+            ("当下供需gap", f"{merge_leve2} {name}", f"当下供需gap{merge_leve2} {name}{type_str}{china_today}"),
+            ("当下供需gap-分词", name, f"当下供需gap-分词{name}{merge_leve2}{type_str}{china_today}"),
+        ]:
+            local_rows.append(
+                {
+                    "strategy": strategy,
+                    "demand_id": hashlib.md5(id_source.encode("utf-8")).hexdigest(),
+                    "demand_name": demand_name,
+                    "weight": weight,
+                    "type": type_str,
+                    "video_count": len(video_ids),
+                    "video_list": video_ids,
+                    "extend": extend,
+                    "dt": china_today,
+                }
+            )
+    return local_rows
+
+
+def _build_local_feature_point_rows(names: list[str]) -> list[dict]:
+    dt = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
+    return [
+        {"特征点": name, "总分发曝光pv": 5000, "质bn_rovn": 0.1, "dt": dt}
+        for name in names
+        if name
+    ]
+
+
 def _create_demand_task(
 def _create_demand_task(
         execution_id: int,
         execution_id: int,
         name: Optional[str] = None,
         name: Optional[str] = None,
         platform: Optional[str] = None,
         platform: Optional[str] = None,
 ) -> Optional[int]:
 ) -> Optional[int]:
     """创建 demand_task 记录,返回任务ID。"""
     """创建 demand_task 记录,返回任务ID。"""
+    if _is_local_json_mode():
+        raise RuntimeError("local_json 模式禁止调用生产 _create_demand_task")
+
     try:
     try:
+        from examples.demand.mysql import mysql_db
+
         # 数据库字段 demand_task.name: varchar(32)
         # 数据库字段 demand_task.name: varchar(32)
         if name is not None:
         if name is not None:
             name = str(name)[:32]
             name = str(name)[:32]
@@ -224,7 +511,11 @@ def _finish_demand_task(task_id: Optional[int], status: int, task_log: str) -> N
     """更新 demand_task 状态与日志。"""
     """更新 demand_task 状态与日志。"""
     if not task_id:
     if not task_id:
         return
         return
+    if _is_local_json_mode():
+        raise RuntimeError("local_json 模式禁止调用生产 _finish_demand_task")
     try:
     try:
+        from examples.demand.mysql import mysql_db
+
         mysql_db.update(
         mysql_db.update(
             "demand_task",
             "demand_task",
             {
             {
@@ -239,82 +530,101 @@ def _finish_demand_task(task_id: Optional[int], status: int, task_log: str) -> N
         log(f"[task] 更新 demand_task 失败,task_id={task_id}, status={status}, error={e}")
         log(f"[task] 更新 demand_task 失败,task_id={task_id}, status={status}, error={e}")
 
 
 
 
-def write_demand_items_to_mysql(execution_id: int, merge_level2: str) -> int:
-    """
-    把 result/{execution_id}/execution_id_{execution_id}_demand_items.json
-    写入 MySQL 表 demand_content
-    """
-    # create_demand_item(s) 使用 Path.cwd()/result 作为输出目录。
-    # 为了兼容“从不同目录启动脚本”的情况,这里同时尝试 cwd 和脚本目录两种结果位置。
-    demand_items_path = (
-            Path.cwd()
-            / "result"
-            / str(execution_id)
-            / f"execution_id_{execution_id}_demand_items.json"
+def _start_local_demand_task(
+        *,
+        execution_id: int,
+        task_id: Optional[int],
+        name: Optional[str],
+        platform: Optional[str],
+) -> int:
+    from examples.demand.local_output_sink import LocalOutputSink
+
+    local_task_id = int(task_id or os.getenv("DEMAND_LOCAL_TASK_ID", "1"))
+    os.environ["DEMAND_LOCAL_TASK_ID"] = str(local_task_id)
+    root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
+    task_payload = {
+        "id": local_task_id,
+        "execution_id": execution_id,
+        "name": name,
+        "platform": platform,
+        "status": 0,
+        "mode": LOCAL_JSON_MODE,
+        "is_simulated_id": True,
+        "log": "",
+        "started_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
+    }
+    LocalOutputSink(root).write_all(
+        run_manifest={
+            "output_mode": LOCAL_JSON_MODE,
+            "execution_id": execution_id,
+            "merge_leve2": name,
+            "platform_type": platform,
+            "task_id": local_task_id,
+            "status": 0,
+            "initialized": True,
+            "created_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
+        },
+        demand_task=task_payload,
     )
     )
-    if not demand_items_path.exists():
-        alt_path = (
-                Path(__file__).parent
-                / "result"
-                / str(execution_id)
-                / f"execution_id_{execution_id}_demand_items.json"
-        )
-        if alt_path.exists():
-            demand_items_path = alt_path
-        else:
-            log(f"[mysql] 未找到需求 JSON:{demand_items_path}(也未找到 {alt_path}),跳过写入")
-            return 0
+    log(f"[task][local_json] 写入本地 demand_task,task_id={local_task_id}, execution_id={execution_id}")
+    return local_task_id
 
 
-    try:
-        with open(demand_items_path, "r", encoding="utf-8") as f:
-            loaded = json.load(f)
-    except Exception as e:
-        log(f"[mysql] 读取需求 JSON 失败:{demand_items_path},error={e}")
-        return 0
 
 
-    items = loaded["items"] if isinstance(loaded, dict) and isinstance(loaded.get("items"), list) else loaded
-    if not isinstance(items, list):
-        log(f"[mysql] 需求 JSON 非数组,跳过写入:type={type(items)}")
-        return 0
+def _finish_local_demand_task(task_id: Optional[int], status: int, task_log: str) -> None:
+    if not task_id:
+        return
+    root = _get_local_output_root()
+    if not root:
+        return
+    task_path = root / "demand_task.json"
+    payload: dict[str, Any] = {}
+    if task_path.exists():
+        try:
+            with open(task_path, "r", encoding="utf-8") as f:
+                loaded = json.load(f)
+            if isinstance(loaded, dict):
+                payload = loaded
+        except Exception:
+            payload = {}
+    payload.update(
+        {
+            "id": task_id,
+            "status": int(status),
+            "mode": LOCAL_JSON_MODE,
+            "is_simulated_id": True,
+            "log": task_log or "",
+            "finished_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
+        }
+    )
+    _write_json_file(task_path, payload)
+    log(f"[task][local_json] 更新本地 demand_task,task_id={task_id}, status={status}")
 
 
-    dt_value = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
-    score_map = _load_name_score_map(execution_id)
-    rows: list[dict] = []
-    for di in items:
-        if not isinstance(di, dict):
-            continue
 
 
-        name = _join_element_names_to_name(di.get("element_names"))
-        if not name:
-            continue
-        score = _avg_score_for_joined_name(name, score_map)
-        reason = di.get("reason")
-        desc_value = di.get("desc")
-        type = di.get("type")
-        suggestion = desc_value
-        type_str = str(type).strip() if type is not None else ""
-        if type_str == "分类":
-            category_level = query_category_level(execution_id=execution_id, name=name)
-            if category_level:
-                type_str = type_str + f"L{category_level}"
-        video_ids = _resolve_video_ids_by_name_and_execution_id(name=name, execution_id=execution_id)
-        # 兼容旧字段:同时保留 ext_data(reason/desc)JSON,便于旧版消费逻辑迁移期继续使用。
-        ext_data = {"reason": reason, "desc": desc_value, "type": type_str, "video_ids": video_ids}
+def write_demand_items_to_mysql(
+        execution_id: int,
+        merge_level2: str,
+        *,
+        trace_id: Optional[str] = None,
+        task_id: Optional[int] = None,
+) -> int:
+    """
+    把 result/{execution_id}/execution_id_{execution_id}_demand_items.json
+    写入 MySQL 表 demand_content
+    """
+    if _is_local_json_mode():
+        raise RuntimeError("local_json 模式禁止调用生产 write_demand_items_to_mysql")
 
 
-        rows.append(
-            {
-                "merge_leve2": _safe_truncate(merge_level2, 32),
-                "name": _safe_truncate(name, 64),
-                "reason": reason,
-                "suggestion": suggestion,
-                "score": float(score),
-                "ext_data": json.dumps(ext_data, ensure_ascii=False),
-                "dt": dt_value,
-            }
-        )
+    from examples.demand.mysql import mysql_db
 
 
+    rows, _, rejected_items = _build_demand_content_rows(
+        execution_id=execution_id,
+        merge_level2=merge_level2,
+        trace_id=trace_id,
+        task_id=task_id,
+    )
+    if rejected_items:
+        log(f"[mysql] evidence 校验拒绝 rows={len(rejected_items)},仅写入 passed rows")
     if not rows:
     if not rows:
-        log("[mysql] 生成行为空,跳过写入")
         return 0
         return 0
 
 
     affected = mysql_db.insert_many("demand_content", rows)
     affected = mysql_db.insert_many("demand_content", rows)
@@ -324,6 +634,7 @@ def write_demand_items_to_mysql(execution_id: int, merge_level2: str) -> int:
         from examples.demand.data_query_tools import write_dwd_multi_demand_pool_di_to_hive
         from examples.demand.data_query_tools import write_dwd_multi_demand_pool_di_to_hive
 
 
         hive_written = write_dwd_multi_demand_pool_di_to_hive(rows=rows)
         hive_written = write_dwd_multi_demand_pool_di_to_hive(rows=rows)
+        dt_value = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
         log(f"[hive] 写入 dwd_multi_demand_pool_di 完成,rows={hive_written}, dt={dt_value}")
         log(f"[hive] 写入 dwd_multi_demand_pool_di 完成,rows={hive_written}, dt={dt_value}")
     except Exception as e:
     except Exception as e:
         log(f"[hive] 写入 dwd_multi_demand_pool_di 异常(MySQL 已成功):{e}")
         log(f"[hive] 写入 dwd_multi_demand_pool_di 异常(MySQL 已成功):{e}")
@@ -333,40 +644,49 @@ def write_demand_items_to_mysql(execution_id: int, merge_level2: str) -> int:
     return len(rows)
     return len(rows)
 
 
 
 
+def write_demand_items_to_local_json(
+        execution_id: int,
+        merge_level2: str,
+        *,
+        trace_id: Optional[str] = None,
+        task_id: Optional[int] = None,
+) -> int:
+    """把普通需求池输出镜像到本地 JSON,不写 demand_content / Hive。"""
+    from examples.demand.local_output_sink import LocalOutputSink
+
+    root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
+    rows, demand_items, rejected_items = _build_demand_content_rows(
+        execution_id=execution_id,
+        merge_level2=merge_level2,
+        trace_id=trace_id,
+        task_id=task_id,
+        serialize_ext_data=False,
+        require_evidence=True,
+    )
+
+    sink = LocalOutputSink(root)
+    sink.write_demand_items(demand_items)
+    sink.write_rejected_demand_items(rejected_items)
+    sink.write_demand_content(rows)
+    sink.write_dwd_multi_demand_pool_di_from_demand_content(rows)
+    sink.write_feature_point_data([])
+    log(
+        "[local_json] 写入本地 demand_content/dwd_multi_demand_pool_di 完成,"
+        f"demand_content_rows={len(rows)}, rejected={len(rejected_items)}, root={root}"
+    )
+    return len(rows)
+
+
 def write_global_tree_demand_items_to_hive(execution_id: int, merge_level2: str) -> int:
 def write_global_tree_demand_items_to_hive(execution_id: int, merge_level2: str) -> int:
     """
     """
     把 result/{execution_id}/execution_id_{execution_id}_demand_items.json
     把 result/{execution_id}/execution_id_{execution_id}_demand_items.json
     写入 Hive 表 feature_point_data(仅全局树场景)。
     写入 Hive 表 feature_point_data(仅全局树场景)。
     """
     """
-    demand_items_path = (
-            Path.cwd()
-            / "result"
-            / str(execution_id)
-            / f"execution_id_{execution_id}_demand_items.json"
-    )
-    if not demand_items_path.exists():
-        alt_path = (
-                Path(__file__).parent
-                / "result"
-                / str(execution_id)
-                / f"execution_id_{execution_id}_demand_items.json"
-        )
-        if alt_path.exists():
-            demand_items_path = alt_path
-        else:
-            log(f"[hive] 未找到需求 JSON:{demand_items_path}(也未找到 {alt_path}),跳过写入")
-            return 0
+    if _is_local_json_mode():
+        raise RuntimeError("local_json 模式禁止调用生产 write_global_tree_demand_items_to_hive")
 
 
-    try:
-        with open(demand_items_path, "r", encoding="utf-8") as f:
-            loaded = json.load(f)
-    except Exception as e:
-        log(f"[hive] 读取需求 JSON 失败:{demand_items_path},error={e}")
-        return 0
-
-    items = loaded["items"] if isinstance(loaded, dict) and isinstance(loaded.get("items"), list) else loaded
-    if not isinstance(items, list):
-        log(f"[hive] 需求 JSON 非数组,跳过写入:type={type(items)}")
+    items = _load_demand_items(execution_id=execution_id, log_prefix="hive")
+    if not items:
         return 0
         return 0
 
 
     names: list[str] = []
     names: list[str] = []
@@ -393,24 +713,123 @@ def write_global_tree_demand_items_to_hive(execution_id: int, merge_level2: str)
         return 0
         return 0
 
 
 
 
-async def run_once(execution_id, merge_level2, count: int = 30, task_id: Optional[int] = None) -> str:
+def write_global_tree_demand_items_to_local_json(
+        execution_id: int,
+        merge_level2: str,
+        *,
+        trace_id: Optional[str] = None,
+        task_id: Optional[int] = None,
+) -> int:
+    """把全局树 feature_point_data 输出镜像到本地 JSON,不写 Hive。"""
+    from examples.demand.local_output_sink import LocalOutputSink
+
+    root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
+    items = _load_demand_items(execution_id=execution_id, log_prefix="local_json")
+    normalized_items = []
+    names: list[str] = []
+    for di in items:
+        normalized = dict(di)
+        normalized["evidence_refs"] = _normalize_evidence_refs(di.get("evidence_refs"))
+        normalized_items.append(normalized)
+        name = _join_element_names_to_name(di.get("element_names"))
+        if name:
+            names.append(name)
+
+    sink = LocalOutputSink(root)
+    sink.write_demand_items(normalized_items)
+    sink.write_rejected_demand_items([])
+    sink.write_demand_content([])
+    sink.write_dwd_multi_demand_pool_di([])
+    sink.write_feature_point_data_from_names(names)
+    log(
+        "[local_json] 写入本地 feature_point_data 完成,"
+        f"rows={len(names)}, merge_leve2={merge_level2}, root={root}"
+    )
+    return len(names)
+
+
+def _write_local_run_manifest(
+        *,
+        execution_id: int,
+        merge_level2: str,
+        platform_type: Optional[str],
+        count: int,
+        task_id: Optional[int],
+        trace_id: Optional[str],
+        final_text: str,
+        task_status: int,
+        total_tokens: int,
+        total_cost: float,
+        result_write_count: int,
+        log_file_path: Path,
+        result_write_error: Optional[str] = None,
+) -> None:
+    if not _is_local_json_mode():
+        return
+    root = _get_local_output_root() or _ensure_local_output_paths(execution_id)
+    manifest = {
+        "output_mode": LOCAL_JSON_MODE,
+        "execution_id": execution_id,
+        "merge_leve2": merge_level2,
+        "platform_type": platform_type,
+        "count": count,
+        "task_id": task_id,
+        "trace_id": trace_id,
+        "status": task_status,
+        "total_tokens": total_tokens,
+        "total_cost": total_cost,
+        "result_write_count": result_write_count,
+        "result_write_error": result_write_error,
+        "final_text_length": len(final_text or ""),
+        "paths": {
+            "root": str(root),
+            "result": str(_get_result_base_dir()),
+            "trace": str(_get_trace_store_base_dir()),
+            "output": str(_get_output_base_dir()),
+            "demand_items_raw": str(_demand_items_output_path(execution_id)),
+            "log_file": str(log_file_path),
+        },
+        "created_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
+    }
+    _write_json_file(root / "run_manifest.json", manifest)
+
+
+async def run_once(
+        execution_id,
+        merge_level2,
+        count: int = 30,
+        task_id: Optional[int] = None,
+        platform_type: Optional[str] = None,
+) -> dict:
+    from agent.core.runner import AgentRunner
+    from agent.llm import create_openrouter_llm_call
+    from agent.llm.prompts import SimplePrompt
+    from agent.trace import FileSystemTraceStore, Message, Trace
+    from agent.utils import setup_logging
+    from examples.demand.config import DEBUG, ENABLED_TOOLS, LOG_FILE, LOG_LEVEL, RUN_CONFIG
+
     task_log_text = ""
     task_log_text = ""
     task_status = 0
     task_status = 0
+    result_write_count = 0
+    result_write_error: Optional[str] = None
+
+    if _is_local_json_mode():
+        _ensure_local_output_paths(execution_id)
 
 
     TopicBuildAgentContext.set_execution_id(execution_id)
     TopicBuildAgentContext.set_execution_id(execution_id)
+    TopicBuildAgentContext.set_metadata("result_base_dir", str(_get_result_base_dir()))
 
 
     base_dir = Path(__file__).parent
     base_dir = Path(__file__).parent
-    output_dir = base_dir / "output"
-    output_dir.mkdir(exist_ok=True)
+    output_dir = _get_output_base_dir()
+    output_dir.mkdir(parents=True, exist_ok=True)
 
 
     setup_logging(level=LOG_LEVEL, file=LOG_FILE)
     setup_logging(level=LOG_LEVEL, file=LOG_FILE)
     register_selected_tools(ENABLED_TOOLS)
     register_selected_tools(ENABLED_TOOLS)
 
 
     prompt = SimplePrompt(base_dir / "demand.md")
     prompt = SimplePrompt(base_dir / "demand.md")
 
 
-    model = resolve_model(prompt)
-
     run_config = copy.deepcopy(RUN_CONFIG)
     run_config = copy.deepcopy(RUN_CONFIG)
+    model = resolve_model(prompt, run_config)
     run_config.temperature = float(prompt.config.get("temperature", run_config.temperature))
     run_config.temperature = float(prompt.config.get("temperature", run_config.temperature))
     run_config.max_iterations = int(prompt.config.get("max_iterations", run_config.max_iterations))
     run_config.max_iterations = int(prompt.config.get("max_iterations", run_config.max_iterations))
     run_config.tools = ENABLED_TOOLS.copy()
     run_config.tools = ENABLED_TOOLS.copy()
@@ -425,7 +844,8 @@ async def run_once(execution_id, merge_level2, count: int = 30, task_id: Optiona
 
 
     initial_messages = prompt.build_messages(merge_level2=merge_level2, count=count)
     initial_messages = prompt.build_messages(merge_level2=merge_level2, count=count)
 
 
-    store = FileSystemTraceStore(base_path=TRACE_STORE_PATH)
+    trace_store_path = _get_trace_store_base_dir()
+    store = FileSystemTraceStore(base_path=str(trace_store_path))
     runner = AgentRunner(
     runner = AgentRunner(
         trace_store=store,
         trace_store=store,
         llm_call=create_openrouter_llm_call(model=model),
         llm_call=create_openrouter_llm_call(model=model),
@@ -436,6 +856,7 @@ async def run_once(execution_id, merge_level2, count: int = 30, task_id: Optiona
     final_text = ""
     final_text = ""
     total_tokens = 0
     total_tokens = 0
     total_cost = 0.0
     total_cost = 0.0
+    trace_id: Optional[str] = None
     has_completed_trace_cost = False
     has_completed_trace_cost = False
     log_file_path = output_dir / f"{execution_id}" / f"run_log_{execution_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
     log_file_path = output_dir / f"{execution_id}" / f"run_log_{execution_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
     log_file_path.parent.mkdir(parents=True, exist_ok=True)
     log_file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -443,6 +864,7 @@ async def run_once(execution_id, merge_level2, count: int = 30, task_id: Optiona
         with build_log(execution_id) as log_buffer:
         with build_log(execution_id) as log_buffer:
             async for item in runner.run(messages=initial_messages, config=run_config):
             async for item in runner.run(messages=initial_messages, config=run_config):
                 if isinstance(item, Trace):
                 if isinstance(item, Trace):
+                    trace_id = getattr(item, "trace_id", None) or trace_id
                     if getattr(item, "status", None) == "completed":
                     if getattr(item, "status", None) == "completed":
                         total_tokens = int(getattr(item, "total_tokens", 0) or 0)
                         total_tokens = int(getattr(item, "total_tokens", 0) or 0)
                         total_cost = float(getattr(item, "total_cost", 0.0) or 0.0)
                         total_cost = float(getattr(item, "total_cost", 0.0) or 0.0)
@@ -468,15 +890,43 @@ async def run_once(execution_id, merge_level2, count: int = 30, task_id: Optiona
             # agent 执行完成后:全局树写 Hive,其他写 MySQL
             # agent 执行完成后:全局树写 Hive,其他写 MySQL
             try:
             try:
                 if str(merge_level2).strip() == "全局树":
                 if str(merge_level2).strip() == "全局树":
-                    write_global_tree_demand_items_to_hive(execution_id=execution_id, merge_level2=merge_level2)
+                    if _is_local_json_mode():
+                        result_write_count = write_global_tree_demand_items_to_local_json(
+                            execution_id=execution_id,
+                            merge_level2=merge_level2,
+                            trace_id=trace_id,
+                            task_id=task_id,
+                        )
+                    else:
+                        result_write_count = write_global_tree_demand_items_to_hive(
+                            execution_id=execution_id,
+                            merge_level2=merge_level2,
+                        )
                 else:
                 else:
                     # element_names -> name(逗号分隔);reason -> demand_content.reason;desc -> demand_content.suggestion;dt -> demand_content.dt
                     # element_names -> name(逗号分隔);reason -> demand_content.reason;desc -> demand_content.suggestion;dt -> demand_content.dt
-                    write_demand_items_to_mysql(execution_id=execution_id, merge_level2=merge_level2)
+                    if _is_local_json_mode():
+                        result_write_count = write_demand_items_to_local_json(
+                            execution_id=execution_id,
+                            merge_level2=merge_level2,
+                            trace_id=trace_id,
+                            task_id=task_id,
+                        )
+                    else:
+                        result_write_count = write_demand_items_to_mysql(
+                            execution_id=execution_id,
+                            merge_level2=merge_level2,
+                            trace_id=trace_id,
+                            task_id=task_id,
+                        )
             except Exception as e:
             except Exception as e:
+                result_write_error = str(e)
                 log(f"[result-write] 写入结果异常:{e}")
                 log(f"[result-write] 写入结果异常:{e}")
+                if _is_local_json_mode():
+                    task_status = 2
 
 
             task_log_text = log_buffer.getvalue()
             task_log_text = log_buffer.getvalue()
-            task_status = 1
+            if task_status != 2:
+                task_status = 1
     except Exception as e:
     except Exception as e:
         if not task_log_text:
         if not task_log_text:
             # 如果异常发生在 build_log 内部,尽量回收已产生的日志
             # 如果异常发生在 build_log 内部,尽量回收已产生的日志
@@ -498,9 +948,34 @@ async def run_once(execution_id, merge_level2, count: int = 30, task_id: Optiona
             except Exception:
             except Exception:
                 # 兜底:即使写文件失败,也要确保 MySQL 状态被更新
                 # 兜底:即使写文件失败,也要确保 MySQL 状态被更新
                 pass
                 pass
-        _finish_demand_task(task_id=task_id, status=task_status, task_log=task_log_text)
+        if _is_local_json_mode():
+            _finish_local_demand_task(task_id=task_id, status=task_status, task_log=task_log_text)
+        else:
+            _finish_demand_task(task_id=task_id, status=task_status, task_log=task_log_text)
+        _write_local_run_manifest(
+            execution_id=execution_id,
+            merge_level2=merge_level2,
+            platform_type=platform_type,
+            count=int(count),
+            task_id=task_id,
+            trace_id=trace_id,
+            final_text=final_text,
+            task_status=task_status,
+            total_tokens=total_tokens,
+            total_cost=total_cost,
+            result_write_count=result_write_count,
+            log_file_path=log_file_path,
+            result_write_error=result_write_error,
+        )
 
 
-    return final_text
+    return {
+        "final_text": final_text,
+        "trace_id": trace_id,
+        "output_dir": str(output_dir / f"{execution_id}"),
+        "trace_store_path": str(trace_store_path),
+        "result_base_dir": str(_get_result_base_dir()),
+        "result_write_count": result_write_count,
+    }
 
 
 
 
 async def main(
 async def main(
@@ -510,20 +985,58 @@ async def main(
         execution_id: Optional[int] = None,
         execution_id: Optional[int] = None,
         task_id: Optional[int] = None,
         task_id: Optional[int] = None,
 ) -> dict:
 ) -> dict:
+    if _is_local_json_mode():
+        _require_local_entrypoint()
+        if execution_id is None:
+            raise ValueError("local_json 模式必须传入已有 execution_id,不能触发 prepare 写库流程")
+        _ensure_local_output_paths(execution_id)
+
     if execution_id is None:
     if execution_id is None:
         if platform_type == "piaoquan":
         if platform_type == "piaoquan":
+            from examples.demand.piaoquan_prepare import piaoquan_prepare
+
             execution_id = piaoquan_prepare(cluster_name)
             execution_id = piaoquan_prepare(cluster_name)
         elif platform_type == "changwen":
         elif platform_type == "changwen":
+            from examples.demand.changwen_prepare import changwen_prepare
+
             execution_id = changwen_prepare(cluster_name)
             execution_id = changwen_prepare(cluster_name)
         elif platform_type == "zengzhang":
         elif platform_type == "zengzhang":
+            from examples.demand.zengzhang_prepare import zengzhang_prepare
+
             execution_id = zengzhang_prepare(cluster_name)
             execution_id = zengzhang_prepare(cluster_name)
         else:
         else:
             execution_id = None
             execution_id = None
     if not execution_id:
     if not execution_id:
-        return {"execution_id": None, "final_text": ""}
+        return {"execution_id": None, "trace_id": None, "final_text": ""}
+
+    if _is_local_json_mode():
+        task_id = _start_local_demand_task(
+            execution_id=int(execution_id),
+            task_id=task_id,
+            name=cluster_name,
+            platform=platform_type,
+        )
 
 
-    final_text = await run_once(execution_id, cluster_name, count=count, task_id=task_id)
-    return {"execution_id": execution_id, "final_text": final_text}
+    run_result = await run_once(
+        execution_id,
+        cluster_name,
+        count=count,
+        task_id=task_id,
+        platform_type=platform_type,
+    )
+    result = {
+        "execution_id": execution_id,
+        "trace_id": run_result.get("trace_id"),
+        "final_text": run_result.get("final_text", ""),
+        "output_dir": run_result.get("output_dir"),
+        "trace_store_path": run_result.get("trace_store_path"),
+        "result_base_dir": run_result.get("result_base_dir"),
+        "result_write_count": run_result.get("result_write_count", 0),
+    }
+    if _is_local_json_mode():
+        root = _get_local_output_root()
+        result["local_output_root"] = str(root) if root else None
+    return result
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":

+ 106 - 0
examples/demand/run_existing_execution_local.py

@@ -0,0 +1,106 @@
+"""Local JSON entrypoint for an existing DemandAgent execution.
+
+This entrypoint intentionally requires an existing execution_id. It redirects
+result/.trace/output into one local run directory and never calls prepare.
+"""
+import argparse
+import asyncio
+import json
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+
+def _default_output_root(execution_id: int) -> Path:
+    ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d_%H%M%S")
+    return Path(__file__).parent / "test_output_data" / f"execution_{execution_id}_{ts}"
+
+
+def _test_output_base_dir() -> Path:
+    return Path(__file__).parent / "test_output_data"
+
+
+def _resolve_output_root(execution_id: int, run_id: str | None, output_root: Path | None) -> Path:
+    base_dir = _test_output_base_dir().resolve()
+    if run_id:
+        return base_dir / run_id
+    if output_root is None:
+        return _default_output_root(execution_id)
+
+    resolved = output_root.expanduser().resolve()
+    if resolved != base_dir and base_dir not in resolved.parents:
+        raise ValueError(
+            f"local MVP 输出必须位于 {base_dir} 下;请改用 --run-id 或传入该目录下的 --output-root"
+        )
+    return resolved
+
+
+def _configure_local_env(execution_id: int, output_root: Path) -> None:
+    os.environ["DEMAND_OUTPUT_MODE"] = "local_json"
+    os.environ["DEMAND_LOCAL_ENTRYPOINT"] = "run_existing_execution_local"
+    os.environ["DEMAND_LOCAL_OUTPUT_ROOT"] = str(output_root)
+    os.environ["DEMAND_RESULT_BASE_DIR"] = str(output_root / "intermediate" / "result")
+    os.environ["DEMAND_TRACE_STORE_PATH"] = str(output_root / ".trace")
+    os.environ["DEMAND_OUTPUT_BASE_DIR"] = str(output_root / "output")
+    os.environ.setdefault("DEMAND_LOCAL_TASK_ID", "1")
+
+
+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} 不存在,local MVP 只允许跑已有 execution")
+    if str(execution.get("status") or "").strip().lower() != "success":
+        raise ValueError(
+            f"execution_id={execution_id} status={execution.get('status')},不是 success,拒绝生成本地输出"
+        )
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description="Run DemandAgent against an existing execution_id and write local JSON outputs."
+    )
+    parser.add_argument("--execution-id", type=int, required=True, help="Existing topic_pattern_execution.id")
+    parser.add_argument("--merge-level2", required=True, help="Current merge_leve2 / cluster name")
+    parser.add_argument("--platform-type", default="piaoquan", help="Platform label for manifest only")
+    parser.add_argument("--count", type=int, default=5, help="Approximate demand count requested from the prompt")
+    parser.add_argument("--run-id", default=None, help="Run directory name under examples/demand/test_output_data")
+    parser.add_argument(
+        "--output-root",
+        type=Path,
+        default=None,
+        help="Optional full run directory; must be under examples/demand/test_output_data",
+    )
+    return parser.parse_args()
+
+
+async def async_main() -> dict:
+    args = parse_args()
+    output_root = _resolve_output_root(args.execution_id, args.run_id, args.output_root)
+    _configure_local_env(args.execution_id, output_root)
+    _validate_execution_success(args.execution_id)
+
+    from examples.demand.run import main
+
+    return await main(
+        cluster_name=args.merge_level2,
+        platform_type=args.platform_type,
+        count=args.count,
+        execution_id=args.execution_id,
+        task_id=int(os.environ.get("DEMAND_LOCAL_TASK_ID", "1")),
+    )
+
+
+def main() -> None:
+    result = asyncio.run(async_main())
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    main()

+ 22 - 0
examples/demand/tool_logging.py

@@ -0,0 +1,22 @@
+"""Lightweight logging helpers shared by DemandAgent business tools."""
+
+import json
+
+from examples.demand.log_capture import log
+
+
+def log_tool_input(tool_name: str, params: dict):
+    """Print tool parameters before execution."""
+    log(f"\n[FOLD:🔧 {tool_name}]")
+    log("[FOLD:📥 调用参数]")
+    log(json.dumps(params, ensure_ascii=False, indent=2))
+    log("[/FOLD]")
+
+
+def log_tool_output(tool_name: str, result: str) -> str:
+    """Print tool return content and return it unchanged."""
+    log("[FOLD:📤 返回内容]")
+    log(result)
+    log("[/FOLD]")
+    log("[/FOLD]\n")
+    return result

+ 12 - 12
examples/demand/weight_score_query_tools.py

@@ -11,7 +11,7 @@ from pathlib import Path
 
 
 from agent import tool
 from agent import tool
 from examples.demand.demand_agent_context import TopicBuildAgentContext
 from examples.demand.demand_agent_context import TopicBuildAgentContext
-from examples.demand.demand_pattern_tools import _log_tool_input, _log_tool_output
+from examples.demand.tool_logging import log_tool_input, log_tool_output
 
 
 _VALID_LEVELS = {"元素", "分类"}
 _VALID_LEVELS = {"元素", "分类"}
 _VALID_DIMENSIONS = {"实质", "形式", "意图"}
 _VALID_DIMENSIONS = {"实质", "形式", "意图"}
@@ -71,17 +71,17 @@ def get_weight_score_topn(level: str, dimension: str, start: int = 1, end: int =
         "start": start,
         "start": start,
         "end": end,
         "end": end,
     }
     }
-    _log_tool_input("get_weight_score_topn", params)
+    log_tool_input("get_weight_score_topn", params)
 
 
     try:
     try:
         _validate_params(level=level, dimension=dimension)
         _validate_params(level=level, dimension=dimension)
         if start < 1 or end < 1:
         if start < 1 or end < 1:
-            return _log_tool_output(
+            return log_tool_output(
                 "get_weight_score_topn",
                 "get_weight_score_topn",
                 f"错误: start/end 必须为大于等于 1 的整数,当前值: start={start}, end={end}",
                 f"错误: start/end 必须为大于等于 1 的整数,当前值: start={start}, end={end}",
             )
             )
         if start > end:
         if start > end:
-            return _log_tool_output(
+            return log_tool_output(
                 "get_weight_score_topn",
                 "get_weight_score_topn",
                 f"错误: start 不能大于 end,当前值: start={start}, end={end}",
                 f"错误: start 不能大于 end,当前值: start={start}, end={end}",
             )
             )
@@ -100,9 +100,9 @@ def get_weight_score_topn(level: str, dimension: str, start: int = 1, end: int =
             "matched_count": len(ranged_items),
             "matched_count": len(ranged_items),
             "items": ranged_items,
             "items": ranged_items,
         }
         }
-        return _log_tool_output("get_weight_score_topn", json.dumps(result, ensure_ascii=False, indent=2))
+        return log_tool_output("get_weight_score_topn", json.dumps(result, ensure_ascii=False, indent=2))
     except Exception as e:
     except Exception as e:
-        return _log_tool_output("get_weight_score_topn", f"查询失败: {e}")
+        return log_tool_output("get_weight_score_topn", f"查询失败: {e}")
 
 
 
 
 @tool("批量查询指定名称的权重分。参数:level(元素/分类)、dimension(实质/形式/意图)、names(名称列表)。")
 @tool("批量查询指定名称的权重分。参数:level(元素/分类)、dimension(实质/形式/意图)、names(名称列表)。")
@@ -124,19 +124,19 @@ def get_weight_score_by_name(level: str, dimension: str, names: list[str]) -> st
         "dimension": dimension,
         "dimension": dimension,
         "names": names,
         "names": names,
     }
     }
-    _log_tool_input("get_weight_score_by_name", params)
+    log_tool_input("get_weight_score_by_name", params)
 
 
     try:
     try:
         _validate_params(level=level, dimension=dimension)
         _validate_params(level=level, dimension=dimension)
         if not names:
         if not names:
-            return _log_tool_output("get_weight_score_by_name", "错误: names 不能为空列表")
+            return log_tool_output("get_weight_score_by_name", "错误: names 不能为空列表")
         if not isinstance(names, list):
         if not isinstance(names, list):
-            return _log_tool_output("get_weight_score_by_name", f"错误: names 必须为列表,当前类型: {type(names).__name__}")
+            return log_tool_output("get_weight_score_by_name", f"错误: names 必须为列表,当前类型: {type(names).__name__}")
 
 
         stripped: list[str] = []
         stripped: list[str] = []
         for i, n in enumerate(names):
         for i, n in enumerate(names):
             if n is None or (isinstance(n, str) and not n.strip()):
             if n is None or (isinstance(n, str) and not n.strip()):
-                return _log_tool_output(
+                return log_tool_output(
                     "get_weight_score_by_name",
                     "get_weight_score_by_name",
                     f"错误: names[{i}] 不能为空",
                     f"错误: names[{i}] 不能为空",
                 )
                 )
@@ -166,6 +166,6 @@ def get_weight_score_by_name(level: str, dimension: str, names: list[str]) -> st
             "query_count": len(stripped),
             "query_count": len(stripped),
             "results": results,
             "results": results,
         }
         }
-        return _log_tool_output("get_weight_score_by_name", json.dumps(result, ensure_ascii=False, indent=2))
+        return log_tool_output("get_weight_score_by_name", json.dumps(result, ensure_ascii=False, indent=2))
     except Exception as e:
     except Exception as e:
-        return _log_tool_output("get_weight_score_by_name", f"查询失败: {e}")
+        return log_tool_output("get_weight_score_by_name", f"查询失败: {e}")