Jelajahi Sumber

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 1 bulan lalu
induk
melakukan
e891fe6e7c

+ 2 - 1
.gitignore

@@ -62,6 +62,7 @@ output
 .trace_test/
 .trace_test2/
 examples/**/output*/
+examples/demand/test_output_data/
 
 frontend/htmlTemplate/mock_data
 frontend/react-template/yarn.lock
@@ -72,4 +73,4 @@ knowhub/knowhub.db-shm
 knowhub/knowhub.db-wal
 
 # 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 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(
@@ -23,7 +23,7 @@ def think_and_plan(thought: str, thought_number: int, action: str, plan: str) ->
         "action": action,
         "plan": plan,
     }
-    _log_tool_input("think_and_plan", params)
+    log_tool_input("think_and_plan", params)
 
     result = (
         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"(此工具仅用于记录思考过程,不会修改任何数据)"
     )
-    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
 from zoneinfo import ZoneInfo
 
-from odps import ODPS
-from odps.errors import ODPSError
 from datetime import date, datetime, timedelta
 import json
 from pathlib import Path
 
-from examples.demand.mysql import mysql_db
-
 
 def get_odps_data(sql):
+    from odps import ODPS
+    from odps.errors import ODPSError
+
     # 配置信息
     access_id = 'LTAI9EBa0bd5PrDa'
     access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
@@ -35,6 +34,9 @@ def get_odps_data(sql):
 
 
 def execute_odps_sql(sql) -> bool:
+    from odps import ODPS
+    from odps.errors import ODPSError
+
     # 配置信息
     access_id = 'LTAI9EBa0bd5PrDa'
     access_key = 'vAalxds7YxhfOA2yVv8GziCg3Y87v5'
@@ -85,26 +87,28 @@ def _parse_ext_data(ext_data_raw: object) -> dict:
     return {}
 
 
+def _get_mysql_db():
+    from examples.demand.mysql import mysql_db
+
+    return mysql_db
+
+
 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:
+    video_ids = row.get("video_list") or []
+    if not isinstance(video_ids, list):
+        video_ids = []
     return (
         "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"'{_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)
 
 
-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:
-        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:
         merge_leve2 = str(row.get("merge_leve2") 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_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()
-        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(
-            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()
-        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:
         return 0
 
     _insert_hive_select_parts(gap_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:
@@ -191,14 +250,14 @@ def write_feature_point_data_to_hive(names: list[str]) -> int:
     - 总分发曝光pv(固定 5000)
     - 质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
 
-    dt = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
     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 "
             f"'{safe_name}' AS `特征点`, "
@@ -216,7 +275,7 @@ PARTITION (dt='{dt}')
     ok = execute_odps_sql(insert_sql)
     if not ok:
         return 0
-    return len(normalized_names)
+    return len(output_rows)
 
 
 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():
+    mysql_db = _get_mysql_db()
     return mysql_db.select(
         "workflow_decode_task_result",
         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):
+    mysql_db = _get_mysql_db()
     return mysql_db.update(
         "workflow_decode_task_result",
         {"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.orm import sessionmaker, Session
@@ -120,6 +122,448 @@ def query_category_level(execution_id: int, name: str) -> int | None:
     finally:
         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()
 
 

+ 27 - 0
examples/demand/demand.md

@@ -58,6 +58,31 @@ $system$
 - `reason`: 产生该需求的理由
 - `desc`: 需求的描述
 - `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`),不允许凭空编造分类
 - 正确的创建顺序:先 element 后 category
 - 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`
 
 $user$

+ 46 - 17
examples/demand/demand_build_agent_tools.py

@@ -1,25 +1,48 @@
 import json
+import os
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 
 from agent import tool
 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:
-    """输出到“当前工作目录/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"
 
 
+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(
-    "存储需求到结果集。 - element_names - reason(原因)- desc(需求描述)- type(来源类型)"
+    "存储需求到结果集。 - element_names - reason(原因)- desc(需求描述)- type(来源类型)- evidence_refs(候选证据引用)"
 )
 def create_demand_item(
         element_names: List[str] = None,
         reason: str = None,
         desc: str = None,
-        type: str = None) -> str:
+        type: str = None,
+        evidence_refs: Optional[Dict[str, Any]] = None) -> str:
     """
     每次调用向“execution_id 对应的本地 JSON 文件”追加一条记录。
 
@@ -28,25 +51,29 @@ def create_demand_item(
       - reason(原因)
       - desc(需求描述)
       - type(来源类型)
+      - evidence_refs(候选证据引用,后续由 orchestration/校验逻辑决定是否能升级为 evidence_pack)
     """
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
+    normalized_evidence_refs = _normalize_evidence_refs(evidence_refs)
     params: Dict[str, Any] = {
         "execution_id": execution_id,
         "element_names": element_names,
         "reason": reason,
         "desc": desc,
         "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:
-        return _log_tool_output("create_demand_item", "错误: 未设置 execution_id")
+        return log_tool_output("create_demand_item", "错误: 未设置 execution_id")
 
     record: Dict[str, Any] = {
         "element_names": element_names,
         "reason": reason,
         "desc": desc,
-        "type": type
+        "type": type,
+        "evidence_refs": normalized_evidence_refs,
     }
 
     # 按 execution_id 区分文件,避免不同执行互相污染。
@@ -80,11 +107,11 @@ def create_demand_item(
         {"success": True, "execution_id": execution_id, "written_to": str(output_path)},
         ensure_ascii=False,
     )
-    return _log_tool_output("create_demand_item", result)
+    return log_tool_output("create_demand_item", result)
 
 
 @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:
     """
@@ -95,17 +122,18 @@ def create_demand_items(demand_items: List[Dict[str, Any]] = None) -> str:
       - reason(原因)
       - desc(需求描述)
       - type(来源类型)
+      - evidence_refs(候选证据引用)
     """
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
     params: Dict[str, Any] = {"execution_id": execution_id, "count": len(demand_items or []),
                               "demand_items": demand_items}
-    _log_tool_input("create_demand_items", params)
+    log_tool_input("create_demand_items", params)
 
     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):
-        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_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]] = []
     for i, di in enumerate(demand_items):
         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 = {
             "element_names": di.get("element_names"),
             "reason": di.get("reason"),
             "desc": di.get("desc"),
             "type": di.get("type"),
+            "evidence_refs": _normalize_evidence_refs(di.get("evidence_refs")),
         }
         written_records.append(record)
 
@@ -150,7 +179,7 @@ def create_demand_items(demand_items: List[Dict[str, Any]] = None) -> str:
         },
         ensure_ascii=False,
     )
-    return _log_tool_output("create_demand_items", result)
+    return log_tool_output("create_demand_items", result)
 
 
 @tool(
@@ -170,8 +199,8 @@ def write_execution_summary(summary: str) -> str:
     """
     execution_id: Optional[int] = TopicBuildAgentContext.get_execution_id()
     params: Dict[str, str] = {"summary": summary}
-    _log_tool_input("write_execution_summary", params)
+    log_tool_input("write_execution_summary", params)
     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)
-    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 示例的最小可运行入口。"""
 import asyncio
 import copy
+import hashlib
 import importlib
 import json
 import os
 import sys
 from datetime import datetime
 from pathlib import Path
-from typing import Optional
+from typing import Any, Optional
 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 代理
 os.environ.setdefault("no_proxy", "*")
 # 该示例仅使用项目侧能力,禁用框架内置 skills
@@ -32,21 +21,20 @@ os.environ.setdefault("AGENT_STRICT_TOOL_SELECTION", "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 路径
 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
 
 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.config import DEBUG, LOG_FILE, LOG_LEVEL, RUN_CONFIG, TRACE_STORE_PATH
-
 CUSTOM_TOOL_MODULES = {
     # demand 示例:严格按工具名白名单加载对应模块
     "think_and_plan": "examples.demand.agent_tools",
@@ -68,6 +56,80 @@ CUSTOM_TOOL_MODULES = {
     "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):
     """根据二级品类和平台查询最新的 execution_id。"""
@@ -86,14 +148,14 @@ def get_execution_id_by_merge_level2(cluster_name: str):
         session.close()
 
 
-def resolve_model(prompt: SimplePrompt) -> str:
+def resolve_model(prompt: Any, run_config: Any) -> str:
     model_from_prompt = prompt.config.get("model")
     if 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":
         return ""
     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)
 
 
+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(
         execution_id: int,
         name: Optional[str] = None,
         platform: Optional[str] = None,
 ) -> Optional[int]:
     """创建 demand_task 记录,返回任务ID。"""
+    if _is_local_json_mode():
+        raise RuntimeError("local_json 模式禁止调用生产 _create_demand_task")
+
     try:
+        from examples.demand.mysql import mysql_db
+
         # 数据库字段 demand_task.name: varchar(32)
         if name is not None:
             name = str(name)[:32]
@@ -224,7 +511,11 @@ def _finish_demand_task(task_id: Optional[int], status: int, task_log: str) -> N
     """更新 demand_task 状态与日志。"""
     if not task_id:
         return
+    if _is_local_json_mode():
+        raise RuntimeError("local_json 模式禁止调用生产 _finish_demand_task")
     try:
+        from examples.demand.mysql import mysql_db
+
         mysql_db.update(
             "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}")
 
 
-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:
-        log("[mysql] 生成行为空,跳过写入")
         return 0
 
     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
 
         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}")
     except Exception as 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)
 
 
+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:
     """
     把 result/{execution_id}/execution_id_{execution_id}_demand_items.json
     写入 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
 
     names: list[str] = []
@@ -393,24 +713,123 @@ def write_global_tree_demand_items_to_hive(execution_id: int, merge_level2: str)
         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_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_metadata("result_base_dir", str(_get_result_base_dir()))
 
     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)
     register_selected_tools(ENABLED_TOOLS)
 
     prompt = SimplePrompt(base_dir / "demand.md")
 
-    model = resolve_model(prompt)
-
     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.max_iterations = int(prompt.config.get("max_iterations", run_config.max_iterations))
     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)
 
-    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(
         trace_store=store,
         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 = ""
     total_tokens = 0
     total_cost = 0.0
+    trace_id: Optional[str] = None
     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.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:
             async for item in runner.run(messages=initial_messages, config=run_config):
                 if isinstance(item, Trace):
+                    trace_id = getattr(item, "trace_id", None) or trace_id
                     if getattr(item, "status", None) == "completed":
                         total_tokens = int(getattr(item, "total_tokens", 0) or 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
             try:
                 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:
                     # 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:
+                result_write_error = str(e)
                 log(f"[result-write] 写入结果异常:{e}")
+                if _is_local_json_mode():
+                    task_status = 2
 
             task_log_text = log_buffer.getvalue()
-            task_status = 1
+            if task_status != 2:
+                task_status = 1
     except Exception as e:
         if not task_log_text:
             # 如果异常发生在 build_log 内部,尽量回收已产生的日志
@@ -498,9 +948,34 @@ async def run_once(execution_id, merge_level2, count: int = 30, task_id: Optiona
             except Exception:
                 # 兜底:即使写文件失败,也要确保 MySQL 状态被更新
                 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(
@@ -510,20 +985,58 @@ async def main(
         execution_id: Optional[int] = None,
         task_id: Optional[int] = None,
 ) -> 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 platform_type == "piaoquan":
+            from examples.demand.piaoquan_prepare import piaoquan_prepare
+
             execution_id = piaoquan_prepare(cluster_name)
         elif platform_type == "changwen":
+            from examples.demand.changwen_prepare import changwen_prepare
+
             execution_id = changwen_prepare(cluster_name)
         elif platform_type == "zengzhang":
+            from examples.demand.zengzhang_prepare import zengzhang_prepare
+
             execution_id = zengzhang_prepare(cluster_name)
         else:
             execution_id = None
     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__":

+ 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 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_DIMENSIONS = {"实质", "形式", "意图"}
@@ -71,17 +71,17 @@ def get_weight_score_topn(level: str, dimension: str, start: int = 1, end: int =
         "start": start,
         "end": end,
     }
-    _log_tool_input("get_weight_score_topn", params)
+    log_tool_input("get_weight_score_topn", params)
 
     try:
         _validate_params(level=level, dimension=dimension)
         if start < 1 or end < 1:
-            return _log_tool_output(
+            return log_tool_output(
                 "get_weight_score_topn",
                 f"错误: start/end 必须为大于等于 1 的整数,当前值: start={start}, end={end}",
             )
         if start > end:
-            return _log_tool_output(
+            return log_tool_output(
                 "get_weight_score_topn",
                 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),
             "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:
-        return _log_tool_output("get_weight_score_topn", f"查询失败: {e}")
+        return log_tool_output("get_weight_score_topn", f"查询失败: {e}")
 
 
 @tool("批量查询指定名称的权重分。参数:level(元素/分类)、dimension(实质/形式/意图)、names(名称列表)。")
@@ -124,19 +124,19 @@ def get_weight_score_by_name(level: str, dimension: str, names: list[str]) -> st
         "dimension": dimension,
         "names": names,
     }
-    _log_tool_input("get_weight_score_by_name", params)
+    log_tool_input("get_weight_score_by_name", params)
 
     try:
         _validate_params(level=level, dimension=dimension)
         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):
-            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] = []
         for i, n in enumerate(names):
             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",
                     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),
             "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:
-        return _log_tool_output("get_weight_score_by_name", f"查询失败: {e}")
+        return log_tool_output("get_weight_score_by_name", f"查询失败: {e}")