xueyiming vor 10 Stunden
Ursprung
Commit
0ada1b4b17
91 geänderte Dateien mit 8243 neuen und 611 gelöschten Zeilen
  1. 13 0
      .env.example
  2. 6 0
      Dockerfile
  3. 19 20
      PRD.md
  4. 15 24
      README.md
  5. 105 17
      agents/find_agent/demand_run.py
  6. 6 1
      alembic/env.py
  7. 26 16
      alembic/versions/20260730_07_add_feedback_username.py
  8. 31 10
      alembic/versions/20260731_08_add_find_agent_p0_gates.py
  9. 277 0
      alembic/versions/20260731_08a_create_video_discovery_tables.py
  10. 71 0
      alembic/versions/20260731_09_transactional_aigc_outbox.py
  11. 288 0
      alembic/versions/20260731_10_platform_demand_mvp.py
  12. 243 0
      alembic/versions/20260731_11_daily_demand_package.py
  13. 296 0
      alembic/versions/20260731_12_feedback_attribution_strategy.py
  14. 197 0
      alembic/versions/20260731_13_version_demand_belong_rel.py
  15. 31 0
      alembic/versions/20260731_14_bootstrap_empty_database.py
  16. 22 2
      api/app.py
  17. 2 0
      api/auth_middleware.py
  18. 126 0
      api/routers/business_harness.py
  19. 1 0
      api/routers/pipeline.py
  20. 1 0
      api/schemas/pipeline.py
  21. 420 0
      api/services/business_harness.py
  22. 51 0
      api/services/demand_feedback.py
  23. 2 0
      api/services/pipeline.py
  24. 20 1
      deploy/README.md
  25. 0 1
      problem/2026-07-24_问题总结.md
  26. 284 0
      problem/2026-07-31_后续工作计划.md
  27. 237 0
      problem/2026-07-31_实施与上线验收.md
  28. 4 0
      pyproject.toml
  29. 4 0
      scripts/container-entrypoint.sh
  30. 170 0
      scripts/mysql_schema_migration_smoke.py
  31. 37 0
      scripts/release_gate.sh
  32. 2 2
      scripts/upload_logs_to_oss.py
  33. 0 1
      scripts/verify_video_truncate_runtime.py
  34. 1 1
      scripts/visualize_run.py
  35. 28 5
      supply_infra/aigc/client.py
  36. 25 0
      supply_infra/business_harness/__init__.py
  37. 397 0
      supply_infra/business_harness/content_feedback.py
  38. 659 0
      supply_infra/business_harness/evaluate.py
  39. 58 0
      supply_infra/business_harness/feedback.py
  40. 423 0
      supply_infra/business_harness/materialize.py
  41. 248 0
      supply_infra/business_harness/strategy.py
  42. 150 0
      supply_infra/category_match.py
  43. 24 0
      supply_infra/config.py
  44. 34 2
      supply_infra/db/models/__init__.py
  45. 635 0
      supply_infra/db/models/business_harness.py
  46. 65 1
      supply_infra/db/models/demand_belong_pool_rel.py
  47. 22 0
      supply_infra/db/models/demand_feedback.py
  48. 11 3
      supply_infra/db/models/global_tree_category.py
  49. 7 1
      supply_infra/db/models/pipeline_outbox.py
  50. 13 0
      supply_infra/db/models/video_discovery.py
  51. 0 2
      supply_infra/db/repositories/__init__.py
  52. 31 3
      supply_infra/db/repositories/demand_belong_category_repo.py
  53. 29 3
      supply_infra/db/repositories/demand_belong_pool_rel_repo.py
  54. 56 1
      supply_infra/db/repositories/global_tree_category_repo.py
  55. 52 5
      supply_infra/db/repositories/multi_demand_pool_di_repo.py
  56. 17 1
      supply_infra/db/repositories/pipeline_lock_repo.py
  57. 70 3
      supply_infra/db/repositories/pipeline_outbox_repo.py
  58. 6 27
      supply_infra/odps/client.py
  59. 330 0
      supply_infra/pipeline/aigc_outbox.py
  60. 6 0
      supply_infra/pipeline/cli.py
  61. 1 0
      supply_infra/pipeline/contracts.py
  62. 4 1
      supply_infra/pipeline/dag.py
  63. 7 2
      supply_infra/pipeline/enums.py
  64. 6 5
      supply_infra/pipeline/gates.py
  65. 78 1
      supply_infra/pipeline/health.py
  66. 1 0
      supply_infra/pipeline/orchestrator.py
  67. 230 0
      supply_infra/pipeline/preflight.py
  68. 13 2
      supply_infra/pipeline/reconciler.py
  69. 34 74
      supply_infra/pipeline/registry.py
  70. 14 6
      supply_infra/pipeline/run_service.py
  71. 1 0
      supply_infra/pipeline/step_cli.py
  72. 23 0
      supply_infra/pipeline/worker.py
  73. 41 9
      supply_infra/scheduler/jobs/demand_pool/belong_rel.py
  74. 164 60
      supply_infra/scheduler/jobs/demand_pool/sync.py
  75. 6 1
      supply_infra/scheduler/jobs/discover_videos_from_demands.py
  76. 159 84
      supply_infra/scheduler/jobs/publish_videos_from_discovery.py
  77. 133 190
      supply_infra/scheduler/jobs/sync_global_tree_odps_to_mysql.py
  78. 2 1
      supply_infra/scheduler/plan_group_batch.py
  79. 3 0
      tests/api/test_demand_feedback.py
  80. 213 0
      tests/supply_infra/pipeline/test_aigc_outbox.py
  81. 46 0
      tests/supply_infra/pipeline/test_aigc_safety.py
  82. 22 5
      tests/supply_infra/pipeline/test_dag_and_gates.py
  83. 118 0
      tests/supply_infra/pipeline/test_full_flow_contract.py
  84. 38 1
      tests/supply_infra/pipeline/test_health.py
  85. 29 0
      tests/supply_infra/pipeline/test_orchestrator.py
  86. 62 0
      tests/supply_infra/pipeline/test_preflight.py
  87. 70 0
      tests/supply_infra/scheduler/test_demand_belong_rel.py
  88. 138 0
      tests/supply_infra/scheduler/test_sync_global_tree.py
  89. 169 11
      tests/supply_infra/scheduler/test_sync_multi_demand_pool.py
  90. 8 1
      web/src/types/pipeline.ts
  91. 6 4
      web/src/views/GlobalDemandMapView.vue

+ 13 - 0
.env.example

@@ -58,6 +58,7 @@ ODPS_PROJECT=
 ODPS_ENDPOINT=https://service.cn.maxcompute.aliyun.com/api
 
 # Scheduler(独立进程;供给流水线每天 15:00 Asia/Shanghai 触发)
+# 生产必须改为 true;容器启动预检会阻止 false 配置上线。
 SCHEDULER_ENABLED=false
 SCHEDULER_TIMEZONE=Asia/Shanghai
 SCHEDULER_CRON_HOUR=15
@@ -72,10 +73,16 @@ FIND_AGENT_MIN_ACCOUNT_50_PLUS_RATIO=0.20
 FIND_AGENT_FESTIVAL_LEAD_DAYS=7
 FIND_AGENT_EVENT_MAX_AGE_DAYS=7
 FIND_AGENT_SEASONAL_MAX_AGE_DAYS=180
+CATEGORY_MATCH_WORKERS=16
 
 # Pipeline control plane
 PROCESS_ROLE=api
 DATABASE_AUTO_CREATE=false
+RUN_DB_MIGRATIONS=true
+RUN_RUNTIME_PREFLIGHT=true
+PIPELINE_REQUIRE_SCHEDULER=true
+# HTTPS 生产设为 true,同时必须设置 AUTH_COOKIE_SECURE=true。
+PIPELINE_REQUIRE_SECURE_COOKIE=false
 PIPELINE_WORKER_PROCESSES=4
 PIPELINE_MAX_ACTIVE_STEPS=4
 PIPELINE_WORKER_POLL_SECONDS=2
@@ -100,5 +107,11 @@ ALIYUN_OSS_PUBLIC_BASE_URL=http://rescdn.yishihui.com
 ALIYUN_OSS_CONNECT_TIMEOUT_SECONDS=30
 LOG_OSS_UPLOAD_ENABLED=true
 
+# 分类树路径匹配(每次请求只发送一个 term)
+CATEGORY_MATCH_API_URL=https://library.aiddit.com/api/search/categories/match-paths/v2
+CATEGORY_MATCH_TIMEOUT_SECONDS=60
+# 新分区有效节点少于现有活动树的 50% 时拒绝同步,避免部分分区误退役整棵树。
+GLOBAL_CATEGORY_MIN_RETENTION_RATIO=0.5
+
 # AIGC platform (视频爬取/发布计划;需配置 token,默认真实调用接口)
 AIGC_API_TOKEN=

+ 6 - 0
Dockerfile

@@ -30,6 +30,9 @@ ENV PYTHONUNBUFFERED=1 \
     SCHEDULER_CRON_MINUTE=0 \
     PIPELINE_WORKER_PROCESSES=4 \
     PIPELINE_MAX_ACTIVE_STEPS=4 \
+    PIPELINE_REQUIRE_SCHEDULER=true \
+    PIPELINE_REQUIRE_SECURE_COOKIE=true \
+    RUN_RUNTIME_PREFLIGHT=true \
     FIND_AGENT_TIMEOUT_SECONDS=600 \
     MYSQL_CONNECTION_BUDGET=40
 
@@ -71,6 +74,9 @@ COPY --from=web-builder /app/web/dist ./web/dist
 
 EXPOSE 8080
 
+HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
+    CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=4).read()" || exit 1
+
 STOPSIGNAL SIGTERM
 
 ENTRYPOINT ["/app/scripts/container-entrypoint.sh"]

+ 19 - 20
PRD.md

@@ -73,7 +73,8 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 | 运维/开发人员 | 观察任务是否运行、定位失败、手动补偿 | Scheduler 状态接口、日志、`python -m supply_infra.scheduler.jobs.*` |
 | ODPS | 提供分类树、需求池、视频解析、ROV/VOV | 定时查询 |
 | MySQL | 保存业务快照、关系、执行状态和候选 | 全流程状态底座 |
-| OpenRouter/模型服务 | 执行归类、分级、拓展和找视频判断 | Agent Tool Calling |
+| 分类路径服务 | 逐词返回全局分类树 `stable_id` 和路径 | `match-paths/v2` |
+| OpenRouter/模型服务 | 执行分级、拓展和找视频判断 | Agent Tool Calling |
 | 抖音搜索/画像/TikHub/千问 | 提供找视频、详情、画像和内容解析证据 | `find_agent` 外部工具 |
 | OSS | 保存 Agent `.log`、`.jsonl` 和 HTML 可视化 | 每次 Agent 运行后上传 |
 | AIGC 平台 | 创建视频爬取计划并绑定生产计划 | 流水线末端调用 |
@@ -85,7 +86,6 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 | 对象 | 说明 | 核心数据表 |
 |---|---|---|
 | 全局分类树 | 稳定的内容分类骨架 | `global_tree_category` |
-| 树下元素 | ODPS 提取的实质元素及挂靠 | `global_tree_element` |
 | 策略需求池 | 每日多策略需求原始行 | `multi_demand_pool_di` |
 | 需求归类词 | 由需求名称拆出的词及单一挂靠节点 | `demand_belong_category` |
 | 需求池匹配边 | 归类词与需求池行的子串关系 | `demand_belong_pool_rel` |
@@ -167,32 +167,30 @@ flowchart LR
 
 ### 7.1 输入
 
-- ODPS `pattern_mining_element` 的 T-1 分区;
-- ODPS `public_pattern_mining_category` 的 T-1 分区;
-- 固定过滤 `execution_id=401`、`source_type/element_type=实质` 等条件。
+- ODPS `global_category` 的 T-1 分区;
+- 固定过滤 `retired_at_execution_id IS NULL`、`source_type=实质`。
 
 ### 7.2 处理
 
-1. 拉取有效元素及其 `category_id`;
-2. 拉取全量分类;
-3. 从元素命中的分类向上补齐全部祖先;
-4. 按 ODPS `source_id` 去重;
-5. 对 MySQL 不存在的分类分配本地 ID;
-6. 转换父节点 ID 后 `INSERT IGNORE`;
-7. 重载 ID 映射并写入元素。
+1. 拉取 `stable_id/name/description/level/parent_stable_id`;
+2. 按 `stable_id` 去重;
+3. 对 MySQL 不存在的分类分配稳定的本地 ID;
+4. 把 `parent_stable_id` 转换为本地 `parent_id`;
+5. upsert 名称、描述、层级和父节点;
+6. 将当日有效树中已不存在的历史节点软删除。
 
 ### 7.3 输出
 
 - `global_tree_category`;
-- `global_tree_element`;
-- 本阶段的拉取、插入、跳过和失败数量。
+- 本阶段的拉取、新增、刷新、退役和失败数量。
 
 ### 7.4 当前行为边界
 
-- 已存在分类不会更新名称、描述、层级和父节点;
-- ODPS 已删除或迁移的节点不会在 MySQL 自动软删除;
-- 已有元素也只追加,不校正旧挂靠;
-- 缺失父节点的分类会被跳过,但不会阻断后续流水线。
+- `global_tree_element` 暂不再读取或写入,物理表仅为历史兼容保留;
+- 空分类分区会直接失败,避免把整棵树误标记为删除;
+- 新分区有效节点少于现有活动树的 50% 时默认拒绝写入,阈值可通过
+  `GLOBAL_CATEGORY_MIN_RETENTION_RATIO` 调整;
+- 缺失父节点的分类不会写入正式树,并计入失败数量。
 
 ---
 
@@ -220,8 +218,9 @@ flowchart LR
 1. 查询当日全部 `demand_name`;
 2. 使用空格拆词,汇总为去重词集合;
 3. 过滤 `demand_belong_category` 已存在的名称;
-4. 每 100 个词调用一次 `demand_belong_category_agent`;
-5. Agent 查询真实分类树,选择分类节点并写入名称、节点和 reason。
+4. 每次只发送一个词到 `match-paths/v2`,固定 `top_k=1`、`min_score=0.8`;
+5. 将响应 `stable_id` 映射为本地分类 ID,保存路径、分数和 stable_id 到 reason;
+6. 空匹配保守跳过;接口错误或未知 stable_id 会阻断下游。
 
 当前表以 `name` 唯一,所以一个词实际上只能保存一个分类节点。
 

+ 15 - 24
README.md

@@ -1715,33 +1715,27 @@ Skill 机制读取指定目录下的 `SKILL.md`:
 
 ---
 
-## 5. 三个业务 Agent
+## 5. 业务 Agent 与分类路径服务
 
-## 5.1 `demand_belong_category_agent`
+## 5.1 分类路径匹配接口
 
-目标:把需求词挂到真实存在的全局分类树节点。
+需求词挂树不再调用 `demand_belong_category_agent`。流水线逐词调用:
 
-工具:
-
-- `query_global_tree_category`:读取整棵树或指定子树;
-- `batch_insert_demand_belong_category`:批量写入需求词、分类 ID 和原因。
-
-业务流程:
+`POST https://library.aiddit.com/api/search/categories/match-paths/v2`
 
-1. 查看顶层分类;
-2. 选择最相关分支;
-3. 逐层下钻;
-4. 不确定时停在更可靠的上层节点;
-5. 将结果写入 `demand_belong_category`。
+每个请求的 `items` 只包含一个词,固定使用:
 
-系统提示允许一个词选择一个或多个高置信节点,但当前数据库和写入工具以 `name` 唯一:
+- `source_type=实质`;
+- `top_k=1`;
+- `min_score=0.8`;
+- `description=""`。
 
-- `demand_belong_category.name` 有唯一约束;
-- 同一次请求也按 name 去重;
-- 因此当前实现实际上只能保存“一词一个分类节点”;
-- 多挂靠尚未在当前表结构中实现。
+响应中的 `stable_id` 必须存在于当日同步的 `global_tree_category.source_id`,
+再转换为本地 `category_id` 写入 `demand_belong_category`。空匹配保守跳过;
+HTTP/响应错误或未知 `stable_id` 会令归类步骤失败并阻断下游。
 
-定时任务会把新需求词按 100 个一批交给该 Agent。
+分类树同步还会检查有效节点数量。新分区少于现有活动树的 50% 时默认拒绝写入,
+防止部分分区误退役整棵树;该比例由 `GLOBAL_CATEGORY_MIN_RETENTION_RATIO` 配置。
 
 ## 5.2 `generate_demand_agent`
 
@@ -1864,8 +1858,7 @@ ODPS 查询仍在 `supply_infra/odps/client.py` 内直接组织 SQL。
 
 当前读取的主要 ODPS 数据:
 
-- `public_pattern_mining_category`:全局分类;
-- `pattern_mining_element`:树下元素;
+- `global_category`:有效的“实质”全局分类树;
 - `dwd_multi_demand_pool_di`:多策略需求池;
 - `dwd_topic_decode_result_di`:视频解析和最终选题;
 - `dwd_video_produce_plan_stat_hour`:真实 ROV/VOV。
@@ -1887,7 +1880,6 @@ ODPS 查询仍在 `supply_infra/odps/client.py` 内直接组织 SQL。
 | 表 | 作用 | 关键唯一性/关联 |
 |---|---|---|
 | `global_tree_category` | 全局分类树节点 | `source_id` 唯一;`parent_id` 形成树 |
-| `global_tree_element` | ODPS 元素与分类挂靠 | `(name, category_id)` 唯一 |
 | `multi_demand_pool_di` | 按日同步的策略需求池 | 代码按 `(strategy, demand_id, biz_dt)` 管理差异 |
 | `demand_belong_category` | 需求词归属分类 | `name` 唯一;当前一词只能保存一个分类 |
 | `demand_belong_pool_rel` | 需求词与需求池行的匹配边 | `(demand_belong_category_id, multi_demand_pool_di_id)` 唯一 |
@@ -1902,7 +1894,6 @@ ODPS 查询仍在 `supply_infra/odps/client.py` 内直接组织 SQL。
 ```text
 global_tree_category.id
   ├── global_tree_category.parent_id
-  ├── global_tree_element.category_id
   ├── demand_belong_category.category_id
   ├── category_tree_weight.category_id
   └── generated_demand.category_id

+ 105 - 17
agents/find_agent/demand_run.py

@@ -10,10 +10,17 @@ from datetime import datetime
 from typing import Any
 from zoneinfo import ZoneInfo
 
+from sqlalchemy import select
+
 from supply_infra.video_discovery_gates import build_rule_snapshot
 from supply_agent.types import AgentResult
 from supply_infra.config import get_infra_settings
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
+from supply_infra.db.models.business_harness import (
+    DailyDemandPackage,
+    DailyDemandTask,
+    PlatformDemandVersion,
+)
 from supply_infra.db.repositories.demand_video_expansion_repo import (
     DemandVideoExpansionRepository,
 )
@@ -48,6 +55,11 @@ class FindDemandContext:
     demand_grade_id: int
     demand_name: str
     grade: str
+    platform_demand_id: str | None = None
+    platform_demand_version_id: str | None = None
+    demand_package_id: str | None = None
+    daily_demand_task_id: str | None = None
+    task_payload: dict[str, Any] | None = None
     videos: list[FindDemandVideo] = field(default_factory=list)
 
     @property
@@ -144,24 +156,78 @@ def load_find_demand_contexts(
     *,
     grades: Iterable[str] = ("S", "A"),
     top_limit: int | None = None,
+    package_run_id: str | None = None,
 ) -> list[FindDemandContext]:
-    """加载指定业务日全部 S/A 需求(可选 top_limit 截断),每个需求词组装为一条完整上下文。"""
-    grades_rows = DemandGradeRepository(session).list_by_biz_dt_and_grades(biz_dt, grades)
-    if not grades_rows:
-        return []
-
-    grades_rows = sorted(grades_rows, key=_grade_priority_key)
+    """从唯一已发布任务包加载需求;不再由 Find Agent 自行解释等级。"""
+    package_stmt = select(DailyDemandPackage).where(
+        DailyDemandPackage.biz_dt == biz_dt,
+        DailyDemandPackage.status == "published",
+    )
+    if package_run_id:
+        package_stmt = package_stmt.where(
+            DailyDemandPackage.run_id == package_run_id
+        )
+    package = session.scalar(
+        package_stmt.order_by(
+            DailyDemandPackage.package_version.desc(),
+            DailyDemandPackage.created_at.desc(),
+        ).limit(1)
+    )
+    if package is None:
+        raise RuntimeError(
+            f"biz_dt={biz_dt} 没有已发布的统一需求任务包,禁止回退拼接 DemandGrade"
+        )
+    task_rows = session.execute(
+        select(DailyDemandTask, PlatformDemandVersion)
+        .join(
+            PlatformDemandVersion,
+            PlatformDemandVersion.platform_demand_version_id
+            == DailyDemandTask.platform_demand_version_id,
+        )
+        .where(
+            DailyDemandTask.demand_package_id == package.demand_package_id,
+            DailyDemandTask.action_tier.in_(
+                (
+                    "assure_supply",
+                    "priority",
+                    "targeted_validation",
+                    "exploration",
+                )
+            ),
+        )
+    ).all()
+    tier_order = {
+        "assure_supply": 0,
+        "priority": 1,
+        "targeted_validation": 2,
+        "exploration": 3,
+    }
+    task_rows = sorted(
+        task_rows,
+        key=lambda pair: (
+            tier_order.get(str(pair[0].action_tier), 99),
+            -float(pair[0].task_payload_json.get("local_supply_priority") or 0),
+            str(pair[1].name),
+        ),
+    )
     if top_limit is not None and top_limit > 0:
-        grades_rows = grades_rows[: int(top_limit)]
+        task_rows = task_rows[: int(top_limit)]
 
     expansion_repo = DemandVideoExpansionRepository(session)
     detail_repo = MultiDemandVideoDetailRepository(session)
 
     contexts: list[FindDemandContext] = []
-    for grade_row in grades_rows:
+    for task_row, platform_version in task_rows:
+        grade_row = session.get(
+            DemandGradeRepository.model,
+            int(platform_version.source_demand_grade_id),
+        )
+        if grade_row is None:
+            raise RuntimeError(
+                "统一任务包引用的 DemandGrade 不存在: "
+                f"{platform_version.source_demand_grade_id}"
+            )
         expansions = expansion_repo.list_by_demand_grade(biz_dt, int(grade_row.id))
-        if not expansions:
-            continue
 
         by_video: dict[str, list[Any]] = {}
         video_order: list[str] = []
@@ -174,9 +240,6 @@ def load_find_demand_contexts(
                 video_order.append(video_id)
             by_video[video_id].append(row)
 
-        if not video_order:
-            continue
-
         details = detail_repo.list_by_vids(video_order)
         videos: list[FindDemandVideo] = []
         for video_id in video_order:
@@ -194,15 +257,19 @@ def load_find_demand_contexts(
                 )
             )
 
-        if not videos:
-            continue
-
         contexts.append(
             FindDemandContext(
                 biz_dt=biz_dt,
                 demand_grade_id=int(grade_row.id),
                 demand_name=str(grade_row.demand_name),
                 grade=str(grade_row.grade),
+                platform_demand_id=platform_version.platform_demand_id,
+                platform_demand_version_id=(
+                    platform_version.platform_demand_version_id
+                ),
+                demand_package_id=package.demand_package_id,
+                daily_demand_task_id=task_row.task_id,
+                task_payload=dict(task_row.task_payload_json),
                 videos=videos,
             )
         )
@@ -216,6 +283,7 @@ def pick_find_demand_context(
     index: int = 0,
     demand_grade_id: int | None = None,
     grades: Iterable[str] = ("S", "A"),
+    package_run_id: str | None = None,
 ) -> FindDemandContext | None:
     """从数据库选取一条待执行的 find_agent 上下文。"""
     resolved_biz_dt = _resolve_biz_dt(biz_dt)
@@ -224,6 +292,7 @@ def pick_find_demand_context(
             session,
             resolved_biz_dt,
             grades=grades,
+            package_run_id=package_run_id,
         )
 
     if demand_grade_id is not None:
@@ -242,6 +311,7 @@ def list_find_demand_contexts(
     *,
     grades: Iterable[str] = ("S", "A"),
     top_limit: int | None = None,
+    package_run_id: str | None = None,
 ) -> tuple[str, list[FindDemandContext]]:
     """返回解析后的业务日与待执行上下文(默认当日全部 S/A,可选 top_limit)。"""
     resolved_biz_dt = _resolve_biz_dt(biz_dt)
@@ -251,6 +321,7 @@ def list_find_demand_contexts(
             resolved_biz_dt,
             grades=grades,
             top_limit=top_limit,
+            package_run_id=package_run_id,
         )
     return resolved_biz_dt, contexts
 
@@ -262,6 +333,11 @@ def build_run_input_payload(ctx: FindDemandContext) -> dict[str, Any]:
         "demand_grade_id": ctx.demand_grade_id,
         "demand_name": ctx.demand_name,
         "grade": ctx.grade,
+        "platform_demand_id": ctx.platform_demand_id,
+        "platform_demand_version_id": ctx.platform_demand_version_id,
+        "demand_package_id": ctx.demand_package_id,
+        "daily_demand_task_id": ctx.daily_demand_task_id,
+        "daily_demand_task": ctx.task_payload,
         "relevant_points": _flatten_relevant_points(ctx),
         "reference_videos": [_serialize_video(video) for video in ctx.videos],
     }
@@ -280,6 +356,9 @@ def prepare_video_discovery_run(
         "run_id": uuid.uuid4().hex,
         "biz_dt": ctx.biz_dt,
         "demand_grade_id": ctx.demand_grade_id,
+        "demand_package_id": ctx.demand_package_id,
+        "daily_demand_task_id": ctx.daily_demand_task_id,
+        "platform_demand_version_id": ctx.platform_demand_version_id,
         "demand_word": ctx.demand_name,
         "seed_video_id": primary.video_id if primary else None,
         "seed_video_title": primary.title if primary else None,
@@ -356,13 +435,17 @@ def build_find_agent_user_input(
         f"run_id:{run_id}\n"
         f"demand_grade_id:{ctx.demand_grade_id}\n"
         f"demand_word:{ctx.demand_name}\n"
+        f"platform_demand_id:{ctx.platform_demand_id}\n"
+        f"platform_demand_version_id:{ctx.platform_demand_version_id}\n"
+        f"demand_package_id:{ctx.demand_package_id}\n"
+        f"daily_demand_task:{json.dumps(ctx.task_payload or {}, ensure_ascii=False)}\n"
         f"current_datetime:{active_rules['current_datetime']}\n"
         f"current_date:{active_rules['current_date']}\n"
         f"timezone:{active_rules['timezone']}\n"
         f"quality_gate_rules:{json.dumps(active_rules, ensure_ascii=False)}\n"
         f"reference_videos:{json.dumps(videos_payload, ensure_ascii=False)}\n"
         "说明:video_discovery_run 已由系统预创建,无需也不得由模型再次创建。\n"
-        "请直接依据 reference_videos 中各视频的 title 和 points 理解需求并开始搜索;"
+        "请以 daily_demand_task 为唯一业务契约,结合 reference_videos 理解需求并开始搜索;"
         f"后续所有存储和状态工具都必须使用预创建的 run_id={run_id}。"
     )
 
@@ -429,6 +512,11 @@ def serialize_find_demand_context(ctx: FindDemandContext) -> dict[str, Any]:
         "demand_grade_id": ctx.demand_grade_id,
         "demand_name": ctx.demand_name,
         "grade": ctx.grade,
+        "platform_demand_id": ctx.platform_demand_id,
+        "platform_demand_version_id": ctx.platform_demand_version_id,
+        "demand_package_id": ctx.demand_package_id,
+        "daily_demand_task_id": ctx.daily_demand_task_id,
+        "task_payload": ctx.task_payload,
         "video_count": ctx.video_count,
         "point_count": ctx.point_count,
         "videos": [_serialize_video(video) for video in ctx.videos],

+ 6 - 1
alembic/env.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import os
 from logging.config import fileConfig
 
 from alembic import context
@@ -13,10 +14,14 @@ config = context.config
 if config.config_file_name is not None:
     fileConfig(config.config_file_name)
 
+# CI and migration smoke tests may supply an isolated database URL without
+# changing the application's runtime MySQL configuration.
+database_url = os.environ.get("ALEMBIC_DATABASE_URL") or get_infra_settings().mysql_url
+
 # Alembic uses ConfigParser interpolation; escaped credentials may contain "%".
 config.set_main_option(
     "sqlalchemy.url",
-    get_infra_settings().mysql_url.replace("%", "%%"),
+    database_url.replace("%", "%%"),
 )
 target_metadata = Base.metadata
 

+ 26 - 16
alembic/versions/20260730_07_add_feedback_username.py

@@ -28,24 +28,34 @@ def upgrade() -> None:
             comment="反馈人登录账号快照",
         ),
     )
-    op.execute(
-        sa.text(
-            """
-            UPDATE demand_feedback AS feedback
-            JOIN auth_user AS user_account
-              ON user_account.id = feedback.feedback_user_id
-            SET feedback.feedback_username_snapshot = user_account.username
-            WHERE feedback.feedback_username_snapshot = ''
-            """
-        )
-    )
-    op.alter_column(
+    feedback = sa.table(
         "demand_feedback",
-        "feedback_username_snapshot",
-        existing_type=sa.String(length=64),
-        server_default=None,
+        sa.column("feedback_user_id", sa.Integer()),
+        sa.column("feedback_username_snapshot", sa.String(length=64)),
+    )
+    user_account = sa.table(
+        "auth_user",
+        sa.column("id", sa.Integer()),
+        sa.column("username", sa.String(length=64)),
+    )
+    username = (
+        sa.select(user_account.c.username)
+        .where(user_account.c.id == feedback.c.feedback_user_id)
+        .scalar_subquery()
     )
+    op.execute(
+        feedback.update()
+        .where(feedback.c.feedback_username_snapshot == "")
+        .values(feedback_username_snapshot=username)
+    )
+    with op.batch_alter_table("demand_feedback") as batch_op:
+        batch_op.alter_column(
+            "feedback_username_snapshot",
+            existing_type=sa.String(length=64),
+            server_default=None,
+        )
 
 
 def downgrade() -> None:
-    op.drop_column("demand_feedback", "feedback_username_snapshot")
+    with op.batch_alter_table("demand_feedback") as batch_op:
+        batch_op.drop_column("feedback_username_snapshot")

+ 31 - 10
alembic/versions/20260731_08_add_find_agent_p0_gates.py

@@ -12,23 +12,24 @@ import sqlalchemy as sa
 from alembic import op
 
 revision: str = "20260731_08"
-down_revision: str | None = "20260730_07"
+down_revision: str | None = "20260731_08a"
 branch_labels: str | Sequence[str] | None = None
 depends_on: str | Sequence[str] | None = None
 
 
 def upgrade() -> None:
-    op.add_column(
-        "video_discovery_run",
+    inspector = sa.inspect(op.get_bind())
+    run_column_names = {
+        column["name"]
+        for column in inspector.get_columns("video_discovery_run")
+    }
+    run_columns = (
         sa.Column(
             "rule_version",
             sa.String(length=64),
             nullable=True,
             comment="本次候选门禁规则版本",
         ),
-    )
-    op.add_column(
-        "video_discovery_run",
         sa.Column(
             "rule_config_json",
             sa.Text(),
@@ -36,7 +37,14 @@ def upgrade() -> None:
             comment="运行时规则、当前时间和阈值快照 JSON",
         ),
     )
+    for column in run_columns:
+        if column.name not in run_column_names:
+            op.add_column("video_discovery_run", column)
 
+    candidate_column_names = {
+        column["name"]
+        for column in inspector.get_columns("video_discovery_candidate")
+    }
     columns = (
         sa.Column(
             "publish_at",
@@ -137,10 +145,16 @@ def upgrade() -> None:
         ),
     )
     for column in columns:
-        op.add_column("video_discovery_candidate", column)
+        if column.name not in candidate_column_names:
+            op.add_column("video_discovery_candidate", column)
 
 
 def downgrade() -> None:
+    inspector = sa.inspect(op.get_bind())
+    candidate_column_names = {
+        column["name"]
+        for column in inspector.get_columns("video_discovery_candidate")
+    }
     candidate_columns = (
         "rule_version",
         "reject_reason_code",
@@ -160,6 +174,13 @@ def downgrade() -> None:
         "publish_at",
     )
     for column in candidate_columns:
-        op.drop_column("video_discovery_candidate", column)
-    op.drop_column("video_discovery_run", "rule_config_json")
-    op.drop_column("video_discovery_run", "rule_version")
+        if column in candidate_column_names:
+            op.drop_column("video_discovery_candidate", column)
+    run_column_names = {
+        column["name"]
+        for column in inspector.get_columns("video_discovery_run")
+    }
+    if "rule_config_json" in run_column_names:
+        op.drop_column("video_discovery_run", "rule_config_json")
+    if "rule_version" in run_column_names:
+        op.drop_column("video_discovery_run", "rule_version")

+ 277 - 0
alembic/versions/20260731_08a_create_video_discovery_tables.py

@@ -0,0 +1,277 @@
+"""create the complete video discovery persistence tables
+
+Revision ID: 20260731_08a
+Revises: 20260730_07
+Create Date: 2026-07-31
+
+The tables predate Alembic and were previously installed from files under
+``sql/``.  ``checkfirst``-style inspection keeps this migration safe for
+databases where those legacy DDL files were already applied, while making a
+fresh ``alembic upgrade head`` self-contained.
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260731_08a"
+down_revision: str | None = "20260730_07"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _table_names() -> set[str]:
+    return set(sa.inspect(op.get_bind()).get_table_names())
+
+
+def _create_run_table() -> None:
+    op.create_table(
+        "video_discovery_run",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(length=64), nullable=False, comment="Agent 运行标识"),
+        sa.Column("biz_dt", sa.String(length=32), nullable=True, comment="业务日 YYYYMMDD"),
+        sa.Column("demand_grade_id", sa.BigInteger(), nullable=True),
+        sa.Column("demand_word", sa.String(length=256), nullable=False),
+        sa.Column("seed_video_id", sa.String(length=64), nullable=True),
+        sa.Column("seed_video_title", sa.String(length=512), nullable=True),
+        sa.Column("relevant_points_json", sa.Text(), nullable=False),
+        sa.Column("intent_summary", sa.Text(), nullable=True),
+        sa.Column(
+            "status",
+            sa.String(length=24),
+            nullable=False,
+            server_default="running",
+        ),
+        sa.Column("search_count", sa.Integer(), nullable=False, server_default="0"),
+        sa.Column("primary_count", sa.Integer(), nullable=False, server_default="0"),
+        sa.Column("stop_reason", sa.Text(), nullable=True),
+        sa.Column(
+            "create_time",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+        sa.Column(
+            "update_time",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("run_id", name="uk_video_discovery_run_id"),
+        sa.UniqueConstraint(
+            "biz_dt",
+            "demand_grade_id",
+            name="uk_video_discovery_run_biz_grade",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_video_discovery_run_demand",
+        "video_discovery_run",
+        ["demand_word"],
+    )
+    op.create_index(
+        "idx_video_discovery_run_grade",
+        "video_discovery_run",
+        ["demand_grade_id"],
+    )
+    op.create_index(
+        "idx_video_discovery_run_biz_dt",
+        "video_discovery_run",
+        ["biz_dt"],
+    )
+    op.create_index(
+        "idx_video_discovery_run_status",
+        "video_discovery_run",
+        ["status"],
+    )
+
+
+def _create_search_table() -> None:
+    op.create_table(
+        "video_discovery_search",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(length=64), nullable=False),
+        sa.Column("search_key", sa.String(length=64), nullable=False),
+        sa.Column("keyword", sa.String(length=256), nullable=False),
+        sa.Column("query_reason", sa.Text(), nullable=False),
+        sa.Column("source_type", sa.String(length=32), nullable=False),
+        sa.Column("source_value", sa.Text(), nullable=True),
+        sa.Column("parent_search_id", sa.BigInteger(), nullable=True),
+        sa.Column(
+            "provider",
+            sa.String(length=32),
+            nullable=False,
+            server_default="internal_keyword",
+        ),
+        sa.Column("provider_state_json", sa.Text(), nullable=True),
+        sa.Column(
+            "content_type",
+            sa.String(length=16),
+            nullable=False,
+            server_default="视频",
+        ),
+        sa.Column(
+            "sort_type",
+            sa.String(length=32),
+            nullable=False,
+            server_default="综合排序",
+        ),
+        sa.Column(
+            "publish_time",
+            sa.String(length=32),
+            nullable=False,
+            server_default="不限",
+        ),
+        sa.Column(
+            "cursor",
+            sa.String(length=128),
+            nullable=False,
+            server_default="0",
+        ),
+        sa.Column("page_no", sa.Integer(), nullable=False, server_default="1"),
+        sa.Column("results_count", sa.Integer(), nullable=False, server_default="0"),
+        sa.Column(
+            "new_candidate_count",
+            sa.Integer(),
+            nullable=False,
+            server_default="0",
+        ),
+        sa.Column("has_more", sa.Integer(), nullable=False, server_default="0"),
+        sa.Column("next_cursor", sa.String(length=128), nullable=True),
+        sa.Column("result_ids_json", sa.Text(), nullable=True),
+        sa.Column(
+            "status",
+            sa.String(length=16),
+            nullable=False,
+            server_default="success",
+        ),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column(
+            "create_time",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+        sa.Column(
+            "update_time",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_video_discovery_search_run",
+        "video_discovery_search",
+        ["run_id", "id"],
+    )
+    op.create_index(
+        "idx_video_discovery_search_parent",
+        "video_discovery_search",
+        ["run_id", "parent_search_id"],
+    )
+    op.create_index(
+        "idx_video_discovery_search_keyword",
+        "video_discovery_search",
+        ["keyword"],
+    )
+
+
+def _create_candidate_table() -> None:
+    op.create_table(
+        "video_discovery_candidate",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(length=64), nullable=False),
+        sa.Column(
+            "search_id",
+            sa.BigInteger(),
+            sa.ForeignKey(
+                "video_discovery_search.id",
+                name="fk_video_discovery_candidate_search",
+                ondelete="RESTRICT",
+            ),
+            nullable=True,
+        ),
+        sa.Column("aweme_id", sa.String(length=64), nullable=False),
+        sa.Column("title", sa.String(length=512), nullable=True),
+        sa.Column("content_link", sa.String(length=1024), nullable=True),
+        sa.Column("author_name", sa.String(length=256), nullable=True),
+        sa.Column("author_sec_uid", sa.String(length=256), nullable=True),
+        sa.Column("source_keywords_json", sa.Text(), nullable=True),
+        sa.Column("source_search_ids_json", sa.Text(), nullable=True),
+        sa.Column("tags_json", sa.Text(), nullable=True),
+        sa.Column("play_count", sa.BigInteger(), nullable=True),
+        sa.Column("like_count", sa.BigInteger(), nullable=True),
+        sa.Column("comment_count", sa.BigInteger(), nullable=True),
+        sa.Column("collect_count", sa.BigInteger(), nullable=True),
+        sa.Column("share_count", sa.BigInteger(), nullable=True),
+        sa.Column("content_age_evidence_json", sa.Text(), nullable=True),
+        sa.Column("account_age_evidence_json", sa.Text(), nullable=True),
+        sa.Column("age_normalization_json", sa.Text(), nullable=True),
+        sa.Column("relevance_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("elder_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("share_score", sa.Numeric(8, 6), nullable=True),
+        sa.Column("value_score", sa.Numeric(8, 2), nullable=True),
+        sa.Column("decision_reason", sa.Text(), nullable=True),
+        sa.Column(
+            "decision_bucket",
+            sa.String(length=24),
+            nullable=False,
+            server_default="pending_evaluation",
+        ),
+        sa.Column("aigc_crawler_plan_id", sa.String(length=64), nullable=True),
+        sa.Column("aigc_produce_plan_id", sa.String(length=64), nullable=True),
+        sa.Column("aigc_publish_plan_id", sa.String(length=64), nullable=True),
+        sa.Column("aigc_plan_label", sa.String(length=64), nullable=True),
+        sa.Column(
+            "create_time",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+        sa.Column(
+            "update_time",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_video_discovery_candidate_search",
+        "video_discovery_candidate",
+        ["search_id", "id"],
+    )
+    op.create_index(
+        "idx_video_discovery_candidate_bucket",
+        "video_discovery_candidate",
+        ["run_id", "decision_bucket"],
+    )
+    op.create_index(
+        "idx_video_discovery_candidate_author",
+        "video_discovery_candidate",
+        ["author_sec_uid"],
+    )
+
+
+def upgrade() -> None:
+    tables = _table_names()
+    if "video_discovery_run" not in tables:
+        _create_run_table()
+    if "video_discovery_search" not in tables:
+        _create_search_table()
+    if "video_discovery_candidate" not in tables:
+        _create_candidate_table()
+
+
+def downgrade() -> None:
+    # These tables may have existed before Alembic took ownership of the schema.
+    # A destructive automatic downgrade could delete legacy production data, so
+    # this adoption migration is intentionally forward-only.
+    pass

+ 71 - 0
alembic/versions/20260731_09_transactional_aigc_outbox.py

@@ -0,0 +1,71 @@
+"""turn the AIGC effect ledger into a transactional outbox
+
+Revision ID: 20260731_09
+Revises: 20260731_08
+Create Date: 2026-07-31
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260731_09"
+down_revision: str | None = "20260731_08"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "pipeline_outbox",
+        sa.Column(
+            "attempt_count",
+            sa.Integer(),
+            nullable=False,
+            server_default="0",
+        ),
+    )
+    op.add_column(
+        "pipeline_outbox",
+        sa.Column("last_attempt_at", sa.DateTime(), nullable=True),
+    )
+    op.add_column(
+        "pipeline_outbox",
+        sa.Column("next_retry_at", sa.DateTime(), nullable=True),
+    )
+    op.add_column(
+        "pipeline_outbox",
+        sa.Column("external_id", sa.String(length=128), nullable=True),
+    )
+    op.add_column(
+        "pipeline_outbox",
+        sa.Column("external_name", sa.String(length=512), nullable=True),
+    )
+    op.add_column(
+        "pipeline_outbox",
+        sa.Column("response_json", sa.JSON(), nullable=True),
+    )
+
+    outbox = sa.table(
+        "pipeline_outbox",
+        sa.column("status", sa.String(length=32)),
+    )
+    # Rows produced by the old implementation were written only after the
+    # external call completed, so they represent already-dispatched effects.
+    op.execute(
+        outbox.update()
+        .where(outbox.c.status == "dispatched")
+        .values(status="succeeded")
+    )
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("pipeline_outbox") as batch_op:
+        batch_op.drop_column("response_json")
+        batch_op.drop_column("external_name")
+        batch_op.drop_column("external_id")
+        batch_op.drop_column("next_retry_at")
+        batch_op.drop_column("last_attempt_at")
+        batch_op.drop_column("attempt_count")

+ 288 - 0
alembic/versions/20260731_10_platform_demand_mvp.py

@@ -0,0 +1,288 @@
+"""add evidence packages and versioned platform demand objects
+
+Revision ID: 20260731_10
+Revises: 20260731_09
+Create Date: 2026-07-31
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260731_10"
+down_revision: str | None = "20260731_09"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _pipeline_table_options() -> dict[str, str]:
+    bind = op.get_bind()
+    if bind.dialect.name != "mysql":
+        return {}
+    row = bind.execute(
+        sa.text(
+            "SELECT CHARACTER_SET_NAME, COLLATION_NAME "
+            "FROM INFORMATION_SCHEMA.COLUMNS "
+            "WHERE TABLE_SCHEMA = DATABASE() "
+            "AND TABLE_NAME = 'pipeline_run' "
+            "AND COLUMN_NAME = 'run_id'"
+        )
+    ).one()
+    return {
+        "mysql_charset": str(row.CHARACTER_SET_NAME),
+        "mysql_collate": str(row.COLLATION_NAME),
+    }
+
+
+def upgrade() -> None:
+    table_options = _pipeline_table_options()
+    op.create_table(
+        "evidence_package",
+        sa.Column("package_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("biz_dt", sa.String(8), nullable=False),
+        sa.Column("package_version", sa.Integer(), nullable=False),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("source_snapshot_hash", sa.String(64), nullable=False),
+        sa.Column("source_versions_json", sa.JSON(), nullable=False),
+        sa.Column("evidence_count", sa.Integer(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint("run_id", name="uk_evidence_package_run"),
+        **table_options,
+    )
+    op.create_index(
+        "idx_evidence_package_biz",
+        "evidence_package",
+        ["biz_dt", "created_at"],
+    )
+
+    op.create_table(
+        "raw_demand_expression",
+        sa.Column("expression_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "package_id",
+            sa.String(36),
+            sa.ForeignKey("evidence_package.package_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("source_type", sa.String(64), nullable=False),
+        sa.Column("source_record_id", sa.String(128), nullable=False),
+        sa.Column("raw_text", sa.String(512), nullable=False),
+        sa.Column("original_payload_json", sa.JSON(), nullable=False),
+        sa.Column("content_hash", sa.String(64), nullable=False),
+        sa.Column("observed_biz_dt", sa.String(8), nullable=False),
+        sa.Column("data_quality", sa.String(24), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "package_id",
+            "source_type",
+            "source_record_id",
+            name="uk_raw_expression_source",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_raw_expression_package",
+        "raw_demand_expression",
+        ["package_id"],
+    )
+    op.create_index(
+        "idx_raw_expression_hash",
+        "raw_demand_expression",
+        ["content_hash"],
+    )
+
+    op.create_table(
+        "standard_demand_term",
+        sa.Column("term_id", sa.String(36), primary_key=True),
+        sa.Column("canonical_key", sa.String(64), nullable=False),
+        sa.Column("canonical_text", sa.String(256), nullable=False),
+        sa.Column("normalized_text", sa.String(256), nullable=False),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint("canonical_key", name="uk_standard_demand_term_key"),
+        **table_options,
+    )
+    op.create_index(
+        "idx_standard_demand_term_text",
+        "standard_demand_term",
+        ["normalized_text"],
+    )
+
+    op.create_table(
+        "standard_demand_alias",
+        sa.Column("alias_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "term_id",
+            sa.String(36),
+            sa.ForeignKey("standard_demand_term.term_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "expression_id",
+            sa.String(36),
+            sa.ForeignKey("raw_demand_expression.expression_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("alias_text", sa.String(512), nullable=False),
+        sa.Column("relation_type", sa.String(32), nullable=False),
+        sa.Column("confidence", sa.Numeric(6, 5), nullable=False),
+        sa.Column("reason", sa.Text(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "term_id",
+            "expression_id",
+            name="uk_standard_demand_alias_expression",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_standard_demand_alias_term",
+        "standard_demand_alias",
+        ["term_id"],
+    )
+
+    op.create_table(
+        "platform_demand",
+        sa.Column("platform_demand_id", sa.String(36), primary_key=True),
+        sa.Column("canonical_key", sa.String(64), nullable=False),
+        sa.Column("name", sa.String(256), nullable=False),
+        sa.Column("description", sa.Text(), nullable=False),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("lifecycle_state", sa.String(24), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint("canonical_key", name="uk_platform_demand_key"),
+        **table_options,
+    )
+    op.create_index("idx_platform_demand_name", "platform_demand", ["name"])
+    op.create_index("idx_platform_demand_status", "platform_demand", ["status"])
+
+    op.create_table(
+        "platform_demand_version",
+        sa.Column("platform_demand_version_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "platform_demand_id",
+            sa.String(36),
+            sa.ForeignKey("platform_demand.platform_demand_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "term_id",
+            sa.String(36),
+            sa.ForeignKey("standard_demand_term.term_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "package_id",
+            sa.String(36),
+            sa.ForeignKey("evidence_package.package_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("source_demand_grade_id", sa.BigInteger(), nullable=False),
+        sa.Column("version_no", sa.Integer(), nullable=False),
+        sa.Column("biz_dt", sa.String(8), nullable=False),
+        sa.Column("name", sa.String(256), nullable=False),
+        sa.Column("description", sa.Text(), nullable=False),
+        sa.Column("cognition_confidence", sa.Numeric(6, 5), nullable=False),
+        sa.Column("reason", sa.Text(), nullable=False),
+        sa.Column("change_type", sa.String(24), nullable=False),
+        sa.Column("evidence_hash", sa.String(64), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "platform_demand_id",
+            "version_no",
+            name="uk_platform_demand_version_no",
+        ),
+        sa.UniqueConstraint(
+            "platform_demand_id",
+            "run_id",
+            name="uk_platform_demand_version_run",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_platform_demand_version_biz",
+        "platform_demand_version",
+        ["biz_dt", "run_id"],
+    )
+
+    op.create_table(
+        "platform_demand_category_rel",
+        sa.Column("rel_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "platform_demand_version_id",
+            sa.String(36),
+            sa.ForeignKey(
+                "platform_demand_version.platform_demand_version_id",
+                ondelete="RESTRICT",
+            ),
+            nullable=False,
+        ),
+        sa.Column("category_id", sa.BigInteger(), nullable=False),
+        sa.Column("relation_type", sa.String(32), nullable=False),
+        sa.Column("relation_source", sa.String(64), nullable=False),
+        sa.Column("reason", sa.Text(), nullable=False),
+        sa.Column("confidence", sa.Numeric(6, 5), nullable=False),
+        sa.Column("is_inferred", sa.Boolean(), nullable=False),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("valid_from_biz_dt", sa.String(8), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "platform_demand_version_id",
+            "category_id",
+            "relation_type",
+            name="uk_platform_demand_category_rel",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_platform_demand_category_node",
+        "platform_demand_category_rel",
+        ["category_id"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index(
+        "idx_platform_demand_category_node",
+        table_name="platform_demand_category_rel",
+    )
+    op.drop_table("platform_demand_category_rel")
+    op.drop_index(
+        "idx_platform_demand_version_biz",
+        table_name="platform_demand_version",
+    )
+    op.drop_table("platform_demand_version")
+    op.drop_index("idx_platform_demand_status", table_name="platform_demand")
+    op.drop_index("idx_platform_demand_name", table_name="platform_demand")
+    op.drop_table("platform_demand")
+    op.drop_index(
+        "idx_standard_demand_alias_term",
+        table_name="standard_demand_alias",
+    )
+    op.drop_table("standard_demand_alias")
+    op.drop_index(
+        "idx_standard_demand_term_text",
+        table_name="standard_demand_term",
+    )
+    op.drop_table("standard_demand_term")
+    op.drop_index("idx_raw_expression_hash", table_name="raw_demand_expression")
+    op.drop_index("idx_raw_expression_package", table_name="raw_demand_expression")
+    op.drop_table("raw_demand_expression")
+    op.drop_index("idx_evidence_package_biz", table_name="evidence_package")
+    op.drop_table("evidence_package")

+ 243 - 0
alembic/versions/20260731_11_daily_demand_package.py

@@ -0,0 +1,243 @@
+"""add explainable evaluations and the unified daily demand package
+
+Revision ID: 20260731_11
+Revises: 20260731_10
+Create Date: 2026-07-31
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260731_11"
+down_revision: str | None = "20260731_10"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _pipeline_table_options() -> dict[str, str]:
+    bind = op.get_bind()
+    if bind.dialect.name != "mysql":
+        return {}
+    row = bind.execute(
+        sa.text(
+            "SELECT CHARACTER_SET_NAME, COLLATION_NAME "
+            "FROM INFORMATION_SCHEMA.COLUMNS "
+            "WHERE TABLE_SCHEMA = DATABASE() "
+            "AND TABLE_NAME = 'pipeline_run' "
+            "AND COLUMN_NAME = 'run_id'"
+        )
+    ).one()
+    return {
+        "mysql_charset": str(row.CHARACTER_SET_NAME),
+        "mysql_collate": str(row.COLLATION_NAME),
+    }
+
+
+def upgrade() -> None:
+    table_options = _pipeline_table_options()
+    op.create_table(
+        "strategy_version",
+        sa.Column("strategy_version_id", sa.String(36), primary_key=True),
+        sa.Column("version_key", sa.String(64), nullable=False),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("definition_json", sa.JSON(), nullable=False),
+        sa.Column("change_reason", sa.Text(), nullable=False),
+        sa.Column("created_by", sa.String(128), nullable=False),
+        sa.Column("activated_at", sa.DateTime(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint("version_key", name="uk_strategy_version_key"),
+        **table_options,
+    )
+    op.create_index(
+        "idx_strategy_version_status",
+        "strategy_version",
+        ["status", "activated_at"],
+    )
+
+    op.create_table(
+        "demand_evaluation_snapshot",
+        sa.Column("evaluation_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "platform_demand_version_id",
+            sa.String(36),
+            sa.ForeignKey(
+                "platform_demand_version.platform_demand_version_id",
+                ondelete="RESTRICT",
+            ),
+            nullable=False,
+        ),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "strategy_version_id",
+            sa.String(36),
+            sa.ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("biz_dt", sa.String(8), nullable=False),
+        sa.Column("validity_score", sa.Numeric(6, 5), nullable=False),
+        sa.Column("local_supply_priority", sa.Numeric(6, 5), nullable=False),
+        sa.Column("data_confidence", sa.Numeric(6, 5), nullable=False),
+        sa.Column("posterior_state", sa.String(24), nullable=False),
+        sa.Column("action_tier", sa.String(32), nullable=False),
+        sa.Column("metrics_json", sa.JSON(), nullable=False),
+        sa.Column("missing_dimensions_json", sa.JSON(), nullable=False),
+        sa.Column("decision_reason", sa.Text(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "platform_demand_version_id",
+            name="uk_demand_evaluation_version",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_demand_evaluation_biz",
+        "demand_evaluation_snapshot",
+        ["biz_dt", "action_tier"],
+    )
+
+    op.create_table(
+        "demand_score_contribution",
+        sa.Column("contribution_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "evaluation_id",
+            sa.String(36),
+            sa.ForeignKey("demand_evaluation_snapshot.evaluation_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("score_type", sa.String(24), nullable=False),
+        sa.Column("dimension", sa.String(64), nullable=False),
+        sa.Column("raw_value_json", sa.JSON(), nullable=False),
+        sa.Column("normalized_value", sa.Numeric(8, 7), nullable=False),
+        sa.Column("configured_weight", sa.Numeric(8, 7), nullable=False),
+        sa.Column("effective_weight", sa.Numeric(8, 7), nullable=False),
+        sa.Column("contribution", sa.Numeric(8, 7), nullable=False),
+        sa.Column("data_status", sa.String(24), nullable=False),
+        sa.Column("reason", sa.Text(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "evaluation_id",
+            "score_type",
+            "dimension",
+            name="uk_demand_score_contribution",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_demand_score_contribution_eval",
+        "demand_score_contribution",
+        ["evaluation_id"],
+    )
+
+    op.create_table(
+        "daily_demand_package",
+        sa.Column("demand_package_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "evidence_package_id",
+            sa.String(36),
+            sa.ForeignKey("evidence_package.package_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "strategy_version_id",
+            sa.String(36),
+            sa.ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("biz_dt", sa.String(8), nullable=False),
+        sa.Column("package_version", sa.Integer(), nullable=False),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("content_hash", sa.String(64), nullable=False),
+        sa.Column("item_count", sa.Integer(), nullable=False),
+        sa.Column("published_at", sa.DateTime(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint("run_id", name="uk_daily_demand_package_run"),
+        **table_options,
+    )
+    op.create_index(
+        "idx_daily_demand_package_biz",
+        "daily_demand_package",
+        ["biz_dt", "status"],
+    )
+
+    op.create_table(
+        "daily_demand_task",
+        sa.Column("task_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "demand_package_id",
+            sa.String(36),
+            sa.ForeignKey("daily_demand_package.demand_package_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "platform_demand_version_id",
+            sa.String(36),
+            sa.ForeignKey(
+                "platform_demand_version.platform_demand_version_id",
+                ondelete="RESTRICT",
+            ),
+            nullable=False,
+        ),
+        sa.Column(
+            "evaluation_id",
+            sa.String(36),
+            sa.ForeignKey("demand_evaluation_snapshot.evaluation_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("action_tier", sa.String(32), nullable=False),
+        sa.Column("search_terms_json", sa.JSON(), nullable=False),
+        sa.Column("exclude_terms_json", sa.JSON(), nullable=False),
+        sa.Column("hit_rules_json", sa.JSON(), nullable=False),
+        sa.Column("hypotheses_json", sa.JSON(), nullable=False),
+        sa.Column("evidence_gaps_json", sa.JSON(), nullable=False),
+        sa.Column("existing_content_json", sa.JSON(), nullable=False),
+        sa.Column("allocation_json", sa.JSON(), nullable=False),
+        sa.Column("task_payload_json", sa.JSON(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "demand_package_id",
+            "platform_demand_version_id",
+            name="uk_daily_demand_task_version",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_daily_demand_task_tier",
+        "daily_demand_task",
+        ["demand_package_id", "action_tier"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("idx_daily_demand_task_tier", table_name="daily_demand_task")
+    op.drop_table("daily_demand_task")
+    op.drop_index(
+        "idx_daily_demand_package_biz",
+        table_name="daily_demand_package",
+    )
+    op.drop_table("daily_demand_package")
+    op.drop_index(
+        "idx_demand_score_contribution_eval",
+        table_name="demand_score_contribution",
+    )
+    op.drop_table("demand_score_contribution")
+    op.drop_index(
+        "idx_demand_evaluation_biz",
+        table_name="demand_evaluation_snapshot",
+    )
+    op.drop_table("demand_evaluation_snapshot")
+    op.drop_index("idx_strategy_version_status", table_name="strategy_version")
+    op.drop_table("strategy_version")

+ 296 - 0
alembic/versions/20260731_12_feedback_attribution_strategy.py

@@ -0,0 +1,296 @@
+"""add content attribution, feedback processing and strategy workflow
+
+Revision ID: 20260731_12
+Revises: 20260731_11
+Create Date: 2026-07-31
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260731_12"
+down_revision: str | None = "20260731_11"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _pipeline_table_options() -> dict[str, str]:
+    bind = op.get_bind()
+    if bind.dialect.name != "mysql":
+        return {}
+    row = bind.execute(
+        sa.text(
+            "SELECT CHARACTER_SET_NAME, COLLATION_NAME "
+            "FROM INFORMATION_SCHEMA.COLUMNS "
+            "WHERE TABLE_SCHEMA = DATABASE() "
+            "AND TABLE_NAME = 'pipeline_run' "
+            "AND COLUMN_NAME = 'run_id'"
+        )
+    ).one()
+    return {
+        "mysql_charset": str(row.CHARACTER_SET_NAME),
+        "mysql_collate": str(row.COLLATION_NAME),
+    }
+
+
+def upgrade() -> None:
+    table_options = _pipeline_table_options()
+    op.add_column(
+        "video_discovery_run",
+        sa.Column("demand_package_id", sa.String(36), nullable=True),
+    )
+    op.add_column(
+        "video_discovery_run",
+        sa.Column("daily_demand_task_id", sa.String(36), nullable=True),
+    )
+    op.add_column(
+        "video_discovery_run",
+        sa.Column("platform_demand_version_id", sa.String(36), nullable=True),
+    )
+    op.create_index(
+        "idx_video_discovery_run_platform_version",
+        "video_discovery_run",
+        ["platform_demand_version_id"],
+    )
+
+    op.add_column(
+        "demand_feedback",
+        sa.Column(
+            "processing_status",
+            sa.String(24),
+            nullable=False,
+            server_default="pending",
+        ),
+    )
+    op.add_column(
+        "demand_feedback",
+        sa.Column("platform_demand_id", sa.String(36), nullable=True),
+    )
+    op.add_column(
+        "demand_feedback",
+        sa.Column("platform_demand_version_id", sa.String(36), nullable=True),
+    )
+    op.add_column(
+        "demand_feedback",
+        sa.Column("processed_by", sa.String(128), nullable=True),
+    )
+    op.add_column(
+        "demand_feedback",
+        sa.Column("processed_at", sa.DateTime(), nullable=True),
+    )
+    op.add_column(
+        "demand_feedback",
+        sa.Column("resolution_reason", sa.Text(), nullable=True),
+    )
+    op.add_column(
+        "demand_feedback",
+        sa.Column("consumed_run_id", sa.String(36), nullable=True),
+    )
+    op.add_column(
+        "demand_feedback",
+        sa.Column("impact_json", sa.Text(), nullable=True),
+    )
+    op.create_index(
+        "idx_demand_feedback_processing",
+        "demand_feedback",
+        ["processing_status", "created_at"],
+    )
+
+    op.create_table(
+        "content_demand_coverage",
+        sa.Column("coverage_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "platform_demand_version_id",
+            sa.String(36),
+            sa.ForeignKey(
+                "platform_demand_version.platform_demand_version_id",
+                ondelete="RESTRICT",
+            ),
+            nullable=False,
+        ),
+        sa.Column(
+            "task_id",
+            sa.String(36),
+            sa.ForeignKey("daily_demand_task.task_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("content_id", sa.String(128), nullable=False),
+        sa.Column("content_url", sa.String(1024), nullable=True),
+        sa.Column("source_type", sa.String(32), nullable=False),
+        sa.Column("coverage_role", sa.String(24), nullable=False),
+        sa.Column("coverage_score", sa.Numeric(6, 5), nullable=False),
+        sa.Column("evidence_json", sa.JSON(), nullable=False),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("discovered_at", sa.DateTime(), nullable=True),
+        sa.Column("content_published_at", sa.DateTime(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "platform_demand_version_id",
+            "content_id",
+            "coverage_role",
+            name="uk_content_demand_coverage",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_content_demand_coverage_content",
+        "content_demand_coverage",
+        ["content_id"],
+    )
+
+    op.create_table(
+        "content_performance_fact",
+        sa.Column("performance_fact_id", sa.String(36), primary_key=True),
+        sa.Column("content_id", sa.String(128), nullable=False),
+        sa.Column("biz_dt", sa.String(8), nullable=False),
+        sa.Column("source", sa.String(64), nullable=False),
+        sa.Column("payload_hash", sa.String(64), nullable=False),
+        sa.Column("exposure_count", sa.BigInteger(), nullable=True),
+        sa.Column("sample_size", sa.BigInteger(), nullable=True),
+        sa.Column("content_quality", sa.Numeric(6, 5), nullable=True),
+        sa.Column("rov", sa.Numeric(16, 8), nullable=True),
+        sa.Column("vov", sa.Numeric(16, 8), nullable=True),
+        sa.Column("raw_payload_json", sa.JSON(), nullable=False),
+        sa.Column("observed_at", sa.DateTime(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "content_id",
+            "biz_dt",
+            "source",
+            "payload_hash",
+            name="uk_content_performance_fact",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_content_performance_fact_content",
+        "content_performance_fact",
+        ["content_id", "biz_dt"],
+    )
+
+    op.create_table(
+        "demand_attribution_snapshot",
+        sa.Column("attribution_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "platform_demand_version_id",
+            sa.String(36),
+            sa.ForeignKey(
+                "platform_demand_version.platform_demand_version_id",
+                ondelete="RESTRICT",
+            ),
+            nullable=False,
+        ),
+        sa.Column(
+            "coverage_id",
+            sa.String(36),
+            sa.ForeignKey("content_demand_coverage.coverage_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column(
+            "performance_fact_id",
+            sa.String(36),
+            sa.ForeignKey(
+                "content_performance_fact.performance_fact_id",
+                ondelete="RESTRICT",
+            ),
+            nullable=False,
+        ),
+        sa.Column(
+            "strategy_version_id",
+            sa.String(36),
+            sa.ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("support_state", sa.String(24), nullable=False),
+        sa.Column("attributable_rov", sa.Numeric(16, 8), nullable=True),
+        sa.Column("attributable_vov", sa.Numeric(16, 8), nullable=True),
+        sa.Column("attribution_confidence", sa.Numeric(6, 5), nullable=False),
+        sa.Column("validity_influence", sa.Numeric(8, 7), nullable=False),
+        sa.Column("priority_influence", sa.Numeric(8, 7), nullable=False),
+        sa.Column("reason", sa.Text(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "coverage_id",
+            "performance_fact_id",
+            "strategy_version_id",
+            name="uk_demand_attribution_snapshot",
+        ),
+        **table_options,
+    )
+    op.create_index(
+        "idx_demand_attribution_version",
+        "demand_attribution_snapshot",
+        ["platform_demand_version_id", "created_at"],
+    )
+
+    op.create_table(
+        "strategy_change_proposal",
+        sa.Column("proposal_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "base_strategy_version_id",
+            sa.String(36),
+            sa.ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+            nullable=False,
+        ),
+        sa.Column("status", sa.String(24), nullable=False),
+        sa.Column("requested_change_json", sa.JSON(), nullable=False),
+        sa.Column("proposed_definition_json", sa.JSON(), nullable=False),
+        sa.Column("diff_json", sa.JSON(), nullable=False),
+        sa.Column("preview_json", sa.JSON(), nullable=True),
+        sa.Column("reason", sa.Text(), nullable=False),
+        sa.Column("created_by", sa.String(128), nullable=False),
+        sa.Column("approved_by", sa.String(128), nullable=True),
+        sa.Column("approved_at", sa.DateTime(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        **table_options,
+    )
+    op.create_index(
+        "idx_strategy_proposal_status",
+        "strategy_change_proposal",
+        ["status", "created_at"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index(
+        "idx_strategy_proposal_status",
+        table_name="strategy_change_proposal",
+    )
+    op.drop_table("strategy_change_proposal")
+    op.drop_index(
+        "idx_demand_attribution_version",
+        table_name="demand_attribution_snapshot",
+    )
+    op.drop_table("demand_attribution_snapshot")
+    op.drop_index(
+        "idx_content_performance_fact_content",
+        table_name="content_performance_fact",
+    )
+    op.drop_table("content_performance_fact")
+    op.drop_index(
+        "idx_content_demand_coverage_content",
+        table_name="content_demand_coverage",
+    )
+    op.drop_table("content_demand_coverage")
+    op.drop_index("idx_demand_feedback_processing", table_name="demand_feedback")
+    for column in (
+        "impact_json",
+        "consumed_run_id",
+        "resolution_reason",
+        "processed_at",
+        "processed_by",
+        "platform_demand_version_id",
+        "platform_demand_id",
+        "processing_status",
+    ):
+        op.drop_column("demand_feedback", column)
+    op.drop_index(
+        "idx_video_discovery_run_platform_version",
+        table_name="video_discovery_run",
+    )
+    op.drop_column("video_discovery_run", "platform_demand_version_id")
+    op.drop_column("video_discovery_run", "daily_demand_task_id")
+    op.drop_column("video_discovery_run", "demand_package_id")

+ 197 - 0
alembic/versions/20260731_13_version_demand_belong_rel.py

@@ -0,0 +1,197 @@
+"""version and explain legacy demand-belong relation edges
+
+Revision ID: 20260731_13
+Revises: 20260731_12
+Create Date: 2026-07-31
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260731_13"
+down_revision: str | None = "20260731_12"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _new_columns() -> tuple[sa.Column, ...]:
+    return (
+        sa.Column(
+            "biz_dt",
+            sa.String(32),
+            nullable=False,
+            server_default="legacy",
+        ),
+        sa.Column(
+            "relation_type",
+            sa.String(32),
+            nullable=False,
+            server_default="explicit_token",
+        ),
+        sa.Column(
+            "relation_source",
+            sa.String(64),
+            nullable=False,
+            server_default="legacy",
+        ),
+        sa.Column(
+            "reason",
+            sa.Text(),
+            nullable=False,
+        ),
+        sa.Column(
+            "confidence",
+            sa.Numeric(6, 5),
+            nullable=False,
+            server_default="1",
+        ),
+        sa.Column(
+            "is_inferred",
+            sa.Boolean(),
+            nullable=False,
+            server_default="1",
+        ),
+        sa.Column(
+            "status",
+            sa.String(24),
+            nullable=False,
+            server_default="active",
+        ),
+        sa.Column(
+            "valid_from_biz_dt",
+            sa.String(32),
+            nullable=False,
+            server_default="legacy",
+        ),
+        sa.Column("valid_to_biz_dt", sa.String(32), nullable=True),
+    )
+
+
+def upgrade() -> None:
+    inspector = sa.inspect(op.get_bind())
+    table_names = set(inspector.get_table_names())
+    if "demand_belong_pool_rel" not in table_names:
+        op.create_table(
+            "demand_belong_pool_rel",
+            sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+            sa.Column("demand_belong_category_id", sa.BigInteger(), nullable=False),
+            sa.Column("multi_demand_pool_di_id", sa.BigInteger(), nullable=False),
+            *_new_columns(),
+            sa.Column(
+                "create_time",
+                sa.DateTime(),
+                nullable=False,
+                server_default=sa.func.now(),
+            ),
+            sa.Column(
+                "update_time",
+                sa.DateTime(),
+                nullable=False,
+                server_default=sa.func.now(),
+            ),
+            sa.PrimaryKeyConstraint("id"),
+            sa.UniqueConstraint(
+                "demand_belong_category_id",
+                "multi_demand_pool_di_id",
+                name="uk_belong_pool",
+            ),
+        )
+        op.create_index(
+            "idx_belong_category_id",
+            "demand_belong_pool_rel",
+            ["demand_belong_category_id"],
+        )
+        op.create_index(
+            "idx_pool_di_id",
+            "demand_belong_pool_rel",
+            ["multi_demand_pool_di_id"],
+        )
+    else:
+        existing_columns = {
+            column["name"]
+            for column in inspector.get_columns("demand_belong_pool_rel")
+        }
+        reason_added = False
+        for column in _new_columns():
+            if column.name not in existing_columns:
+                if column.name == "reason":
+                    op.add_column(
+                        "demand_belong_pool_rel",
+                        sa.Column("reason", sa.Text(), nullable=True),
+                    )
+                    reason_added = True
+                else:
+                    op.add_column("demand_belong_pool_rel", column)
+        if "multi_demand_pool_di" in table_names:
+            op.execute(
+                sa.text(
+                    "UPDATE demand_belong_pool_rel "
+                    "SET biz_dt = COALESCE(("
+                    "SELECT pool.biz_dt FROM multi_demand_pool_di AS pool "
+                    "WHERE pool.id = demand_belong_pool_rel.multi_demand_pool_di_id"
+                    "), 'legacy'), "
+                    "valid_from_biz_dt = COALESCE(("
+                    "SELECT pool.biz_dt FROM multi_demand_pool_di AS pool "
+                    "WHERE pool.id = demand_belong_pool_rel.multi_demand_pool_di_id"
+                    "), 'legacy'), "
+                    "relation_source = 'legacy_name_match', "
+                    "reason = '历史名称匹配关系,由迁移接管;后续按业务日重建', "
+                    "is_inferred = 1"
+                )
+            )
+        if reason_added:
+            op.execute(
+                sa.text(
+                    "UPDATE demand_belong_pool_rel "
+                    "SET reason = 'legacy relation' "
+                    "WHERE reason IS NULL"
+                )
+            )
+            if op.get_bind().dialect.name == "sqlite":
+                with op.batch_alter_table("demand_belong_pool_rel") as batch_op:
+                    batch_op.alter_column(
+                        "reason",
+                        existing_type=sa.Text(),
+                        nullable=False,
+                    )
+            else:
+                op.alter_column(
+                    "demand_belong_pool_rel",
+                    "reason",
+                    existing_type=sa.Text(),
+                    nullable=False,
+                )
+    index_names = {
+        index["name"]
+        for index in sa.inspect(op.get_bind()).get_indexes(
+            "demand_belong_pool_rel"
+        )
+    }
+    if "idx_belong_pool_biz_status" not in index_names:
+        op.create_index(
+            "idx_belong_pool_biz_status",
+            "demand_belong_pool_rel",
+            ["biz_dt", "status"],
+        )
+
+
+def downgrade() -> None:
+    op.drop_index(
+        "idx_belong_pool_biz_status",
+        table_name="demand_belong_pool_rel",
+    )
+    for column in (
+        "valid_to_biz_dt",
+        "valid_from_biz_dt",
+        "status",
+        "is_inferred",
+        "confidence",
+        "reason",
+        "relation_source",
+        "relation_type",
+        "biz_dt",
+    ):
+        op.drop_column("demand_belong_pool_rel", column)

+ 31 - 0
alembic/versions/20260731_14_bootstrap_empty_database.py

@@ -0,0 +1,31 @@
+"""bootstrap the complete application schema for an empty database
+
+Revision ID: 20260731_14
+Revises: 20260731_13
+Create Date: 2026-07-31
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+from supply_infra.db.base import Base
+import supply_infra.db.models  # noqa: F401
+
+revision: str = "20260731_14"
+down_revision: str | None = "20260731_13"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    """Create model-owned tables that historical deployments supplied manually."""
+
+    Base.metadata.create_all(bind=op.get_bind(), checkfirst=True)
+
+
+def downgrade() -> None:
+    # This is an adoption migration. Some tables may predate Alembic, so a
+    # downgrade must not guess ownership and delete existing application data.
+    pass

+ 22 - 2
api/app.py

@@ -15,6 +15,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
 
 from api.auth_middleware import AuthenticationMiddleware
 from api.routers.auth import router as auth_router
+from api.routers.business_harness import router as business_harness_router
 from api.routers.pipeline import router as pipeline_router
 from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.services.agent_catalog import (
@@ -54,6 +55,7 @@ from api.services.video_discovery_records import (
 )
 from supply_infra.config import get_infra_settings
 from supply_infra.db import dispose_engine, get_session, init_db
+from supply_infra.pipeline.health import pipeline_health_snapshot
 
 
 class SPAStaticFiles(StaticFiles):
@@ -86,6 +88,7 @@ async def lifespan(_app: FastAPI):
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
 app.include_router(auth_router)
 app.include_router(pipeline_router)
+app.include_router(business_harness_router)
 
 app.add_middleware(
     CORSMiddleware,
@@ -113,13 +116,30 @@ def health_live() -> dict[str, str]:
 
 
 @app.get("/health/ready")
-def health_ready() -> dict[str, str]:
+def health_ready() -> dict:
     try:
         with get_session() as session:
             session.execute(text("SELECT 1"))
+        runtime = pipeline_health_snapshot()
     except Exception as exc:
         raise HTTPException(status_code=503, detail="database unavailable") from exc
-    return {"status": "ready"}
+    if not runtime["runtime_components_ready"]:
+        raise HTTPException(
+            status_code=503,
+            detail={
+                "message": "pipeline runtime components unavailable",
+                "scheduler_running": runtime["scheduler_running"],
+                "worker_active_count": runtime["worker_active_count"],
+                "worker_expected_count": runtime["worker_expected_count"],
+                "reconciler_running": runtime["reconciler_running"],
+            },
+        )
+    return {
+        "status": "ready",
+        "scheduler_running": runtime["scheduler_running"],
+        "worker_active_count": runtime["worker_active_count"],
+        "reconciler_running": runtime["reconciler_running"],
+    }
 
 
 @app.get("/api/scheduler/status")

+ 2 - 0
api/auth_middleware.py

@@ -23,6 +23,7 @@ _NORMAL_USER_PATHS = {
     ("GET", "/api/video-discovery/demands"),
     ("GET", "/api/video-discovery/runs"),
     ("GET", "/api/video-discovery/feedback"),
+    ("GET", "/api/business-harness/packages/latest"),
     ("POST", "/api/video-discovery/feedback"),
 }
 _NORMAL_USER_PATTERNS = (
@@ -31,6 +32,7 @@ _NORMAL_USER_PATTERNS = (
     re.compile(r"^/api/video-discovery/runs/[^/]+/searches$"),
     re.compile(r"^/api/video-discovery/runs/[^/]+/candidates$"),
     re.compile(r"^/api/demand-grade/\d+/videos$"),
+    re.compile(r"^/api/business-harness/demands/[^/]+/history$"),
 )
 
 

+ 126 - 0
api/routers/business_harness.py

@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from pydantic import BaseModel, Field
+
+from api.services.business_harness import (
+    get_platform_demand_history,
+    get_published_demand_package,
+)
+from supply_infra.business_harness.content_feedback import (
+    ingest_content_performance_fact,
+)
+from supply_infra.business_harness.feedback import process_feedback
+from supply_infra.business_harness.strategy import (
+    approve_strategy_proposal,
+    create_strategy_proposal,
+    preview_strategy_proposal,
+)
+
+router = APIRouter(prefix="/api/business-harness", tags=["business-harness"])
+
+
+class ProcessFeedbackBody(BaseModel):
+    status: Literal["accepted", "rejected", "needs_evidence"]
+    reason: str = Field(min_length=1, max_length=2000)
+    impact: dict[str, Any] | None = None
+
+
+class ContentPerformanceBody(BaseModel):
+    content_id: str = Field(min_length=1, max_length=128)
+    biz_dt: str = Field(pattern=r"^\d{8}$")
+    source: str = Field(min_length=1, max_length=64)
+    raw_payload: dict[str, Any] = Field(default_factory=dict)
+    observed_at: datetime
+    exposure_count: int | None = Field(default=None, ge=0)
+    sample_size: int | None = Field(default=None, ge=0)
+    content_quality: float | None = Field(default=None, ge=0, le=1)
+    rov: float | None = None
+    vov: float | None = None
+
+
+class StrategyProposalBody(BaseModel):
+    requested_change: dict[str, Any]
+    reason: str = Field(min_length=1, max_length=2000)
+
+
+@router.get("/packages/latest")
+def latest_package(
+    biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+    run_id: str | None = None,
+) -> dict:
+    result = get_published_demand_package(biz_dt=biz_dt, run_id=run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="published demand package not found")
+    return result
+
+
+@router.get("/demands/{platform_demand_id}/history")
+def demand_history(platform_demand_id: str) -> dict:
+    result = get_platform_demand_history(platform_demand_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="platform demand not found")
+    return result
+
+
+@router.post("/feedback/{feedback_id}/process")
+def review_feedback(
+    feedback_id: int,
+    body: ProcessFeedbackBody,
+    request: Request,
+) -> dict:
+    try:
+        return process_feedback(
+            feedback_id,
+            status=body.status,
+            processor=str(request.state.current_user["username"]),
+            reason=body.reason,
+            impact=body.impact,
+        )
+    except LookupError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except RuntimeError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
+@router.post("/content-performance")
+def ingest_content_performance(body: ContentPerformanceBody) -> dict:
+    return ingest_content_performance_fact(**body.model_dump())
+
+
+@router.post("/strategy/proposals")
+def propose_strategy(body: StrategyProposalBody, request: Request) -> dict:
+    try:
+        return create_strategy_proposal(
+            requested_change=body.requested_change,
+            reason=body.reason,
+            created_by=str(request.state.current_user["username"]),
+        )
+    except (ValueError, RuntimeError) as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
+@router.post("/strategy/proposals/{proposal_id}/preview")
+def preview_strategy(proposal_id: str) -> dict:
+    try:
+        return preview_strategy_proposal(proposal_id)
+    except LookupError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except RuntimeError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
+@router.post("/strategy/proposals/{proposal_id}/approve")
+def approve_strategy(proposal_id: str, request: Request) -> dict:
+    try:
+        return approve_strategy_proposal(
+            proposal_id,
+            approved_by=str(request.state.current_user["username"]),
+        )
+    except LookupError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except RuntimeError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc

+ 1 - 0
api/routers/pipeline.py

@@ -32,6 +32,7 @@ def create_pipeline_run(body: CreatePipelineRunBody | None = None) -> dict:
         biz_dt=payload.biz_dt,
         source="api",
         reason=payload.reason,
+        dry_run=payload.dry_run,
     )
 
 

+ 1 - 0
api/schemas/pipeline.py

@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
 class CreatePipelineRunBody(BaseModel):
     biz_dt: str | None = Field(default=None, pattern=r"^\d{8}$")
     reason: str | None = Field(default=None, max_length=2000)
+    dry_run: bool = False
 
 
 class PipelineRunActionResponse(BaseModel):

+ 420 - 0
api/services/business_harness.py

@@ -0,0 +1,420 @@
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import select
+
+from supply_infra.db.models.business_harness import (
+    ContentDemandCoverage,
+    ContentPerformanceFact,
+    DailyDemandPackage,
+    DailyDemandTask,
+    DemandAttributionSnapshot,
+    DemandEvaluationSnapshot,
+    DemandScoreContribution,
+    EvidencePackage,
+    PlatformDemand,
+    PlatformDemandCategoryRel,
+    PlatformDemandVersion,
+    RawDemandExpression,
+    StandardDemandAlias,
+    StrategyVersion,
+)
+from supply_infra.db.session import get_session
+
+
+def get_published_demand_package(
+    *,
+    biz_dt: str | None = None,
+    run_id: str | None = None,
+) -> dict[str, Any] | None:
+    with get_session() as session:
+        stmt = select(DailyDemandPackage).where(
+            DailyDemandPackage.status == "published"
+        )
+        if biz_dt:
+            stmt = stmt.where(DailyDemandPackage.biz_dt == biz_dt)
+        if run_id:
+            stmt = stmt.where(DailyDemandPackage.run_id == run_id)
+        package = session.scalar(
+            stmt.order_by(
+                DailyDemandPackage.biz_dt.desc(),
+                DailyDemandPackage.package_version.desc(),
+                DailyDemandPackage.created_at.desc(),
+            ).limit(1)
+        )
+        if package is None:
+            return None
+        evidence = session.get(EvidencePackage, package.evidence_package_id)
+        strategy = session.get(StrategyVersion, package.strategy_version_id)
+        tasks = list(
+            session.scalars(
+                select(DailyDemandTask)
+                .where(
+                    DailyDemandTask.demand_package_id
+                    == package.demand_package_id
+                )
+                .order_by(DailyDemandTask.action_tier, DailyDemandTask.task_id)
+            ).all()
+        )
+        items: list[dict[str, Any]] = []
+        for task in tasks:
+            evaluation = session.get(
+                DemandEvaluationSnapshot,
+                task.evaluation_id,
+            )
+            contributions = list(
+                session.scalars(
+                    select(DemandScoreContribution)
+                    .where(
+                        DemandScoreContribution.evaluation_id
+                        == task.evaluation_id
+                    )
+                    .order_by(
+                        DemandScoreContribution.score_type,
+                        DemandScoreContribution.dimension,
+                    )
+                ).all()
+            )
+            payload = dict(task.task_payload_json)
+            payload["task_id"] = task.task_id
+            payload["evaluation_id"] = task.evaluation_id
+            payload["score_contributions"] = [
+                {
+                    "score_type": row.score_type,
+                    "dimension": row.dimension,
+                    "raw_value": row.raw_value_json,
+                    "normalized_value": float(row.normalized_value),
+                    "configured_weight": float(row.configured_weight),
+                    "effective_weight": float(row.effective_weight),
+                    "contribution": float(row.contribution),
+                    "data_status": row.data_status,
+                    "reason": row.reason,
+                }
+                for row in contributions
+            ]
+            if evaluation is not None:
+                payload["decision_reason"] = evaluation.decision_reason
+                payload["missing_dimensions"] = (
+                    evaluation.missing_dimensions_json
+                )
+            items.append(payload)
+
+        return {
+            "demand_package_id": package.demand_package_id,
+            "run_id": package.run_id,
+            "biz_dt": package.biz_dt,
+            "package_version": package.package_version,
+            "status": package.status,
+            "content_hash": package.content_hash,
+            "item_count": package.item_count,
+            "published_at": (
+                package.published_at.isoformat()
+                if package.published_at is not None
+                else None
+            ),
+            "evidence_package": (
+                {
+                    "package_id": evidence.package_id,
+                    "status": evidence.status,
+                    "source_snapshot_hash": evidence.source_snapshot_hash,
+                    "source_versions": evidence.source_versions_json,
+                    "evidence_count": evidence.evidence_count,
+                }
+                if evidence is not None
+                else None
+            ),
+            "strategy": (
+                {
+                    "strategy_version_id": strategy.strategy_version_id,
+                    "version_key": strategy.version_key,
+                    "definition": strategy.definition_json,
+                    "change_reason": strategy.change_reason,
+                }
+                if strategy is not None
+                else None
+            ),
+            "items": items,
+        }
+
+
+def get_platform_demand_history(
+    platform_demand_id: str,
+) -> dict[str, Any] | None:
+    with get_session() as session:
+        demand = session.get(PlatformDemand, platform_demand_id)
+        if demand is None:
+            return None
+        versions = list(
+            session.scalars(
+                select(PlatformDemandVersion)
+                .where(
+                    PlatformDemandVersion.platform_demand_id
+                    == platform_demand_id
+                )
+                .order_by(PlatformDemandVersion.version_no.desc())
+            ).all()
+        )
+        version_payloads: list[dict[str, Any]] = []
+        for row in versions:
+            category_relations = list(
+                session.scalars(
+                    select(PlatformDemandCategoryRel)
+                    .where(
+                        PlatformDemandCategoryRel.platform_demand_version_id
+                        == row.platform_demand_version_id
+                    )
+                    .order_by(
+                        PlatformDemandCategoryRel.relation_type,
+                        PlatformDemandCategoryRel.category_id,
+                    )
+                ).all()
+            )
+            evidence_rows = list(
+                session.scalars(
+                    select(RawDemandExpression)
+                    .join(
+                        StandardDemandAlias,
+                        StandardDemandAlias.expression_id
+                        == RawDemandExpression.expression_id,
+                    )
+                    .where(
+                        StandardDemandAlias.term_id == row.term_id,
+                        RawDemandExpression.package_id == row.package_id,
+                    )
+                    .order_by(
+                        RawDemandExpression.source_type,
+                        RawDemandExpression.source_record_id,
+                    )
+                ).all()
+            )
+            evaluation = session.scalar(
+                select(DemandEvaluationSnapshot).where(
+                    DemandEvaluationSnapshot.platform_demand_version_id
+                    == row.platform_demand_version_id
+                )
+            )
+            contributions = (
+                list(
+                    session.scalars(
+                        select(DemandScoreContribution)
+                        .where(
+                            DemandScoreContribution.evaluation_id
+                            == evaluation.evaluation_id
+                        )
+                        .order_by(
+                            DemandScoreContribution.score_type,
+                            DemandScoreContribution.dimension,
+                        )
+                    ).all()
+                )
+                if evaluation is not None
+                else []
+            )
+            coverages = list(
+                session.scalars(
+                    select(ContentDemandCoverage)
+                    .where(
+                        ContentDemandCoverage.platform_demand_version_id
+                        == row.platform_demand_version_id
+                    )
+                    .order_by(
+                        ContentDemandCoverage.content_id,
+                        ContentDemandCoverage.coverage_role,
+                    )
+                ).all()
+            )
+            coverage_payloads: list[dict[str, Any]] = []
+            for coverage in coverages:
+                attributions = list(
+                    session.scalars(
+                        select(DemandAttributionSnapshot)
+                        .where(
+                            DemandAttributionSnapshot.coverage_id
+                            == coverage.coverage_id
+                        )
+                        .order_by(DemandAttributionSnapshot.created_at)
+                    ).all()
+                )
+                attribution_payloads: list[dict[str, Any]] = []
+                for attribution in attributions:
+                    fact = session.get(
+                        ContentPerformanceFact,
+                        attribution.performance_fact_id,
+                    )
+                    attribution_payloads.append(
+                        {
+                            "attribution_id": attribution.attribution_id,
+                            "support_state": attribution.support_state,
+                            "attributable_rov": (
+                                float(attribution.attributable_rov)
+                                if attribution.attributable_rov is not None
+                                else None
+                            ),
+                            "attributable_vov": (
+                                float(attribution.attributable_vov)
+                                if attribution.attributable_vov is not None
+                                else None
+                            ),
+                            "attribution_confidence": float(
+                                attribution.attribution_confidence
+                            ),
+                            "validity_influence": float(
+                                attribution.validity_influence
+                            ),
+                            "priority_influence": float(
+                                attribution.priority_influence
+                            ),
+                            "reason": attribution.reason,
+                            "performance_fact": (
+                                {
+                                    "performance_fact_id": (
+                                        fact.performance_fact_id
+                                    ),
+                                    "biz_dt": fact.biz_dt,
+                                    "source": fact.source,
+                                    "exposure_count": fact.exposure_count,
+                                    "sample_size": fact.sample_size,
+                                    "content_quality": (
+                                        float(fact.content_quality)
+                                        if fact.content_quality is not None
+                                        else None
+                                    ),
+                                    "rov": (
+                                        float(fact.rov)
+                                        if fact.rov is not None
+                                        else None
+                                    ),
+                                    "vov": (
+                                        float(fact.vov)
+                                        if fact.vov is not None
+                                        else None
+                                    ),
+                                    "observed_at": fact.observed_at.isoformat(),
+                                }
+                                if fact is not None
+                                else None
+                            ),
+                        }
+                    )
+                coverage_payloads.append(
+                    {
+                        "coverage_id": coverage.coverage_id,
+                        "task_id": coverage.task_id,
+                        "content_id": coverage.content_id,
+                        "content_url": coverage.content_url,
+                        "source_type": coverage.source_type,
+                        "coverage_role": coverage.coverage_role,
+                        "coverage_score": float(coverage.coverage_score),
+                        "evidence": coverage.evidence_json,
+                        "status": coverage.status,
+                        "content_published_at": (
+                            coverage.content_published_at.isoformat()
+                            if coverage.content_published_at is not None
+                            else None
+                        ),
+                        "attributions": attribution_payloads,
+                    }
+                )
+            version_payloads.append(
+                {
+                    "platform_demand_version_id": (
+                        row.platform_demand_version_id
+                    ),
+                    "version_no": row.version_no,
+                    "biz_dt": row.biz_dt,
+                    "run_id": row.run_id,
+                    "evidence_package_id": row.package_id,
+                    "name": row.name,
+                    "description": row.description,
+                    "cognition_confidence": float(row.cognition_confidence),
+                    "reason": row.reason,
+                    "change_type": row.change_type,
+                    "evidence_hash": row.evidence_hash,
+                    "raw_evidence": [
+                        {
+                            "expression_id": evidence.expression_id,
+                            "source_type": evidence.source_type,
+                            "source_record_id": evidence.source_record_id,
+                            "raw_text": evidence.raw_text,
+                            "original_payload": (
+                                evidence.original_payload_json
+                            ),
+                            "content_hash": evidence.content_hash,
+                            "data_quality": evidence.data_quality,
+                        }
+                        for evidence in evidence_rows
+                    ],
+                    "category_relations": [
+                        {
+                            "category_id": int(relation.category_id),
+                            "relation_type": relation.relation_type,
+                            "relation_source": relation.relation_source,
+                            "reason": relation.reason,
+                            "confidence": float(relation.confidence),
+                            "is_inferred": bool(relation.is_inferred),
+                            "status": relation.status,
+                        }
+                        for relation in category_relations
+                    ],
+                    "evaluation": (
+                        {
+                            "evaluation_id": evaluation.evaluation_id,
+                            "strategy_version_id": (
+                                evaluation.strategy_version_id
+                            ),
+                            "validity_score": float(
+                                evaluation.validity_score
+                            ),
+                            "local_supply_priority": float(
+                                evaluation.local_supply_priority
+                            ),
+                            "data_confidence": float(
+                                evaluation.data_confidence
+                            ),
+                            "posterior_state": evaluation.posterior_state,
+                            "action_tier": evaluation.action_tier,
+                            "metrics": evaluation.metrics_json,
+                            "missing_dimensions": (
+                                evaluation.missing_dimensions_json
+                            ),
+                            "decision_reason": evaluation.decision_reason,
+                            "score_contributions": [
+                                {
+                                    "score_type": contribution.score_type,
+                                    "dimension": contribution.dimension,
+                                    "raw_value": (
+                                        contribution.raw_value_json
+                                    ),
+                                    "normalized_value": float(
+                                        contribution.normalized_value
+                                    ),
+                                    "configured_weight": float(
+                                        contribution.configured_weight
+                                    ),
+                                    "effective_weight": float(
+                                        contribution.effective_weight
+                                    ),
+                                    "contribution": float(
+                                        contribution.contribution
+                                    ),
+                                    "data_status": contribution.data_status,
+                                    "reason": contribution.reason,
+                                }
+                                for contribution in contributions
+                            ],
+                        }
+                        if evaluation is not None
+                        else None
+                    ),
+                    "content_coverage": coverage_payloads,
+                }
+            )
+        return {
+            "platform_demand_id": demand.platform_demand_id,
+            "name": demand.name,
+            "description": demand.description,
+            "status": demand.status,
+            "lifecycle_state": demand.lifecycle_state,
+            "versions": version_payloads,
+        }

+ 51 - 0
api/services/demand_feedback.py

@@ -5,10 +5,16 @@ import json
 from typing import Any
 
 from pydantic import ValidationError
+from sqlalchemy import select
 from sqlalchemy.exc import IntegrityError
 
 from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.services.video_discovery import _parse_video_ids
+from supply_infra.db.models.business_harness import (
+    DailyDemandPackage,
+    DailyDemandTask,
+    PlatformDemandVersion,
+)
 from supply_infra.db.models.demand_feedback import DemandFeedback
 from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
@@ -44,6 +50,10 @@ def _serialize_feedback(row: DemandFeedback) -> dict[str, Any]:
         target_snapshot = json.loads(row.target_snapshot_json)
     except (TypeError, ValueError):
         target_snapshot = {}
+    try:
+        impact = json.loads(row.impact_json) if row.impact_json else None
+    except (TypeError, ValueError):
+        impact = None
     return {
         "id": int(row.id),
         "target_type": row.target_type,
@@ -64,6 +74,16 @@ def _serialize_feedback(row: DemandFeedback) -> dict[str, Any]:
             "username": row.feedback_username_snapshot,
             "display_name": row.feedback_user_name_snapshot,
         },
+        "processing_status": row.processing_status,
+        "platform_demand_id": row.platform_demand_id,
+        "platform_demand_version_id": row.platform_demand_version_id,
+        "processed_by": row.processed_by,
+        "processed_at": (
+            row.processed_at.isoformat() if row.processed_at is not None else None
+        ),
+        "resolution_reason": row.resolution_reason,
+        "consumed_run_id": row.consumed_run_id,
+        "impact": impact,
         "created_at": row.created_at.isoformat() if row.created_at else None,
     }
 
@@ -181,6 +201,26 @@ def create_demand_feedback(
             }
 
         grade, target_snapshot = _resolve_target(session, body)
+        platform_version = session.scalar(
+            select(PlatformDemandVersion)
+            .join(
+                DailyDemandTask,
+                DailyDemandTask.platform_demand_version_id
+                == PlatformDemandVersion.platform_demand_version_id,
+            )
+            .join(
+                DailyDemandPackage,
+                DailyDemandPackage.demand_package_id
+                == DailyDemandTask.demand_package_id,
+            )
+            .where(
+                PlatformDemandVersion.source_demand_grade_id == int(grade.id),
+                DailyDemandPackage.biz_dt == str(grade.biz_dt),
+                DailyDemandPackage.status == "published",
+            )
+            .order_by(DailyDemandPackage.package_version.desc())
+            .limit(1)
+        )
         feedback = DemandFeedback(
             client_request_id=body.client_request_id,
             target_type=body.target_type,
@@ -199,6 +239,17 @@ def create_demand_feedback(
             feedback_user_id=feedback_user_id,
             feedback_user_name_snapshot=feedback_user_name[:128],
             feedback_username_snapshot=feedback_username[:64],
+            processing_status="pending",
+            platform_demand_id=(
+                platform_version.platform_demand_id
+                if platform_version is not None
+                else None
+            ),
+            platform_demand_version_id=(
+                platform_version.platform_demand_version_id
+                if platform_version is not None
+                else None
+            ),
         )
         try:
             repo.add(feedback)

+ 2 - 0
api/services/pipeline.py

@@ -19,12 +19,14 @@ def create_run(
     biz_dt: str | None,
     source: str,
     reason: str | None = None,
+    dry_run: bool = False,
 ) -> dict[str, Any]:
     return submit_pipeline_run(
         biz_dt=biz_dt,
         trigger_type="api",
         trigger_source=source,
         trigger_reason=reason,
+        dry_run=dry_run,
     ).to_dict()
 
 

+ 20 - 1
deploy/README.md

@@ -17,7 +17,12 @@ Scheduler、多个 Pipeline Worker 和 Reconciler。
    进度日志,超时记录会标记失败,批次继续处理下一条需求。
 8. 首次部署配置 `AUTH_BOOTSTRAP_ADMIN_USERNAME` 和至少 8 位的
    `AUTH_BOOTSTRAP_ADMIN_PASSWORD` 以创建初始管理员;已有同名用户不会被覆盖。
-   HTTPS 生产环境设置 `AUTH_COOKIE_SECURE=true`。
+   HTTPS 生产环境设置 `AUTH_COOKIE_SECURE=true`。生产镜像默认启用
+   `PIPELINE_REQUIRE_SECURE_COOKIE=true`,配置错误时会拒绝启动。
+9. 容器按“迁移 → 运行时预检 → Supervisor”顺序启动。预检会验证数据库已到
+   Alembic head、依赖表完整、15 个步骤处理器一致、Scheduler 已启用,以及
+   MySQL、ODPS、OpenRouter、AIGC、OSS 等必要配置。任一硬性条件失败时不会启动
+   API/Worker,避免部分可用状态。
 
 示例:
 
@@ -36,6 +41,20 @@ docker run \
 容器启动时默认运行 `alembic upgrade head`。紧急回滚代码时不自动 downgrade
 数据库;四张控制面表保留审计数据。
 
+镜像还提供 Docker `HEALTHCHECK`。`/health/ready` 只有在数据库可访问、
+Scheduler 心跳有效、活跃 Worker 数达到 `PIPELINE_WORKER_PROCESSES`、且
+Reconciler 心跳有效时才返回成功。部署平台应等待容器变为 healthy 后再切流,
+上线后也应对 unhealthy 状态告警。
+
+发布前可在与生产相同版本的 MySQL 上运行隔离迁移验证(临时库名有安全前缀,
+脚本结束会自动删除,且会校验源库未变化):
+
+```bash
+.venv/bin/python scripts/mysql_schema_migration_smoke.py \
+  --database supply_agent_preflight_release \
+  --downgrade-to 20260731_08
+```
+
 Reconciler 会把失败、失联、漏跑恢复以及 12 小时/2 小时/30 分钟分级预警以
 `pipeline_alert` 结构化日志写到容器 stdout。第一阶段不上传到现有公共 CDN OSS;
 生产应由 Docker 日志采集器接入现有告警平台,或至少保留容器日志。

+ 0 - 1
problem/2026-07-24_问题总结.md

@@ -464,4 +464,3 @@ PRD 要求你可以在对话中查看定义、查询数据、要求分析、提
 - 未发现:受版本管理的自动化测试文件。
 - 未执行:前端构建;原因是 `web/node_modules` 不存在,未在本次分析任务中安装依赖。
 - 发现:`git diff --check afa8a6d..HEAD` 报告 `DemandGradeListPanel.vue` 末尾空白行。
-

+ 284 - 0
problem/2026-07-31_后续工作计划.md

@@ -0,0 +1,284 @@
+# 2026-07-31 后续工作计划
+
+## 1. 目标
+
+接下来的工作不以继续增加页面或 Agent 能力为第一目标,而是依次完成三件事:
+
+1. 让每日流水线在空库、重试、数据修订和跨日场景下都能产生可信结果;
+2. 建立稳定、可版本化的“平台需求”对象,统一评分、找片和人的查看口径;
+3. 建立“需求 → 内容承接 → 内容表现 → 次日判断”的反馈闭环。
+
+只有阶段一的发布门禁全部通过后,阶段二和阶段三的结果才允许成为正式业务输出。
+
+## 2. 当前基线修正
+
+`2026-07-30_问题总结.md` 的审查基线是 `feature/zhangbo@693fe6b`。当前仓库已经合并到
+`master@c36d728`,因此开始开发前应先更新问题状态:
+
+- 人反馈表、API 和前端入口已经合入,P1-6 中“没有反馈入口”已部分解决;后续重点是反馈处理、归因和决策回流。
+- Find Agent 增加了规则门禁、审计字段和记录页面,P2-2 已部分改善;后续重点是统一任务包和内容承接关系。
+- P0-1 仍未解决:Alembic 没有创建 `video_discovery_*` 基础表,最新迁移却直接对这些表增加字段。空库执行 `alembic upgrade head` 仍可能失败。
+- P0-2、P0-3、P0-4 以及平台需求、双评分、统一任务包、内容表现归因仍是未闭合项。
+
+第一项实际工作应是做一次短平快的基线复核,把原问题逐条标记为:
+`未开始 / 部分完成 / 已完成但未验收 / 已验收`,避免重复建设。
+
+## 3. 阶段一:可信日运行(P0)
+
+### WP0-1:收敛数据库迁移
+
+**工作内容**
+
+- 将 `sql/video_discovery_*.sql` 中仍在使用的完整表结构收敛到 Alembic。
+- 明确“已存在生产库升级”和“全新空库创建”两条迁移路径。
+- 校对 ORM Model、Alembic 和实际数据库,删除三套定义之间的漂移。
+- 将空库迁移加入 CI。
+
+**验收标准**
+
+- 空数据库可一次执行 `alembic upgrade head` 成功。
+- 从当前生产前一版本升级到 `head` 成功,已有数据不丢失。
+- 升级后可创建最小 Pipeline Run,并至少跑到 `video_discovery` 步骤。
+- CI 自动检查关键表、字段、索引和唯一约束存在。
+
+### WP0-2:重构 AIGC 外部写入为事务 Outbox
+
+**工作内容**
+
+- 在调用 AIGC 前,以稳定业务键创建并提交 Outbox 事件。
+- 建议业务键至少包含:`biz_dt + platform_demand/task_version + candidate_version + action_type`。
+- Worker 只领取未确认事件,保存发送次数、最后错误、下次重试时间和外部返回 ID。
+- 若外部平台支持幂等键,将业务键一并传递;若不支持,本地必须以唯一约束阻止重复业务请求。
+- 把“生成业务请求”和“发送外部请求”拆成两个可独立恢复的步骤。
+
+**验收标准**
+
+- 在“外部成功但本地未确认”“请求超时”“Worker 崩溃”三个故障点分别重试,最终只产生一个外部计划。
+- 同一日、同一任务重复执行不会创建第二个事件。
+- 运行页可以查到事件状态、重试次数、外部 ID 和失败原因。
+
+### WP0-3:将上游同步从行数比较改为版本/内容差分
+
+**工作内容**
+
+- 优先使用上游更新时间、版本号或水位线。
+- 上游没有版本字段时,对业务主键和影响下游的字段生成稳定内容哈希。
+- 输出新增、修改、删除、未变化数量以及上游版本。
+- 保证同一输入重复执行幂等。
+
+**验收标准**
+
+- “总行数相同、内容发生替换”能够识别并更新。
+- 完全相同的输入可以安全跳过。
+- 删除、迟到和修订数据有明确处理规则及测试。
+- Pipeline 步骤输出能回答“本次到底变了哪些数据”。
+
+### WP0-4:隔离跨日关系并补齐关系语义
+
+**工作内容**
+
+- 所有关系生成强制携带 `biz_dt` 或证据版本。
+- 优先保存上游显式关系,禁止无说明地覆盖为系统推断。
+- 关系边补充 `relation_type`、`relation_source`、`reason`、`confidence`、
+  `is_inferred`、有效期和状态。
+- 字符串匹配只生成低置信候选,不直接写为正式关系。
+
+**验收标准**
+
+- 当日计算不会读取或连接到其他业务日的证据。
+- 短词子串命中不会自动成为正式边。
+- 任意一条正式边都能查到来源、日期、理由和置信度。
+- 上游事实边与系统推断边可分别过滤和展示。
+
+### WP0-5:恢复可重复的测试与发布门禁
+
+**工作内容**
+
+- 固定 Python 3.11 和依赖安装/锁定方式,确保 SQLAlchemy 2.x、pytest 和
+  pytest-asyncio 版本一致。
+- CI 至少增加以下集成测试:
+  - 空库迁移;
+  - 单日最小流水线;
+  - 同数量内容变化;
+  - 跨日关系隔离;
+  - 外部副作用幂等;
+  - 失败步骤阻断发布。
+- 修复 `git diff --check` 的历史格式告警。
+
+**阶段退出条件**
+
+上述测试全部通过,且预发环境连续运行至少 3 个业务日无重复外部动作、无跨日串数,
+才认为“可信日运行”完成。
+
+## 4. 阶段二:统一业务对象(P1)
+
+### WP1-1:定义三层需求对象和每日证据包
+
+需要先完成数据模型和接口契约评审,再写实现:
+
+```text
+原始需求表达(不可变)
+  → 标准需求词/意图(可归一、可维护别名)
+  → 平台需求(稳定 ID、版本、生命周期)
+```
+
+同时建立不可变的每日证据包,保存原始事实引用、关系版本、上游版本、策略版本和
+Pipeline Run。平台需求应成为评分、任务包、内容承接和反馈的唯一中心对象。
+
+**验收标准**
+
+- 同一需求跨日保持稳定 ID,但每日结论有独立版本。
+- 能从平台需求追溯到原始表达、别名、关系和证据日期。
+- 合并、拆分、停用和再激活不覆盖历史。
+- 历史 Run 在上游数据修订后仍能解释当时使用的证据。
+
+### WP1-2:建立树骨架与关系图
+
+- 分类树只表达稳定的层级骨架。
+- 横向关系图表达主挂靠、辅助挂靠、上游关联和推断关联。
+- 所有边沿用 WP0-4 的来源、理由、置信度、版本和状态字段。
+- 前台展示完整祖先路径,并区分事实与推断。
+
+**验收标准**
+
+- 一个平台需求可拥有一个主挂靠和多个辅助挂靠。
+- 任意挂靠可展开完整路径和关系说明。
+- 修改挂靠生成新版本,不覆盖历史版本。
+
+### WP1-3:兼容迁移和双写
+
+- 设计旧 `DemandGrade`、`demand_belong_category` 到新对象的映射和回填规则。
+- 在过渡期采用双读校验或双写,比较新旧结果。
+- 设定旧接口停用时间,避免长期维护两套业务事实。
+
+## 5. 阶段三:可解释决策与统一发布(P1)
+
+### WP2-1:实现两个 0–1 决策结果
+
+每日按平台需求版本产出:
+
+- `validity_score`:需求成立度;
+- `local_supply_priority`:局部供给优先级。
+
+每个结果同时保存原始指标、标准化值、权重、正负贡献、缺失状态、样本质量、置信度和
+reason。缺失后验只能标记为未知,不能按零处理。
+
+**验收标准**
+
+- 两个分数均可由存储的贡献明细复算。
+- 任意分数可展开“哪些因素加分、哪些因素扣分、数据是否充分”。
+- ROV 和 VOV 原值与模型归因判断分开保存。
+
+### WP2-2:映射五类行动层
+
+由两个分数和证据质量映射为:
+
+1. 保供;
+2. 优先;
+3. 定向验证;
+4. 探索;
+5. 抑制。
+
+行动层规则必须版本化;探索额度和后验门槛先通过历史数据回放校准,不能直接写死为
+永久规则。
+
+### WP2-3:发布唯一的每日需求任务包
+
+任务包至少包含:
+
+- Pipeline Run、业务日期、平台需求 ID/版本、策略版本;
+- 全部挂靠路径和关系;
+- 两个分数、贡献明细、行动层;
+- 搜索词、排除词、命中规则、已有内容覆盖;
+- 待验证假设、证据缺口和探索额度。
+
+人和 Find Agent 必须读取同一份已发布任务包,不能再分别解释 `DemandGrade`。
+
+**验收标准**
+
+- 一个成功 Run 只产生一个不可变的已发布任务包版本。
+- 任务包未完成时,前端显示“该批次未发布”,不拼接其他日期的最新数据。
+- 热力图、需求详情和 Find Agent 强制使用相同的 `biz_dt/run_id/package_version`。
+
+## 6. 阶段四:内容闭环与协作控制(P1/P2)
+
+### WP3-1:建立需求—内容承接关系
+
+保存平台需求与内容的多对多关系,包括主/辅助需求、覆盖程度、命中证据、上线时间、
+供给类型、来源任务包和 Find Agent 决策记录。
+
+### WP3-2:建立内容表现事实与归因
+
+- 独立保存曝光、质量、供给量、ROV/VOV 等原始表现事实。
+- 基于覆盖程度、时间窗、样本量和内容质量产出归因结论与置信度。
+- 每日评估快照记录“哪些反馈影响了多少”,可从需求反查到内容和原始表现。
+
+### WP3-3:把已有反馈入口接入处理闭环
+
+现有反馈能力先作为追加事件保留,后续增加:
+
+- 反馈状态:待处理、已采纳、已拒绝、需补证;
+- 处理人、处理理由、关联策略/需求版本;
+- 反馈触发的合并、拆分、挂靠修正或数据问题工单;
+- 次日评估是否消费该反馈及其影响记录。
+
+### WP3-4:只读问答与版本化策略
+
+- 先实现可审计的只读查询计划和结果记录,用于回答“为什么是/不是需求”。
+- 将维度字典、合并/拆分规则、行动层、探索额度和后验门槛建成版本化策略。
+- 自然语言修改只能生成草案;必须经过历史数据影响预览和人工确认后发布。
+
+## 7. 推荐排期
+
+### 第 1 个迭代:只做可信运行
+
+建议拆成三个独立 PR,降低相互影响:
+
+1. **PR-A:迁移与测试环境**
+   - WP0-1、WP0-5;
+2. **PR-B:输入与关系正确性**
+   - WP0-3、WP0-4;
+3. **PR-C:外部副作用幂等**
+   - WP0-2。
+
+本迭代不新增业务页面。完成后在预发连续运行 3 个业务日。
+
+### 第 2 个迭代:平台需求 MVP
+
+- 完成 WP1-1、WP1-2 的模型与 API 契约;
+- 完成核心表、版本、回填和只读查询;
+- 选择少量历史日期做双读校验;
+- 暂不替换全部旧页面和 Find Agent。
+
+### 第 3 个迭代:双评分和统一任务包
+
+- 完成 WP2-1 至 WP2-3;
+- Find Agent 与热力图切换到统一任务包;
+- 旧 `DemandGrade` 接口进入弃用期。
+
+### 第 4 个迭代:内容表现归因和协作闭环
+
+- 完成 WP3-1 至 WP3-4;
+- 用历史数据校准阈值;
+- 形成反馈处理和策略发布的运营流程。
+
+## 8. 立即可执行的任务清单
+
+1. 给原总结的 P0/P1/P2 项逐条补充当前状态和负责人。
+2. 创建 PR-A,先让空库 `alembic upgrade head` 可通过。
+3. 修复本地/CI 测试依赖,跑通当前所有测试,保存基线结果。
+4. 为 Outbox 重构写故障注入测试,再改生产代码。
+5. 为“同数量内容修订”和“跨日短词误匹配”各准备一个最小复现数据集。
+6. 评审平台需求、需求版本、证据包和关系边的数据模型。
+7. 在 P0 发布门禁通过前,暂停继续扩展正式评分逻辑和新的外部写入动作。
+
+## 9. 里程碑验收口径
+
+| 里程碑 | 完成标志 |
+|---|---|
+| M0:基线明确 | 所有问题有状态、负责人、验收用例和目标迭代 |
+| M1:可信运行 | 空库迁移、差分同步、跨日隔离、Outbox 幂等和最小流水线全部通过 |
+| M2:对象统一 | 平台需求有稳定 ID/版本,可追溯原始证据和完整关系 |
+| M3:统一发布 | 双 0–1 分数、五类行动层和唯一任务包成为人/Agent 的共同输入 |
+| M4:闭环形成 | 内容表现可归因到需求,反馈和策略修改可审计并影响后续版本 |
+

+ 237 - 0
problem/2026-07-31_实施与上线验收.md

@@ -0,0 +1,237 @@
+# 2026-07-31 实施与上线验收
+
+## 1. 结论
+
+已按 `2026-07-31_后续工作计划.md` 完成从 P0 可信运行到内容反馈闭环的核心开发,
+并将默认发布门禁固化为 `scripts/release_gate.sh`。
+
+当前本地验收结果:
+
+- 空 SQLite 数据库从零执行全部 Alembic 迁移到 `20260731_13` 成功;
+- 后端默认回归:171 passed,3 deselected;
+- 3 项未进入默认门禁的测试是需要真实 MySQL 或外部 LLM 的集成测试;
+- 本次变更涉及的 Python 文件 Ruff 检查通过;
+- Python 编译检查通过;
+- Vue/TypeScript 生产构建通过;
+- `git diff --check` 通过。
+- 当前配置 MySQL 的隔离结构副本完成 `20260731_08 → 20260731_13`
+  降级/升级演练,最终 47 张表、revision 为 `20260731_13`,源库结构校验未变化;
+- 容器启动增加迁移后运行时预检;Docker 健康检查会验证 Scheduler、Worker 和
+  Reconciler 的租约心跳,而不只检查 API 端口。
+
+以上结果证明代码和离线迁移满足当前发布门禁,但不能替代生产 MySQL 升级演练、真实
+AIGC 联调和预发连续运行。因此,只有完成本文第 5 节的上线前检查后才可发布。
+
+审计说明:第一次编写 MySQL 隔离迁移脚本时,Alembic 环境覆盖了脚本传入的 URL,
+导致当前 `.env` 指向的 `supply_agent` 被从 `20260731_08` 升级到
+`20260731_13`。该过程没有提交新 Pipeline Run,也没有调用 AIGC;新增业务表行数均为
+0,但迁移 13 按设计为 25,723 条旧关系补充了
+`legacy_name_match/active` 等关系元数据。随后已修复 URL 注入,并增加“源库 revision
+或表集合变化即失败”的保护;之后的隔离演练确认源库不再变化。未在没有备份和明确授权
+的情况下执行 downgrade。
+
+## 2. 每一步做了什么
+
+### 第一步:建立可重复的测试基线
+
+- 使用项目 `.venv` 中的 Python 3.12、SQLAlchemy 2.0 和 pytest 运行基线;
+- 把真实 MySQL、OpenRouter 调用标记为 `integration`,默认测试不再因外部环境偶发失败;
+- 修正过期测试,使 Find Agent 工具断言与“运行由调度器预创建”的当前契约一致;
+- 默认 `pytest` 只跑可重复的离线测试,真实集成测试必须显式执行。
+
+解决的问题:过去“测试失败”混合了代码缺陷、环境缺失和过期断言,无法作为发布依据。
+
+### 第二步:收敛 Alembic 迁移
+
+- 增加 `20260731_08a`,由 Alembic 接管完整的 `video_discovery_*` 基础表;
+- 对已通过历史 SQL 创建表的数据库采用存在性检查,避免重复建表;
+- 修复反馈用户名迁移的跨方言更新与列修改;
+- 增加 `20260731_09` 至 `20260731_13`,依次创建事务 Outbox、平台需求、
+  双评分/任务包、内容归因/反馈处理/策略治理和版本化关系结构;
+- `ALEMBIC_DATABASE_URL` 可让 CI 使用隔离数据库做迁移冒烟;
+- 增加空库迁移测试,并验证关键表、字段及最终 revision。
+- 增加历史手工建表接管测试,验证已有门禁字段不会被重复添加,已有记录不会丢失。
+
+解决的问题:新环境只执行 `alembic upgrade head` 时找片核心表缺失。
+
+### 第三步:修复上游同步和跨日关系
+
+- 全局分类树改为直接读取 `loghubods.global_category` 的有效“实质”节点,使用
+  `stable_id/parent_stable_id` 建树;
+- `global_tree_element` 不再参与同步、预检或挂树,物理表仅为历史兼容保留;
+- 需求词挂树不再调用 LLM Agent,改为逐词调用 `match-paths/v2`,固定
+  `top_k=1`、`min_score=0.8`;
+- 接口返回的 `stable_id` 必须能映射到当日有效树,空匹配保守跳过,错误或未知节点
+  阻断下游;真实示例“牺牲”已验证返回 stable_id 7392;
+- 去掉“行数相同即跳过”的逻辑,改为比较影响下游的完整字段;
+- 使用稳定 SHA-256 快照识别完全相同输入;
+- 同数量但内容被修订时会更新名称、类型、视频和扩展字段;
+- 保留已经回填的真实 ROV/VOV,不因源数据重放清空;
+- 关系同步强制传入 `biz_dt`,只读取目标业务日;
+- 文本关系只接受完整 token,不再把任意子串直接作为正式关系;
+- 重建某日关系前删除该日旧边,避免脏边和重复边;
+- 旧关系边增加业务日、关系类型、来源、理由、置信度、推断标记、状态和有效期;
+- 分类需求解析按 `biz_dt` 查询关系边,不再先混合全历史边后由调用方过滤。
+
+解决的问题:同数量修订被漏掉、历史日期串入当天、短词误匹配。
+
+### 第四步:把 AIGC 外部写入改为事务 Outbox
+
+- 先按稳定业务键生成发布批次并写入 Outbox,再调用外部 AIGC;
+- 保存尝试次数、最近尝试时间、下次重试时间、外部 ID、外部名称、响应和错误;
+- 外部创建成功后先持久化外部 ID,再执行绑定;
+- 绑定失败时复用已保存外部 ID,不重新创建爬取计划;
+- 请求超时且无法确定外部结果时标记为 `ambiguous`,禁止自动重放;
+- AIGC 绑定接口先检查同一爬取源是否已存在;
+- 显式传入空 token 时不再回退到环境 token,避免测试或禁用场景误发真实请求。
+
+解决的问题:外部成功、本地未记账或重试时重复创建计划。
+
+### 第五步:建立平台需求和不可变证据包
+
+- 新建原始需求表达、标准需求词、别名、平台需求、平台需求版本和分类关系;
+- 平台需求使用标准化内容生成稳定 ID,跨日保持同一身份;
+- 每个 Pipeline Run 冻结独立证据包,并保存源版本、输入快照和内容哈希;
+- 每日认知以追加版本保存,不覆盖历史;
+- 分类关系保存主/辅类型、来源、理由、置信度和推断标记;
+- 已采纳人工反馈会进入下一次证据包,并记录 `consumed_run_id`。
+
+解决的问题:业务对象只有来源词和日分级,无法跨日追踪或解释。
+
+### 第六步:双评分、五类行动层和统一任务包
+
+- 每个平台需求版本产出 0~1 的需求成立度和局部供给优先级;
+- 每项贡献保存原值、归一值、配置权重、有效权重、贡献、数据状态和理由;
+- 缺失后验不按零处理,而是排除后重新归一并降低数据置信度;
+- 行动层统一为保供、优先、定向验证、探索、抑制;
+- 每个成功 Run 发布唯一、不可变的每日任务包;
+- Find Agent 只消费已发布任务包,并携带平台需求版本、评分、行动层、搜索词、
+  假设、证据缺口和已有内容;
+- `suppress` 不进入找片,不再回退到旧 `DemandGrade` 自行解释。
+
+解决的问题:人和 Agent 使用不同口径,单一旧分数同时承担多种业务含义。
+
+### 第七步:内容覆盖、表现归因和人工反馈
+
+- 找片运行和候选结果引用每日任务、平台需求版本;
+- 主候选生成需求—内容覆盖边,保存覆盖度和命中证据;
+- 内容表现事实以不可变记录保存曝光、样本、质量、ROV/VOV 和原始 payload;
+- 缺少 ROV/VOV 时归因为 `inconclusive`,不影响次日评分;
+- 有真实 ROV/VOV 时结合覆盖度、质量和样本量生成支持/反对结论、置信度及影响值;
+- 次日评分只消费已完成且为支持/反对的归因,不消费未知事实;
+- 反馈增加待处理、已采纳、已拒绝、需补证状态及处理人、理由、影响、消费批次;
+- 管理员可写入真实表现事实并审核反馈,普通用户无权调用这些变更接口;
+- 平台需求历史接口可展开原始证据、分类关系、双评分贡献、内容覆盖、表现事实和归因。
+
+解决的问题:后验无法从需求追到内容,人工反馈提交后没有处理和回流。
+
+全局热力图现在先确定分类树业务日,再用同一 `biz_dt` 请求需求分级;不再并行读取两个
+各自最新的快照。
+
+### 第八步:策略草案、影响预演和审批
+
+- 策略定义独立版本化,评分运行读取唯一 active 版本;
+- 修改先创建结构化草案,保存字段级 diff;
+- 预演会对历史评估重算行动层并列出变化;
+- 未预演的草案不能审批;
+- 审批后旧策略退役,新策略激活,后续任务包保存新策略版本;
+- 草案、预演和审批接口仅管理员可用。
+
+解决的问题:策略修改会覆盖旧内容、没有影响预览和审计。
+
+## 3. 新增管理接口
+
+以下接口受现有认证中间件保护,非管理员默认返回 403:
+
+- `POST /api/business-harness/feedback/{feedback_id}/process`
+- `POST /api/business-harness/content-performance`
+- `POST /api/business-harness/strategy/proposals`
+- `POST /api/business-harness/strategy/proposals/{proposal_id}/preview`
+- `POST /api/business-harness/strategy/proposals/{proposal_id}/approve`
+
+以下只读接口允许已登录普通用户使用:
+
+- `GET /api/business-harness/packages/latest`
+- `GET /api/business-harness/demands/{platform_demand_id}/history`
+
+## 4. 自动发布门禁
+
+在仓库根目录执行:
+
+```bash
+./scripts/release_gate.sh
+```
+
+脚本按顺序执行:
+
+1. Python 编译检查;
+2. 发布关键目录 Ruff 静态检查;
+3. 空库迁移测试;
+4. 171 项离线回归;
+5. Vue/TypeScript 生产构建;
+6. Git 补丁空白检查。
+
+任何一步失败都会立即终止,禁止继续发布。
+
+实际 MySQL 结构演练使用安全前缀的隔离临时库:
+
+```bash
+.venv/bin/python scripts/mysql_schema_migration_smoke.py \
+  --database supply_agent_preflight_release \
+  --downgrade-to 20260731_08
+```
+
+脚本会验证源库未变化并在结束时删除临时库。容器则按
+`alembic upgrade head → runtime preflight → supervisord` 启动;预检失败时不会
+启动部分服务。
+
+需要真实依赖的测试单独执行:
+
+```bash
+.venv/bin/pytest -q -m integration
+```
+
+它要求可访问测试 MySQL,且配置可使用的 OpenRouter 测试凭证。禁止对生产库运行破坏性
+测试,禁止使用生产 AIGC token 做自动重放测试。
+
+## 5. 上线前必须完成
+
+1. 对生产数据库做可恢复备份,并记录备份时间和恢复命令;
+2. 在生产结构副本上执行 `alembic upgrade head`,核对最终 revision 为
+   `20260731_13`,核对行数和关键索引;
+3. 在预发使用非生产 AIGC 计划执行一次完整 Pipeline Run;
+4. 故障注入验证:
+   - AIGC 创建成功、绑定失败后重试不创建第二个计划;
+   - AIGC 超时进入 `ambiguous`,需要人工核对;
+   - 同日重复 Run 不产生第二个任务包或 Outbox 业务事件;
+5. 校验普通用户无法调用反馈处理、表现写入和策略变更接口;
+6. 预发连续运行 3 个业务日,检查跨日关系、任务包版本、归因回流和告警;
+7. 上线窗口暂停 Scheduler 和 Worker,等待运行中步骤退出,再升级数据库和容器;
+8. 新容器就绪后先检查 `/health/live`、`/health/ready`;只有 Scheduler、
+   配置数量的 Worker 和 Reconciler 心跳全部有效时才切流;
+9. 首个正式 Run 全程观察步骤状态、Outbox、AIGC 外部 ID 和任务包发布结果。
+
+## 6. 回滚和止损
+
+- 代码异常:停止 Scheduler/Worker,回滚到上一镜像;新增表保持不动,不自动 downgrade;
+- 迁移异常:保持服务停止,使用上线前备份恢复;不要在不确认数据影响时直接执行
+  `alembic downgrade`;
+- AIGC `ambiguous`:暂停该事件自动处理,到外部平台按计划名称/时间/视频核对,人工写明
+  处置结果后再继续;
+- 任务包或评分异常:停止下游找片和 AIGC,不删除证据包、评分快照、Outbox 和运行记录;
+- 若出现跨日串数、重复外部计划或未发布包被消费,立即停止当天 Pipeline,不允许只修前端
+  展示后继续运行。
+
+## 7. 尚未声称完成的事项
+
+- 尚未在真实生产 MySQL 8.0.36 上执行升级;
+- 尚未调用真实 AIGC 完成故障注入;
+- 尚未完成预发连续 3 个业务日观察;
+- 当前 `.env` 的 `SCHEDULER_ENABLED=false`,不满足生产启动预检;HTTPS 上线还需将
+  `AUTH_COOKIE_SECURE=true`。这两项未修改,避免本地检查意外触发真实日任务;
+- 当前机器没有 Docker/Supervisor 可执行文件,因此完成了 Dockerfile、进程拓扑、
+  启动入口和前端产物检查,但没有在本机实际启动最终镜像;
+- 自然语言业务问答、合并/拆分工单和完整运营前端仍属于后续产品迭代;
+- 发布门禁覆盖的 Python 目录、迁移和脚本 Ruff 检查均已通过。
+
+因此当前状态是“开发与离线验收完成,满足进入预发条件”,不是“已经证明生产零风险”。

+ 4 - 0
pyproject.toml

@@ -51,3 +51,7 @@ target-version = "py311"
 [tool.pytest.ini_options]
 asyncio_mode = "auto"
 testpaths = ["tests"]
+addopts = "-m 'not integration'"
+markers = [
+    "integration: requires live network services, credentials, or an external database",
+]

+ 4 - 0
scripts/container-entrypoint.sh

@@ -5,4 +5,8 @@ if [[ "${RUN_DB_MIGRATIONS:-true}" == "true" ]]; then
   alembic upgrade head
 fi
 
+if [[ "${RUN_RUNTIME_PREFLIGHT:-true}" == "true" ]]; then
+  python -m supply_infra.pipeline.preflight
+fi
+
 exec /usr/bin/supervisord -c /app/deploy/supervisord.pipeline.conf

+ 170 - 0
scripts/mysql_schema_migration_smoke.py

@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+import argparse
+import os
+import re
+
+from alembic import command
+from alembic.config import Config
+from sqlalchemy import create_engine, inspect, text
+from sqlalchemy.engine import make_url
+
+from supply_infra.config import InfraSettings
+from supply_infra.pipeline.preflight import REQUIRED_PIPELINE_TABLES
+
+_SAFE_DATABASE = re.compile(r"^supply_agent_preflight_[a-z0-9_]+$")
+
+
+def _quoted(identifier: str) -> str:
+    if not re.fullmatch(r"[A-Za-z0-9_]+", identifier):
+        raise ValueError(f"unsafe SQL identifier: {identifier!r}")
+    return f"`{identifier}`"
+
+
+def run_smoke(
+    target_database: str,
+    *,
+    downgrade_to: str | None = None,
+) -> dict[str, object]:
+    settings = InfraSettings()
+    source_database = settings.mysql_database
+    if not _SAFE_DATABASE.fullmatch(target_database):
+        raise ValueError(
+            "temporary database name must start with "
+            "supply_agent_preflight_ and contain only lowercase letters, "
+            "digits and underscores"
+        )
+    if target_database == source_database:
+        raise ValueError("temporary database must differ from source database")
+
+    source_url = make_url(settings.mysql_url)
+    server_url = source_url.set(database=None)
+    target_url = source_url.set(database=target_database)
+    server_engine = create_engine(server_url)
+    target_engine = None
+    created = False
+    try:
+        with server_engine.begin() as connection:
+            exists = connection.scalar(
+                text(
+                    "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA "
+                    "WHERE SCHEMA_NAME = :database"
+                ),
+                {"database": target_database},
+            )
+            if int(exists or 0) > 0:
+                raise RuntimeError(
+                    f"temporary database already exists: {target_database}"
+                )
+            connection.execute(
+                text(
+                    f"CREATE DATABASE {_quoted(target_database)} "
+                    "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
+                )
+            )
+            created = True
+
+        source_engine = create_engine(source_url)
+        try:
+            source_tables = inspect(source_engine).get_table_names()
+            with source_engine.connect() as connection:
+                source_revision = connection.scalar(
+                    text("SELECT version_num FROM alembic_version")
+                )
+        finally:
+            source_engine.dispose()
+        with server_engine.begin() as connection:
+            for table_name in source_tables:
+                connection.execute(
+                    text(
+                        f"CREATE TABLE {_quoted(target_database)}."
+                        f"{_quoted(table_name)} LIKE "
+                        f"{_quoted(source_database)}.{_quoted(table_name)}"
+                    )
+                )
+            connection.execute(
+                text(
+                    f"INSERT INTO {_quoted(target_database)}.`alembic_version` "
+                    f"SELECT * FROM {_quoted(source_database)}.`alembic_version`"
+                )
+            )
+
+        alembic_config = Config("alembic.ini")
+        target_url_text = target_url.render_as_string(hide_password=False)
+        previous_alembic_url = os.environ.get("ALEMBIC_DATABASE_URL")
+        os.environ["ALEMBIC_DATABASE_URL"] = target_url_text
+        try:
+            if downgrade_to is not None:
+                command.downgrade(alembic_config, downgrade_to)
+            command.upgrade(alembic_config, "head")
+        finally:
+            if previous_alembic_url is None:
+                os.environ.pop("ALEMBIC_DATABASE_URL", None)
+            else:
+                os.environ["ALEMBIC_DATABASE_URL"] = previous_alembic_url
+
+        target_engine = create_engine(target_url)
+        with target_engine.connect() as connection:
+            target_tables = set(inspect(connection).get_table_names())
+            revision = connection.scalar(
+                text("SELECT version_num FROM alembic_version")
+            )
+        missing = sorted(REQUIRED_PIPELINE_TABLES - target_tables)
+        if missing:
+            raise RuntimeError(
+                f"migrated schema is missing required tables: {missing}"
+            )
+        source_engine = create_engine(source_url)
+        try:
+            current_source_tables = inspect(source_engine).get_table_names()
+            with source_engine.connect() as connection:
+                current_source_revision = connection.scalar(
+                    text("SELECT version_num FROM alembic_version")
+                )
+        finally:
+            source_engine.dispose()
+        if (
+            current_source_tables != source_tables
+            or current_source_revision != source_revision
+        ):
+            raise RuntimeError(
+                "source schema changed during isolated migration smoke test"
+            )
+        return {
+            "success": True,
+            "source_database": source_database,
+            "temporary_database": target_database,
+            "downgrade_to": downgrade_to,
+            "source_table_count": len(source_tables),
+            "target_table_count": len(target_tables),
+            "revision": revision,
+            "missing_required_tables": missing,
+        }
+    finally:
+        if target_engine is not None:
+            target_engine.dispose()
+        if created:
+            with server_engine.begin() as connection:
+                connection.execute(
+                    text(f"DROP DATABASE {_quoted(target_database)}")
+                )
+        server_engine.dispose()
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="Clone the current MySQL schema and test Alembic upgrade"
+    )
+    parser.add_argument("--database", required=True)
+    parser.add_argument(
+        "--downgrade-to",
+        help="First downgrade the isolated clone to this revision",
+    )
+    args = parser.parse_args()
+    result = run_smoke(args.database, downgrade_to=args.downgrade_to)
+    for key, value in result.items():
+        print(f"{key}={value}")
+
+
+if __name__ == "__main__":
+    main()

+ 37 - 0
scripts/release_gate.sh

@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$PROJECT_DIR"
+
+if [[ ! -x .venv/bin/python || ! -x .venv/bin/pytest || ! -x .venv/bin/ruff ]]; then
+  echo "release gate failed: .venv is incomplete" >&2
+  exit 1
+fi
+if [[ ! -d web/node_modules ]]; then
+  echo "release gate failed: web/node_modules is missing; run npm ci in web/" >&2
+  exit 1
+fi
+
+echo "[1/6] Python syntax"
+.venv/bin/python -m compileall -q agents api supply_agent supply_infra scripts
+
+echo "[2/6] Static checks for release-critical code"
+.venv/bin/ruff check agents/find_agent api supply_infra alembic scripts tests
+
+echo "[3/6] Fresh-database migration"
+.venv/bin/pytest -q tests/migrations/test_fresh_database.py
+
+echo "[4/6] Offline regression suite"
+.venv/bin/pytest -q
+
+echo "[5/6] Production frontend build"
+(
+  cd web
+  npm run build
+)
+
+echo "[6/6] Patch whitespace"
+git diff --check
+
+echo "release gate passed"

+ 2 - 2
scripts/upload_logs_to_oss.py

@@ -19,8 +19,8 @@ _LOGS_DIR = _ROOT / "logs"
 if str(_ROOT) not in sys.path:
     sys.path.insert(0, str(_ROOT))
 
-from supply_infra.config import get_infra_settings
-from supply_infra.oss.client import OssClient
+from supply_infra.config import get_infra_settings  # noqa: E402
+from supply_infra.oss.client import OssClient  # noqa: E402
 
 
 def main(filename: str) -> str:

+ 0 - 1
scripts/verify_video_truncate_runtime.py

@@ -5,7 +5,6 @@ from __future__ import annotations
 
 import argparse
 import json
-import sys
 
 
 def main() -> int:

+ 1 - 1
scripts/visualize_run.py

@@ -21,7 +21,7 @@ _ROOT = Path(__file__).resolve().parents[1]
 if str(_ROOT) not in sys.path:
     sys.path.insert(0, str(_ROOT))
 
-from supply_agent.logging.cli import main
+from supply_agent.logging.cli import main  # noqa: E402
 
 if __name__ == "__main__":
     # 只消费本脚本之后的参数,避免被其它启动方式污染 argv

+ 28 - 5
supply_infra/aigc/client.py

@@ -28,12 +28,16 @@ class AigcClient:
 
     def __init__(self, token: str | None = None) -> None:
         settings = get_infra_settings()
-        self.token = (
+        resolved_token = (
             token
-            or os.getenv("AIGC_API_TOKEN")
-            or settings.aigc_api_token
-            or ""
-        ).strip()
+            if token is not None
+            else (
+                os.getenv("AIGC_API_TOKEN")
+                or settings.aigc_api_token
+                or ""
+            )
+        )
+        self.token = resolved_token.strip()
 
     def create_video_crawler_plan(
         self,
@@ -114,6 +118,25 @@ class AigcClient:
                 "error": "生成计划没有输入源组",
             }
 
+        for input_source_group in input_source_groups:
+            for existing_source in input_source_group.get("inputSources", []):
+                same_crawler = (
+                    str(existing_source.get("inputSourceValue") or "")
+                    == crawler_plan_id
+                )
+                same_shape = all(
+                    input_source_info.get(key, 0) == existing_source.get(key, -1)
+                    for key in _INPUT_SOURCE_CHECK_KEYS
+                )
+                if same_crawler and same_shape:
+                    return {
+                        "success": True,
+                        "already_bound": True,
+                        "produce_plan_id": produce_plan_id,
+                        "produce_plan_name": produce_plan_detail.get("name", ""),
+                        "msg": "爬取计划已绑定,无需重复写入",
+                    }
+
         input_source_index = 0
         for index, input_source_group in enumerate(input_source_groups):
             input_sources = input_source_group.get("inputSources", [])

+ 25 - 0
supply_infra/business_harness/__init__.py

@@ -0,0 +1,25 @@
+"""Versioned business Harness built on top of the durable daily pipeline."""
+
+from supply_infra.business_harness.evaluate import publish_daily_demand_package
+from supply_infra.business_harness.content_feedback import (
+    ingest_content_performance_fact,
+    materialize_content_feedback,
+)
+from supply_infra.business_harness.feedback import process_feedback
+from supply_infra.business_harness.materialize import materialize_platform_demands
+from supply_infra.business_harness.strategy import (
+    approve_strategy_proposal,
+    create_strategy_proposal,
+    preview_strategy_proposal,
+)
+
+__all__ = [
+    "approve_strategy_proposal",
+    "create_strategy_proposal",
+    "ingest_content_performance_fact",
+    "materialize_content_feedback",
+    "materialize_platform_demands",
+    "preview_strategy_proposal",
+    "process_feedback",
+    "publish_daily_demand_package",
+]

+ 397 - 0
supply_infra/business_harness/content_feedback.py

@@ -0,0 +1,397 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import uuid
+from datetime import datetime
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import select
+
+from supply_infra.db.models.business_harness import (
+    ContentDemandCoverage,
+    ContentPerformanceFact,
+    DailyDemandPackage,
+    DailyDemandTask,
+    DemandAttributionSnapshot,
+)
+from supply_infra.db.models.video_discovery import (
+    VideoDiscoveryCandidate,
+    VideoDiscoveryRun,
+)
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.contracts import StepContext
+from supply_infra.pipeline.dates import china_now
+
+_NAMESPACE = uuid.UUID("53a14b2c-8ec3-5b88-a684-aa09737b6570")
+
+
+def _uuid(key: str) -> str:
+    return str(uuid.uuid5(_NAMESPACE, key))
+
+
+def _hash(payload: Any) -> str:
+    canonical = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def _clamp(value: float) -> float:
+    return max(0.0, min(1.0, value))
+
+
+def _attribution_values(
+    coverage: ContentDemandCoverage,
+    fact: ContentPerformanceFact,
+) -> dict[str, Any]:
+    coverage_score = float(coverage.coverage_score)
+    quality = float(fact.content_quality) if fact.content_quality is not None else 0.5
+    sample_factor = _clamp(float(fact.sample_size or 0) / 1000.0)
+    confidence = _clamp(coverage_score * quality * sample_factor)
+    if fact.rov is None and fact.vov is None:
+        return {
+            "support_state": "inconclusive",
+            "attributable_rov": None,
+            "attributable_vov": None,
+            "confidence": confidence,
+            "validity_influence": 0.0,
+            "priority_influence": 0.0,
+            "reason": "内容承接关系已建立,但缺少 ROV/VOV,暂不影响次日评分",
+        }
+
+    attributable_rov = (
+        float(fact.rov) * coverage_score if fact.rov is not None else None
+    )
+    attributable_vov = (
+        float(fact.vov) * coverage_score if fact.vov is not None else None
+    )
+    signal = attributable_rov if attributable_rov is not None else 0.0
+    support_state = (
+        "support"
+        if signal > 0.05
+        else "oppose"
+        if signal < -0.05
+        else "inconclusive"
+    )
+    validity_influence = max(-0.1, min(0.1, signal * confidence * 0.1))
+    priority_influence = max(
+        -0.1,
+        min(0.1, (attributable_vov or signal) * confidence * 0.1),
+    )
+    return {
+        "support_state": support_state,
+        "attributable_rov": attributable_rov,
+        "attributable_vov": attributable_vov,
+        "confidence": confidence,
+        "validity_influence": validity_influence,
+        "priority_influence": priority_influence,
+        "reason": (
+            f"覆盖度={coverage_score:.3f}、内容质量={quality:.3f}、"
+            f"样本因子={sample_factor:.3f},归因状态={support_state}"
+        ),
+    }
+
+
+def _ensure_attribution(
+    session,
+    coverage: ContentDemandCoverage,
+    fact: ContentPerformanceFact,
+    strategy_version_id: str,
+) -> DemandAttributionSnapshot:
+    attribution_id = _uuid(
+        f"demand-attribution:{coverage.coverage_id}:"
+        f"{fact.performance_fact_id}:{strategy_version_id}"
+    )
+    existing = session.get(DemandAttributionSnapshot, attribution_id)
+    if existing is not None:
+        return existing
+    values = _attribution_values(coverage, fact)
+    row = DemandAttributionSnapshot(
+        attribution_id=attribution_id,
+        platform_demand_version_id=coverage.platform_demand_version_id,
+        coverage_id=coverage.coverage_id,
+        performance_fact_id=fact.performance_fact_id,
+        strategy_version_id=strategy_version_id,
+        support_state=values["support_state"],
+        attributable_rov=(
+            Decimal(str(values["attributable_rov"]))
+            if values["attributable_rov"] is not None
+            else None
+        ),
+        attributable_vov=(
+            Decimal(str(values["attributable_vov"]))
+            if values["attributable_vov"] is not None
+            else None
+        ),
+        attribution_confidence=Decimal(str(round(values["confidence"], 5))),
+        validity_influence=Decimal(
+            str(round(values["validity_influence"], 7))
+        ),
+        priority_influence=Decimal(
+            str(round(values["priority_influence"], 7))
+        ),
+        reason=values["reason"],
+    )
+    session.add(row)
+    return row
+
+
+def ingest_content_performance_fact(
+    *,
+    content_id: str,
+    biz_dt: str,
+    source: str,
+    raw_payload: dict[str, Any],
+    observed_at: datetime,
+    exposure_count: int | None = None,
+    sample_size: int | None = None,
+    content_quality: float | None = None,
+    rov: float | None = None,
+    vov: float | None = None,
+) -> dict[str, Any]:
+    """Append one immutable observation and attribute it to known demand coverage."""
+    if not content_id.strip() or not source.strip():
+        raise ValueError("content_id and source are required")
+    if len(biz_dt) != 8 or not biz_dt.isdigit():
+        raise ValueError("biz_dt must be YYYYMMDD")
+    if sample_size is not None and sample_size < 0:
+        raise ValueError("sample_size must be non-negative")
+    if exposure_count is not None and exposure_count < 0:
+        raise ValueError("exposure_count must be non-negative")
+    if content_quality is not None and not 0 <= content_quality <= 1:
+        raise ValueError("content_quality must be between 0 and 1")
+
+    canonical_payload = {
+        "raw_payload": raw_payload,
+        "exposure_count": exposure_count,
+        "sample_size": sample_size,
+        "content_quality": content_quality,
+        "rov": rov,
+        "vov": vov,
+        "observed_at": observed_at.isoformat(),
+    }
+    payload_hash = _hash(canonical_payload)
+    fact_id = _uuid(
+        f"content-performance:{content_id}:{biz_dt}:{source}:{payload_hash}"
+    )
+    with get_session() as session:
+        fact = session.get(ContentPerformanceFact, fact_id)
+        idempotent_replay = fact is not None
+        if fact is None:
+            fact = ContentPerformanceFact(
+                performance_fact_id=fact_id,
+                content_id=content_id.strip(),
+                biz_dt=biz_dt,
+                source=source.strip()[:64],
+                payload_hash=payload_hash,
+                exposure_count=exposure_count,
+                sample_size=sample_size,
+                content_quality=(
+                    Decimal(str(content_quality))
+                    if content_quality is not None
+                    else None
+                ),
+                rov=Decimal(str(rov)) if rov is not None else None,
+                vov=Decimal(str(vov)) if vov is not None else None,
+                raw_payload_json=raw_payload,
+                observed_at=observed_at,
+            )
+            session.add(fact)
+            session.flush()
+
+        rows = session.execute(
+            select(ContentDemandCoverage, DailyDemandPackage.strategy_version_id)
+            .join(
+                DailyDemandTask,
+                DailyDemandTask.task_id == ContentDemandCoverage.task_id,
+            )
+            .join(
+                DailyDemandPackage,
+                DailyDemandPackage.demand_package_id
+                == DailyDemandTask.demand_package_id,
+            )
+            .where(ContentDemandCoverage.content_id == content_id.strip())
+        ).all()
+        attribution_ids: list[str] = []
+        for coverage, strategy_version_id in rows:
+            attribution = _ensure_attribution(
+                session,
+                coverage,
+                fact,
+                str(strategy_version_id),
+            )
+            attribution_ids.append(attribution.attribution_id)
+        session.flush()
+        return {
+            "success": True,
+            "performance_fact_id": fact.performance_fact_id,
+            "content_id": fact.content_id,
+            "attribution_ids": attribution_ids,
+            "attribution_count": len(attribution_ids),
+            "idempotent_replay": idempotent_replay,
+        }
+
+
+def materialize_content_feedback(context: StepContext) -> dict[str, Any]:
+    """Persist Find Agent coverage, raw performance facts and attribution."""
+    with get_session() as session:
+        package = session.scalar(
+            select(DailyDemandPackage).where(
+                DailyDemandPackage.run_id == context.run_id,
+                DailyDemandPackage.status == "published",
+            )
+        )
+        if package is None:
+            return {
+                "success": False,
+                "error_code": "daily_demand_package_missing",
+                "error": "找片结果无法关联到已发布任务包",
+            }
+        discovery_runs = list(
+            session.scalars(
+                select(VideoDiscoveryRun).where(
+                    VideoDiscoveryRun.demand_package_id
+                    == package.demand_package_id
+                )
+            ).all()
+        )
+        run_by_id = {str(row.run_id): row for row in discovery_runs}
+        candidates = (
+            list(
+                session.scalars(
+                    select(VideoDiscoveryCandidate).where(
+                        VideoDiscoveryCandidate.run_id.in_(run_by_id),
+                        VideoDiscoveryCandidate.decision_bucket == "primary",
+                    )
+                ).all()
+            )
+            if run_by_id
+            else []
+        )
+        coverage_created = 0
+        facts_created = 0
+        attributions_created = 0
+        for candidate in candidates:
+            discovery_run = run_by_id[str(candidate.run_id)]
+            if (
+                not discovery_run.daily_demand_task_id
+                or not discovery_run.platform_demand_version_id
+            ):
+                raise ValueError(
+                    f"video discovery run {discovery_run.run_id} 缺少统一任务引用"
+                )
+            task = session.get(
+                DailyDemandTask,
+                discovery_run.daily_demand_task_id,
+            )
+            if task is None:
+                raise ValueError(
+                    f"daily demand task not found: {discovery_run.daily_demand_task_id}"
+                )
+            coverage_id = _uuid(
+                "content-coverage:"
+                f"{discovery_run.platform_demand_version_id}:"
+                f"{candidate.aweme_id}:primary"
+            )
+            coverage = session.get(ContentDemandCoverage, coverage_id)
+            if coverage is None:
+                relevance = (
+                    float(candidate.relevance_score)
+                    if candidate.relevance_score is not None
+                    else 0.5
+                )
+                coverage = ContentDemandCoverage(
+                    coverage_id=coverage_id,
+                    platform_demand_version_id=(
+                        discovery_run.platform_demand_version_id
+                    ),
+                    task_id=task.task_id,
+                    content_id=str(candidate.aweme_id),
+                    content_url=candidate.content_link,
+                    source_type="find_agent",
+                    coverage_role="primary",
+                    coverage_score=Decimal(str(round(_clamp(relevance), 5))),
+                    evidence_json={
+                        "decision_reason": candidate.decision_reason,
+                        "source_keywords_json": candidate.source_keywords_json,
+                        "gate_results_json": candidate.gate_results_json,
+                        "video_discovery_run_id": discovery_run.run_id,
+                    },
+                    status="discovered",
+                    discovered_at=candidate.create_time,
+                    content_published_at=candidate.publish_at,
+                )
+                session.add(coverage)
+                session.flush()
+                coverage_created += 1
+
+            raw_fact = {
+                "play_count": candidate.play_count,
+                "like_count": candidate.like_count,
+                "comment_count": candidate.comment_count,
+                "collect_count": candidate.collect_count,
+                "share_count": candidate.share_count,
+                "gate_status": candidate.gate_status,
+                "value_score": (
+                    float(candidate.value_score)
+                    if candidate.value_score is not None
+                    else None
+                ),
+            }
+            payload_hash = _hash(raw_fact)
+            fact_id = _uuid(
+                f"content-performance:{candidate.aweme_id}:"
+                f"{context.biz_dt}:find_agent:{payload_hash}"
+            )
+            fact = session.get(ContentPerformanceFact, fact_id)
+            if fact is None:
+                quality = 1.0 if candidate.gate_status == "pass" else 0.5
+                fact = ContentPerformanceFact(
+                    performance_fact_id=fact_id,
+                    content_id=str(candidate.aweme_id),
+                    biz_dt=context.biz_dt,
+                    source="find_agent_snapshot",
+                    payload_hash=payload_hash,
+                    exposure_count=candidate.play_count,
+                    sample_size=candidate.play_count,
+                    content_quality=Decimal(str(quality)),
+                    rov=None,
+                    vov=None,
+                    raw_payload_json=raw_fact,
+                    observed_at=candidate.update_time or china_now(),
+                )
+                session.add(fact)
+                session.flush()
+                facts_created += 1
+            before = session.get(
+                DemandAttributionSnapshot,
+                _uuid(
+                    f"demand-attribution:{coverage.coverage_id}:"
+                    f"{fact.performance_fact_id}:{package.strategy_version_id}"
+                ),
+            )
+            _ensure_attribution(
+                session,
+                coverage,
+                fact,
+                package.strategy_version_id,
+            )
+            if before is None:
+                attributions_created += 1
+
+        session.flush()
+        return {
+            "success": True,
+            "biz_dt": context.biz_dt,
+            "demand_package_id": package.demand_package_id,
+            "candidate_count": len(candidates),
+            "coverage_created": coverage_created,
+            "performance_facts_created": facts_created,
+            "attributions_created": attributions_created,
+        }

+ 659 - 0
supply_infra/business_harness/evaluate.py

@@ -0,0 +1,659 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import uuid
+from datetime import datetime
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import func, select
+
+from supply_infra.db.models.business_harness import (
+    DailyDemandPackage,
+    DailyDemandTask,
+    DemandAttributionSnapshot,
+    DemandEvaluationSnapshot,
+    DemandScoreContribution,
+    EvidencePackage,
+    PlatformDemandCategoryRel,
+    PlatformDemandVersion,
+    RawDemandExpression,
+    StandardDemandAlias,
+    StrategyVersion,
+)
+from supply_infra.db.models.demand_grade import DemandGrade
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.contracts import StepContext
+from supply_infra.pipeline.dates import china_now
+
+_NAMESPACE = uuid.UUID("53a14b2c-8ec3-5b88-a684-aa09737b6570")
+_STRATEGY_KEY = "harness-v1"
+_STRATEGY_DEFINITION: dict[str, Any] = {
+    "validity_weights": {
+        "legacy_grade_score": 0.35,
+        "prior_heat": 0.20,
+        "source_coverage": 0.15,
+        "posterior_rov": 0.15,
+        "attributed_feedback": 0.15,
+    },
+    "local_priority_weights": {
+        "validity": 0.30,
+        "prior_heat": 0.20,
+        "grade_band": 0.15,
+        "supply_gap": 0.20,
+        "attributed_feedback": 0.15,
+    },
+    "action_thresholds": {
+        "assure_supply": {"validity": 0.75, "priority": 0.55},
+        "priority": {"validity": 0.60, "priority": 0.70},
+        "targeted_validation": {"validity": 0.45, "confidence_below": 0.75},
+        "exploration": {"validity": 0.25},
+    },
+    "posterior_min_samples": 1,
+    "exploration_allocation": 1,
+    "schema_version": 1,
+}
+
+
+def _uuid(key: str) -> str:
+    return str(uuid.uuid5(_NAMESPACE, key))
+
+
+def _hash(payload: Any) -> str:
+    canonical = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def _clamp(value: float) -> float:
+    return max(0.0, min(1.0, value))
+
+
+def _json_list(raw: str | None) -> list[Any]:
+    if not raw:
+        return []
+    try:
+        value = json.loads(raw)
+    except (TypeError, json.JSONDecodeError):
+        return []
+    return value if isinstance(value, list) else []
+
+
+def _weighted_score(
+    dimensions: list[dict[str, Any]],
+) -> tuple[Decimal, list[dict[str, Any]]]:
+    available = [item for item in dimensions if item["normalized"] is not None]
+    weight_sum = sum(float(item["weight"]) for item in available)
+    if weight_sum <= 0:
+        return Decimal("0.00000"), []
+    contributions: list[dict[str, Any]] = []
+    total = 0.0
+    for item in available:
+        effective_weight = float(item["weight"]) / weight_sum
+        contribution = _clamp(float(item["normalized"])) * effective_weight
+        total += contribution
+        contributions.append(
+            {
+                **item,
+                "effective_weight": effective_weight,
+                "contribution": contribution,
+            }
+        )
+    return Decimal(str(round(_clamp(total), 5))), contributions
+
+
+def _grade_band(grade: str) -> float:
+    return {
+        "S": 1.0,
+        "A": 0.85,
+        "B": 0.65,
+        "C": 0.40,
+        "D": 0.20,
+    }.get(str(grade).upper(), 0.30)
+
+
+def _action_tier(
+    definition: dict[str, Any],
+    validity: float,
+    priority: float,
+    confidence: float,
+    *,
+    has_posterior: bool,
+) -> str:
+    thresholds = definition["action_thresholds"]
+    assure = thresholds["assure_supply"]
+    if (
+        has_posterior
+        and validity >= assure["validity"]
+        and priority >= assure["priority"]
+    ):
+        return "assure_supply"
+    priority_rule = thresholds["priority"]
+    if validity >= priority_rule["validity"] and priority >= priority_rule["priority"]:
+        return "priority"
+    validate = thresholds["targeted_validation"]
+    if (
+        validity >= validate["validity"]
+        and confidence < validate["confidence_below"]
+    ):
+        return "targeted_validation"
+    if validity >= thresholds["exploration"]["validity"]:
+        return "exploration"
+    return "suppress"
+
+
+def _ensure_strategy(session) -> StrategyVersion:
+    strategy = session.scalar(
+        select(StrategyVersion)
+        .where(StrategyVersion.status == "active")
+        .order_by(StrategyVersion.activated_at.desc())
+        .limit(1)
+    )
+    if strategy is not None:
+        return strategy
+    legacy = session.scalar(
+        select(StrategyVersion).where(StrategyVersion.version_key == _STRATEGY_KEY)
+    )
+    if legacy is not None:
+        legacy.status = "active"
+        legacy.activated_at = china_now()
+        session.flush()
+        return legacy
+    strategy = StrategyVersion(
+        strategy_version_id=_uuid(f"strategy-version:{_STRATEGY_KEY}"),
+        version_key=_STRATEGY_KEY,
+        status="active",
+        definition_json=_STRATEGY_DEFINITION,
+        change_reason="平台需求双评分与五类行动层初始显式策略",
+        created_by="system",
+        activated_at=china_now(),
+    )
+    session.add(strategy)
+    session.flush()
+    return strategy
+
+
+def _raw_dimension(
+    name: str,
+    raw: Any,
+    normalized: float | None,
+    weight: float,
+    reason: str,
+) -> dict[str, Any]:
+    return {
+        "dimension": name,
+        "raw": {"value": raw},
+        "normalized": normalized,
+        "weight": weight,
+        "data_status": "available" if normalized is not None else "missing",
+        "reason": reason,
+    }
+
+
+def _evaluation_inputs(
+    session,
+    version: PlatformDemandVersion,
+    grade: DemandGrade,
+    definition: dict[str, Any],
+) -> tuple[
+    dict[str, Any],
+    list[dict[str, Any]],
+    list[dict[str, Any]],
+    list[str],
+]:
+    strategies = [str(value) for value in _json_list(grade.strategies)]
+    videos = [str(value) for value in _json_list(grade.video_list)]
+    posterior_available = bool(grade.has_posterior and grade.posterior_rov_count > 0)
+    posterior_raw = (
+        float(grade.posterior_rov_avg)
+        if posterior_available and grade.posterior_rov_avg is not None
+        else None
+    )
+    attributed = session.execute(
+        select(
+            func.avg(DemandAttributionSnapshot.validity_influence),
+            func.avg(DemandAttributionSnapshot.priority_influence),
+            func.count(),
+        )
+        .join(
+            PlatformDemandVersion,
+            PlatformDemandVersion.platform_demand_version_id
+            == DemandAttributionSnapshot.platform_demand_version_id,
+        )
+        .where(
+            PlatformDemandVersion.platform_demand_id
+            == version.platform_demand_id,
+            DemandAttributionSnapshot.support_state.in_(("support", "oppose")),
+        )
+    ).one()
+    attributed_validity = (
+        float(attributed[0]) if attributed[0] is not None else None
+    )
+    attributed_priority = (
+        float(attributed[1]) if attributed[1] is not None else None
+    )
+    validity_weights = definition["validity_weights"]
+    validity_dimensions = [
+        _raw_dimension(
+            "legacy_grade_score",
+            float(grade.score) if grade.score is not None else None,
+            _clamp(float(grade.score) / 100.0) if grade.score is not None else None,
+            float(validity_weights["legacy_grade_score"]),
+            "旧分级来源内排名归一到 0~1,仅作为过渡信号",
+        ),
+        _raw_dimension(
+            "prior_heat",
+            (
+                float(grade.prior_total_score)
+                if grade.prior_total_score is not None
+                else None
+            ),
+            (
+                _clamp(float(grade.prior_total_score) / 4.0)
+                if grade.prior_total_score is not None
+                else None
+            ),
+            float(validity_weights["prior_heat"]),
+            "四类先验总分按理论范围 0~4 归一",
+        ),
+        _raw_dimension(
+            "source_coverage",
+            {"strategies": strategies, "count": len(strategies)},
+            _clamp(len(strategies) / 4.0),
+            float(validity_weights["source_coverage"]),
+            "来源覆盖越多,需求成立的交叉支持越充分",
+        ),
+        _raw_dimension(
+            "posterior_rov",
+            {
+                "rov": posterior_raw,
+                "sample_count": int(grade.posterior_rov_count or 0),
+            },
+            _clamp(0.5 + posterior_raw / 2.0) if posterior_raw is not None else None,
+            float(validity_weights["posterior_rov"]),
+            "仅在存在有效样本时使用后验;缺失不按零处理",
+        ),
+        _raw_dimension(
+            "attributed_feedback",
+            {
+                "validity_influence": attributed_validity,
+                "sample_count": int(attributed[2] or 0),
+            },
+            (
+                _clamp(0.5 + attributed_validity * 5.0)
+                if attributed_validity is not None
+                else None
+            ),
+            float(validity_weights["attributed_feedback"]),
+            "只消费已关联内容且完成表现归因的支持/反对反馈",
+        ),
+    ]
+    validity, _ = _weighted_score(validity_dimensions)
+    local_weights = definition["local_priority_weights"]
+    local_dimensions = [
+        _raw_dimension(
+            "validity",
+            float(validity),
+            float(validity),
+            float(local_weights["validity"]),
+            "局部供给首先受需求成立度约束",
+        ),
+        _raw_dimension(
+            "prior_heat",
+            (
+                float(grade.prior_total_score)
+                if grade.prior_total_score is not None
+                else None
+            ),
+            (
+                _clamp(float(grade.prior_total_score) / 4.0)
+                if grade.prior_total_score is not None
+                else None
+            ),
+            float(local_weights["prior_heat"]),
+            "当前主题内先验热度",
+        ),
+        _raw_dimension(
+            "grade_band",
+            grade.grade,
+            _grade_band(grade.grade),
+            float(local_weights["grade_band"]),
+            "旧等级仅作为过渡期的相对位置快照",
+        ),
+        _raw_dimension(
+            "supply_gap",
+            {"known_content_count": len(videos)},
+            1.0 - _clamp(len(videos) / 10.0) if grade.video_list is not None else None,
+            float(local_weights["supply_gap"]),
+            "已有内容越少,局部补供价值越高;未知供给不按零处理",
+        ),
+        _raw_dimension(
+            "attributed_feedback",
+            {
+                "priority_influence": attributed_priority,
+                "sample_count": int(attributed[2] or 0),
+            },
+            (
+                _clamp(0.5 + attributed_priority * 5.0)
+                if attributed_priority is not None
+                else None
+            ),
+            float(local_weights["attributed_feedback"]),
+            "内容表现归因对次日局部供给优先级的影响",
+        ),
+    ]
+    missing = [
+        item["dimension"]
+        for item in validity_dimensions + local_dimensions
+        if item["normalized"] is None
+    ]
+    metrics = {
+        "grade": grade.grade,
+        "strategies": strategies,
+        "videos": videos,
+        "posterior_available": posterior_available,
+        "posterior_rov_count": int(grade.posterior_rov_count or 0),
+        "attributed_feedback_count": int(attributed[2] or 0),
+    }
+    return metrics, validity_dimensions, local_dimensions, list(dict.fromkeys(missing))
+
+
+def _store_contributions(
+    session,
+    evaluation_id: str,
+    score_type: str,
+    contributions: list[dict[str, Any]],
+) -> None:
+    for item in contributions:
+        session.add(
+            DemandScoreContribution(
+                contribution_id=_uuid(
+                    f"score-contribution:{evaluation_id}:"
+                    f"{score_type}:{item['dimension']}"
+                ),
+                evaluation_id=evaluation_id,
+                score_type=score_type,
+                dimension=item["dimension"],
+                raw_value_json=item["raw"],
+                normalized_value=Decimal(str(round(item["normalized"], 7))),
+                configured_weight=Decimal(str(round(item["weight"], 7))),
+                effective_weight=Decimal(
+                    str(round(item["effective_weight"], 7))
+                ),
+                contribution=Decimal(str(round(item["contribution"], 7))),
+                data_status=item["data_status"],
+                reason=item["reason"],
+            )
+        )
+
+
+def publish_daily_demand_package(context: StepContext) -> dict[str, Any]:
+    with get_session() as session:
+        existing = session.scalar(
+            select(DailyDemandPackage).where(
+                DailyDemandPackage.run_id == context.run_id
+            )
+        )
+        if existing is not None:
+            return {
+                "success": existing.status == "published",
+                "idempotent_replay": True,
+                "demand_package_id": existing.demand_package_id,
+                "biz_dt": existing.biz_dt,
+                "item_count": existing.item_count,
+                "status": existing.status,
+                "content_hash": existing.content_hash,
+            }
+
+        evidence_package = session.scalar(
+            select(EvidencePackage).where(EvidencePackage.run_id == context.run_id)
+        )
+        if evidence_package is None or evidence_package.status != "frozen":
+            return {
+                "success": False,
+                "error_code": "evidence_package_not_frozen",
+                "error": "平台需求证据包不存在或尚未冻结",
+            }
+        versions = list(
+            session.scalars(
+                select(PlatformDemandVersion)
+                .where(PlatformDemandVersion.run_id == context.run_id)
+                .order_by(PlatformDemandVersion.platform_demand_version_id)
+            ).all()
+        )
+        if not versions:
+            return {
+                "success": False,
+                "error_code": "platform_demand_versions_missing",
+                "error": "没有可发布的平台需求版本",
+            }
+
+        strategy = _ensure_strategy(session)
+        previous_package_version = int(
+            session.scalar(
+                select(func.max(DailyDemandPackage.package_version)).where(
+                    DailyDemandPackage.biz_dt == context.biz_dt
+                )
+            )
+            or 0
+        )
+        package_id = _uuid(f"daily-demand-package:{context.run_id}")
+        package = DailyDemandPackage(
+            demand_package_id=package_id,
+            run_id=context.run_id,
+            evidence_package_id=evidence_package.package_id,
+            strategy_version_id=strategy.strategy_version_id,
+            biz_dt=context.biz_dt,
+            package_version=previous_package_version + 1,
+            status="building",
+            content_hash="0" * 64,
+            item_count=0,
+            published_at=None,
+        )
+        session.add(package)
+        session.flush()
+
+        task_payloads: list[dict[str, Any]] = []
+        tier_counts: dict[str, int] = {}
+        for version in versions:
+            grade = session.get(DemandGrade, int(version.source_demand_grade_id))
+            if grade is None:
+                raise ValueError(
+                    "平台需求版本引用的 DemandGrade 不存在: "
+                    f"{version.source_demand_grade_id}"
+                )
+            definition = dict(strategy.definition_json)
+            metrics, validity_dims, local_dims, missing = _evaluation_inputs(
+                session,
+                version,
+                grade,
+                definition,
+            )
+            validity, validity_contributions = _weighted_score(validity_dims)
+            local_priority, local_contributions = _weighted_score(local_dims)
+            configured_validity_weight = sum(float(item["weight"]) for item in validity_dims)
+            available_validity_weight = sum(
+                float(item["weight"])
+                for item in validity_dims
+                if item["normalized"] is not None
+            )
+            data_confidence = Decimal(
+                str(
+                    round(
+                        _clamp(
+                            available_validity_weight
+                            / configured_validity_weight
+                        ),
+                        5,
+                    )
+                )
+            )
+            has_posterior = bool(metrics["posterior_available"])
+            action_tier = _action_tier(
+                definition,
+                float(validity),
+                float(local_priority),
+                float(data_confidence),
+                has_posterior=has_posterior,
+            )
+            posterior_state = "available" if has_posterior else "missing"
+            evaluation_id = _uuid(
+                f"demand-evaluation:{version.platform_demand_version_id}"
+            )
+            reason = (
+                f"成立度={validity},局部供给优先级={local_priority},"
+                f"数据置信度={data_confidence},行动层={action_tier};"
+                f"缺失维度={missing or ['无']}。"
+            )
+            evaluation = DemandEvaluationSnapshot(
+                evaluation_id=evaluation_id,
+                platform_demand_version_id=version.platform_demand_version_id,
+                run_id=context.run_id,
+                strategy_version_id=strategy.strategy_version_id,
+                biz_dt=context.biz_dt,
+                validity_score=validity,
+                local_supply_priority=local_priority,
+                data_confidence=data_confidence,
+                posterior_state=posterior_state,
+                action_tier=action_tier,
+                metrics_json=metrics,
+                missing_dimensions_json=missing,
+                decision_reason=reason,
+            )
+            session.add(evaluation)
+            _store_contributions(
+                session,
+                evaluation_id,
+                "validity",
+                validity_contributions,
+            )
+            _store_contributions(
+                session,
+                evaluation_id,
+                "local_supply_priority",
+                local_contributions,
+            )
+
+            relation_rows = list(
+                session.scalars(
+                    select(PlatformDemandCategoryRel).where(
+                        PlatformDemandCategoryRel.platform_demand_version_id
+                        == version.platform_demand_version_id
+                    )
+                ).all()
+            )
+            aliases = [
+                str(value)
+                for value in session.scalars(
+                    select(RawDemandExpression.raw_text)
+                    .join(
+                        StandardDemandAlias,
+                        StandardDemandAlias.expression_id
+                        == RawDemandExpression.expression_id,
+                    )
+                    .where(StandardDemandAlias.term_id == version.term_id)
+                ).all()
+            ]
+            search_terms = list(dict.fromkeys([version.name, *aliases]))
+            existing_content = [str(value) for value in metrics["videos"]]
+            hypotheses = (
+                ["需要通过新内容验证该需求的真实 ROV/VOV"]
+                if not has_posterior
+                else ["验证后验表现是否能在新增内容上持续"]
+            )
+            task_payload = {
+                "platform_demand_id": version.platform_demand_id,
+                "platform_demand_version_id": version.platform_demand_version_id,
+                "version_no": version.version_no,
+                "biz_dt": context.biz_dt,
+                "run_id": context.run_id,
+                "strategy_version": strategy.version_key,
+                "name": version.name,
+                "description": version.description,
+                "reason": version.reason,
+                "category_relations": [
+                    {
+                        "category_id": int(row.category_id),
+                        "relation_type": row.relation_type,
+                        "relation_source": row.relation_source,
+                        "reason": row.reason,
+                        "confidence": float(row.confidence),
+                        "is_inferred": bool(row.is_inferred),
+                    }
+                    for row in relation_rows
+                ],
+                "validity_score": float(validity),
+                "local_supply_priority": float(local_priority),
+                "data_confidence": float(data_confidence),
+                "posterior_state": posterior_state,
+                "action_tier": action_tier,
+                "search_terms": search_terms,
+                "exclude_terms": [],
+                "hit_rules": {
+                    "semantic_match_required": True,
+                    "must_reference_platform_demand_version": True,
+                },
+                "hypotheses": hypotheses,
+                "evidence_gaps": missing,
+                "existing_content": existing_content,
+                "allocation": {
+                    "mode": action_tier,
+                    "exploration_quota": (
+                        definition["exploration_allocation"]
+                        if action_tier == "exploration"
+                        else 0
+                    ),
+                },
+                "decision_reason": reason,
+            }
+            session.add(
+                DailyDemandTask(
+                    task_id=_uuid(
+                        f"daily-demand-task:{package_id}:"
+                        f"{version.platform_demand_version_id}"
+                    ),
+                    demand_package_id=package_id,
+                    platform_demand_version_id=version.platform_demand_version_id,
+                    evaluation_id=evaluation_id,
+                    action_tier=action_tier,
+                    search_terms_json=search_terms,
+                    exclude_terms_json=[],
+                    hit_rules_json=task_payload["hit_rules"],
+                    hypotheses_json=hypotheses,
+                    evidence_gaps_json=missing,
+                    existing_content_json=existing_content,
+                    allocation_json=task_payload["allocation"],
+                    task_payload_json=task_payload,
+                )
+            )
+            task_payloads.append(task_payload)
+            tier_counts[action_tier] = tier_counts.get(action_tier, 0) + 1
+
+        package.item_count = len(task_payloads)
+        package.content_hash = _hash(task_payloads)
+        package.status = "published"
+        package.published_at = china_now()
+        session.flush()
+        return {
+            "success": True,
+            "idempotent_replay": False,
+            "demand_package_id": package_id,
+            "biz_dt": context.biz_dt,
+            "package_version": package.package_version,
+            "strategy_version": strategy.version_key,
+            "status": package.status,
+            "item_count": package.item_count,
+            "tier_counts": tier_counts,
+            "content_hash": package.content_hash,
+            "published_at": (
+                package.published_at.isoformat()
+                if isinstance(package.published_at, datetime)
+                else None
+            ),
+        }

+ 58 - 0
supply_infra/business_harness/feedback.py

@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from supply_infra.db.models.demand_feedback import DemandFeedback
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.dates import china_now
+
+_PROCESSING_STATUSES = {"accepted", "rejected", "needs_evidence"}
+
+
+def process_feedback(
+    feedback_id: int,
+    *,
+    status: str,
+    processor: str,
+    reason: str,
+    impact: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    if status not in _PROCESSING_STATUSES:
+        raise ValueError(f"unsupported feedback status: {status}")
+    if not processor.strip() or not reason.strip():
+        raise ValueError("processor and reason are required")
+
+    with get_session() as session:
+        row = session.get(DemandFeedback, int(feedback_id), with_for_update=True)
+        if row is None:
+            raise LookupError(f"feedback not found: {feedback_id}")
+        if row.processing_status in {"accepted", "rejected"}:
+            if row.processing_status != status:
+                raise RuntimeError(
+                    f"feedback already finalized as {row.processing_status}"
+                )
+            return {
+                "feedback_id": int(row.id),
+                "processing_status": row.processing_status,
+                "idempotent_replay": True,
+            }
+        row.processing_status = status
+        row.processed_by = processor[:128]
+        row.processed_at = china_now()
+        row.resolution_reason = reason
+        row.impact_json = (
+            json.dumps(impact, ensure_ascii=False, sort_keys=True)
+            if impact is not None
+            else None
+        )
+        session.flush()
+        return {
+            "feedback_id": int(row.id),
+            "processing_status": row.processing_status,
+            "processed_by": row.processed_by,
+            "processed_at": row.processed_at.isoformat(),
+            "resolution_reason": row.resolution_reason,
+            "impact": impact,
+            "idempotent_replay": False,
+        }

+ 423 - 0
supply_infra/business_harness/materialize.py

@@ -0,0 +1,423 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import unicodedata
+import uuid
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import func, select
+
+from supply_infra.db.models.business_harness import (
+    EvidencePackage,
+    PlatformDemand,
+    PlatformDemandCategoryRel,
+    PlatformDemandVersion,
+    RawDemandExpression,
+    StandardDemandAlias,
+    StandardDemandTerm,
+)
+from supply_infra.db.models.demand_grade import DemandGrade
+from supply_infra.db.models.demand_grade_category_rel import DemandGradeCategoryRel
+from supply_infra.db.models.demand_feedback import DemandFeedback
+from supply_infra.db.repositories.multi_demand_pool_di_repo import (
+    MultiDemandPoolDiRepository,
+)
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.contracts import StepContext
+
+_NAMESPACE = uuid.UUID("53a14b2c-8ec3-5b88-a684-aa09737b6570")
+
+
+def _uuid(key: str) -> str:
+    return str(uuid.uuid5(_NAMESPACE, key))
+
+
+def _normalized_text(value: str) -> str:
+    return " ".join(unicodedata.normalize("NFKC", str(value)).strip().casefold().split())
+
+
+def _hash_payload(payload: Any) -> str:
+    canonical = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def _parse_int_list(raw: str | None) -> list[int]:
+    if not raw:
+        return []
+    try:
+        parsed = json.loads(raw)
+    except (TypeError, json.JSONDecodeError):
+        return []
+    if not isinstance(parsed, list):
+        return []
+    result: list[int] = []
+    for value in parsed:
+        try:
+            result.append(int(value))
+        except (TypeError, ValueError):
+            continue
+    return list(dict.fromkeys(result))
+
+
+def _score_confidence(score: Decimal | float | None) -> Decimal:
+    if score is None:
+        return Decimal("0.50000")
+    normalized = max(0.0, min(1.0, float(score) / 100.0))
+    return Decimal(str(round(normalized, 5)))
+
+
+def _pool_payload(row: Any) -> dict[str, Any]:
+    return {
+        "id": int(row.id),
+        "strategy": row.strategy,
+        "demand_id": row.demand_id,
+        "demand_name": row.demand_name,
+        "weight": float(row.weight) if row.weight is not None else None,
+        "type": row.type,
+        "video_count": int(row.video_count or 0),
+        "video_list": row.video_list,
+        "extend": row.extend,
+        "real_rov_7d": (
+            float(row.real_rov_7d) if row.real_rov_7d is not None else None
+        ),
+        "real_vov_7d": (
+            float(row.real_vov_7d) if row.real_vov_7d is not None else None
+        ),
+        "biz_dt": row.biz_dt,
+    }
+
+
+def _existing_result(session, package: EvidencePackage) -> dict[str, Any]:
+    version_count = int(
+        session.scalar(
+            select(func.count())
+            .select_from(PlatformDemandVersion)
+            .where(PlatformDemandVersion.run_id == package.run_id)
+        )
+        or 0
+    )
+    return {
+        "success": True,
+        "idempotent_replay": True,
+        "package_id": package.package_id,
+        "biz_dt": package.biz_dt,
+        "evidence_count": package.evidence_count,
+        "platform_demand_versions": version_count,
+        "source_snapshot_hash": package.source_snapshot_hash,
+    }
+
+
+def materialize_platform_demands(context: StepContext) -> dict[str, Any]:
+    """
+    Freeze raw expressions and map today's DemandGrade rows into stable,
+    append-only platform-demand versions.
+
+    This is an intentionally conservative MVP: one current representative
+    demand name maps to one stable canonical object. Future merge/split review
+    can re-point terms without changing raw evidence.
+    """
+    package_id = _uuid(f"evidence-package:{context.run_id}")
+    with get_session() as session:
+        existing_package = session.scalar(
+            select(EvidencePackage).where(EvidencePackage.run_id == context.run_id)
+        )
+        if existing_package is not None:
+            return _existing_result(session, existing_package)
+
+        grades = list(
+            session.scalars(
+                select(DemandGrade)
+                .where(DemandGrade.biz_dt == context.biz_dt)
+                .order_by(DemandGrade.id)
+            ).all()
+        )
+        if not grades:
+            return {
+                "success": False,
+                "error_code": "platform_demand_no_grades",
+                "error": f"biz_dt={context.biz_dt} 没有可物化的 DemandGrade",
+            }
+
+        grade_ids = [int(grade.id) for grade in grades]
+        category_map: dict[int, list[int]] = {}
+        category_stmt = select(
+            DemandGradeCategoryRel.demand_grade_id,
+            DemandGradeCategoryRel.category_id,
+        ).where(DemandGradeCategoryRel.demand_grade_id.in_(grade_ids))
+        for grade_id, category_id in session.execute(category_stmt).all():
+            category_map.setdefault(int(grade_id), []).append(int(category_id))
+
+        package = EvidencePackage(
+            package_id=package_id,
+            run_id=context.run_id,
+            biz_dt=context.biz_dt,
+            package_version=1,
+            status="building",
+            source_snapshot_hash="0" * 64,
+            source_versions_json={
+                "run_id": context.run_id,
+                "biz_dt": context.biz_dt,
+                "date_snapshot": context.date_snapshot,
+                "config_snapshot": context.config_snapshot,
+                "input_snapshot": context.input_snapshot,
+            },
+            evidence_count=0,
+        )
+        session.add(package)
+        session.flush()
+
+        all_content_hashes: set[str] = set()
+        created_expressions: set[str] = set()
+        version_count = 0
+        relation_count = 0
+
+        for grade in grades:
+            normalized = _normalized_text(grade.demand_name)
+            canonical_key = _hash_payload({"normalized_text": normalized})
+            term_id = _uuid(f"standard-term:{canonical_key}")
+            platform_demand_id = _uuid(f"platform-demand:{canonical_key}")
+
+            term = session.scalar(
+                select(StandardDemandTerm).where(
+                    StandardDemandTerm.canonical_key == canonical_key
+                )
+            )
+            if term is None:
+                term = StandardDemandTerm(
+                    term_id=term_id,
+                    canonical_key=canonical_key,
+                    canonical_text=grade.demand_name,
+                    normalized_text=normalized,
+                    status="active",
+                )
+                session.add(term)
+
+            platform = session.scalar(
+                select(PlatformDemand).where(
+                    PlatformDemand.canonical_key == canonical_key
+                )
+            )
+            if platform is None:
+                platform = PlatformDemand(
+                    platform_demand_id=platform_demand_id,
+                    canonical_key=canonical_key,
+                    name=grade.demand_name,
+                    description=f"可搜索、可供给的平台需求:{grade.demand_name}",
+                    status="active",
+                    lifecycle_state="new",
+                )
+                session.add(platform)
+            session.flush()
+
+            pool_ids = _parse_int_list(grade.related_pool_ids)
+            pool_rows = MultiDemandPoolDiRepository(session).get_by_ids(pool_ids)
+            source_items: list[tuple[str, str, dict[str, Any]]] = []
+            for pool_row in pool_rows:
+                source_items.append(
+                    (
+                        "multi_demand_pool_di",
+                        str(pool_row.id),
+                        _pool_payload(pool_row),
+                    )
+                )
+            feedback_rows = list(
+                session.scalars(
+                    select(DemandFeedback).where(
+                        DemandFeedback.platform_demand_id == platform_demand_id,
+                        DemandFeedback.processing_status == "accepted",
+                        DemandFeedback.consumed_run_id.is_(None),
+                    )
+                ).all()
+            )
+            for feedback in feedback_rows:
+                source_items.append(
+                    (
+                        "demand_feedback",
+                        str(feedback.id),
+                        {
+                            "id": int(feedback.id),
+                            "demand_name": (
+                                feedback.content
+                                or feedback.reason_code
+                                or feedback.feedback_action
+                            ),
+                            "feedback_action": feedback.feedback_action,
+                            "reason_code": feedback.reason_code,
+                            "content": feedback.content,
+                            "resolution_reason": feedback.resolution_reason,
+                            "impact_json": feedback.impact_json,
+                            "feedback_user_id": feedback.feedback_user_id,
+                            "created_at": (
+                                feedback.created_at.isoformat()
+                                if feedback.created_at is not None
+                                else None
+                            ),
+                        },
+                    )
+                )
+                feedback.consumed_run_id = context.run_id
+            if not source_items:
+                source_items.append(
+                    (
+                        "demand_grade",
+                        str(grade.id),
+                        {
+                            "id": int(grade.id),
+                            "demand_name": grade.demand_name,
+                            "grade": grade.grade,
+                            "score": (
+                                float(grade.score) if grade.score is not None else None
+                            ),
+                            "reason": grade.reason,
+                            "biz_dt": grade.biz_dt,
+                        },
+                    )
+                )
+
+            expression_ids: list[str] = []
+            expression_hashes: list[str] = []
+            for source_type, source_record_id, source_payload in source_items:
+                expression_id = _uuid(
+                    f"raw-expression:{package_id}:{source_type}:{source_record_id}"
+                )
+                content_hash = _hash_payload(source_payload)
+                all_content_hashes.add(content_hash)
+                expression_ids.append(expression_id)
+                expression_hashes.append(content_hash)
+                expression = session.get(RawDemandExpression, expression_id)
+                if expression is None:
+                    expression = RawDemandExpression(
+                        expression_id=expression_id,
+                        package_id=package_id,
+                        source_type=source_type,
+                        source_record_id=source_record_id,
+                        raw_text=str(source_payload["demand_name"]),
+                        original_payload_json=source_payload,
+                        content_hash=content_hash,
+                        observed_biz_dt=context.biz_dt,
+                        data_quality="available",
+                    )
+                    session.add(expression)
+                    created_expressions.add(expression_id)
+                alias_id = _uuid(f"standard-alias:{term_id}:{expression_id}")
+                if session.get(StandardDemandAlias, alias_id) is None:
+                    session.add(
+                        StandardDemandAlias(
+                            alias_id=alias_id,
+                            term_id=term_id,
+                            expression_id=expression_id,
+                            alias_text=str(source_payload["demand_name"]),
+                            relation_type="source_expression",
+                            confidence=Decimal("1.00000"),
+                            reason="由当日原始需求表达直接归一到代表需求词",
+                        )
+                    )
+
+            category_ids = sorted(set(category_map.get(int(grade.id), [])))
+            if not category_ids:
+                category_ids = sorted(set(_parse_int_list(grade.category_ids)))
+            evidence_hash = _hash_payload(
+                {
+                    "grade_id": int(grade.id),
+                    "grade": grade.grade,
+                    "score": grade.score,
+                    "reason": grade.reason,
+                    "expression_hashes": sorted(expression_hashes),
+                    "category_ids": category_ids,
+                }
+            )
+            existing_version = session.scalar(
+                select(PlatformDemandVersion).where(
+                    PlatformDemandVersion.platform_demand_id == platform_demand_id,
+                    PlatformDemandVersion.run_id == context.run_id,
+                )
+            )
+            if existing_version is not None:
+                continue
+            previous = session.scalar(
+                select(PlatformDemandVersion)
+                .where(
+                    PlatformDemandVersion.platform_demand_id == platform_demand_id
+                )
+                .order_by(PlatformDemandVersion.version_no.desc())
+                .limit(1)
+            )
+            version_no = int(previous.version_no) + 1 if previous is not None else 1
+            change_type = (
+                "created"
+                if previous is None
+                else (
+                    "unchanged"
+                    if previous.evidence_hash == evidence_hash
+                    else "updated"
+                )
+            )
+            version_id = _uuid(
+                f"platform-demand-version:{platform_demand_id}:{context.run_id}"
+            )
+            confidence = _score_confidence(grade.score)
+            session.add(
+                PlatformDemandVersion(
+                    platform_demand_version_id=version_id,
+                    platform_demand_id=platform_demand_id,
+                    term_id=term_id,
+                    package_id=package_id,
+                    run_id=context.run_id,
+                    source_demand_grade_id=int(grade.id),
+                    version_no=version_no,
+                    biz_dt=context.biz_dt,
+                    name=grade.demand_name,
+                    description=f"可搜索、可验证的平台需求:{grade.demand_name}",
+                    cognition_confidence=confidence,
+                    reason=grade.reason,
+                    change_type=change_type,
+                    evidence_hash=evidence_hash,
+                )
+            )
+            for index, category_id in enumerate(category_ids):
+                relation_type = (
+                    "primary_attachment" if index == 0 else "auxiliary_attachment"
+                )
+                session.add(
+                    PlatformDemandCategoryRel(
+                        rel_id=_uuid(
+                            f"platform-category-rel:{version_id}:"
+                            f"{category_id}:{relation_type}"
+                        ),
+                        platform_demand_version_id=version_id,
+                        category_id=category_id,
+                        relation_type=relation_type,
+                        relation_source="demand_grade_snapshot",
+                        reason=grade.reason,
+                        confidence=confidence,
+                        is_inferred=True,
+                        status="active",
+                        valid_from_biz_dt=context.biz_dt,
+                    )
+                )
+                relation_count += 1
+            version_count += 1
+
+        package.source_snapshot_hash = _hash_payload(sorted(all_content_hashes))
+        package.evidence_count = len(created_expressions)
+        package.status = "frozen"
+        session.flush()
+
+        return {
+            "success": True,
+            "idempotent_replay": False,
+            "package_id": package.package_id,
+            "biz_dt": context.biz_dt,
+            "evidence_count": package.evidence_count,
+            "platform_demand_versions": version_count,
+            "category_relations": relation_count,
+            "source_snapshot_hash": package.source_snapshot_hash,
+        }

+ 248 - 0
supply_infra/business_harness/strategy.py

@@ -0,0 +1,248 @@
+from __future__ import annotations
+
+import copy
+import uuid
+from typing import Any
+
+from sqlalchemy import select
+
+from supply_infra.db.models.business_harness import (
+    DemandEvaluationSnapshot,
+    StrategyChangeProposal,
+    StrategyVersion,
+)
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.dates import china_now
+
+
+def _deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
+    result = copy.deepcopy(base)
+    for key, value in patch.items():
+        if isinstance(value, dict) and isinstance(result.get(key), dict):
+            result[key] = _deep_merge(result[key], value)
+        else:
+            result[key] = value
+    return result
+
+
+def _flatten(payload: dict[str, Any], prefix: str = "") -> dict[str, Any]:
+    result: dict[str, Any] = {}
+    for key, value in payload.items():
+        path = f"{prefix}.{key}" if prefix else key
+        if isinstance(value, dict):
+            result.update(_flatten(value, path))
+        else:
+            result[path] = value
+    return result
+
+
+def _validate_definition(definition: dict[str, Any]) -> None:
+    required = {
+        "validity_weights",
+        "local_priority_weights",
+        "action_thresholds",
+    }
+    missing = required - definition.keys()
+    if missing:
+        raise ValueError(f"strategy definition missing: {sorted(missing)}")
+    for group in ("validity_weights", "local_priority_weights"):
+        weights = definition[group]
+        if not isinstance(weights, dict) or not weights:
+            raise ValueError(f"{group} must be a non-empty object")
+        for key, value in weights.items():
+            if not isinstance(value, (int, float)) or value < 0 or value > 1:
+                raise ValueError(f"invalid weight {group}.{key}: {value!r}")
+        if sum(float(value) for value in weights.values()) <= 0:
+            raise ValueError(f"{group} total weight must be positive")
+    thresholds = _flatten(definition["action_thresholds"])
+    for key, value in thresholds.items():
+        if not isinstance(value, (int, float)) or not 0 <= float(value) <= 1:
+            raise ValueError(f"invalid threshold action_thresholds.{key}: {value!r}")
+
+
+def _tier(definition: dict[str, Any], row: DemandEvaluationSnapshot) -> str:
+    thresholds = definition["action_thresholds"]
+    validity = float(row.validity_score)
+    priority = float(row.local_supply_priority)
+    confidence = float(row.data_confidence)
+    assure = thresholds["assure_supply"]
+    if (
+        row.posterior_state == "available"
+        and validity >= assure["validity"]
+        and priority >= assure["priority"]
+    ):
+        return "assure_supply"
+    priority_rule = thresholds["priority"]
+    if validity >= priority_rule["validity"] and priority >= priority_rule["priority"]:
+        return "priority"
+    validate = thresholds["targeted_validation"]
+    if (
+        validity >= validate["validity"]
+        and confidence < validate["confidence_below"]
+    ):
+        return "targeted_validation"
+    if validity >= thresholds["exploration"]["validity"]:
+        return "exploration"
+    return "suppress"
+
+
+def create_strategy_proposal(
+    *,
+    requested_change: dict[str, Any],
+    reason: str,
+    created_by: str,
+) -> dict[str, Any]:
+    if not requested_change or not reason.strip() or not created_by.strip():
+        raise ValueError("requested_change, reason and created_by are required")
+    with get_session() as session:
+        base = session.scalar(
+            select(StrategyVersion)
+            .where(StrategyVersion.status == "active")
+            .order_by(StrategyVersion.activated_at.desc())
+            .limit(1)
+        )
+        if base is None:
+            raise RuntimeError("no active strategy version")
+        proposed = _deep_merge(dict(base.definition_json), requested_change)
+        _validate_definition(proposed)
+        before = _flatten(dict(base.definition_json))
+        after = _flatten(proposed)
+        diff = {
+            key: {"before": before.get(key), "after": after.get(key)}
+            for key in sorted(set(before) | set(after))
+            if before.get(key) != after.get(key)
+        }
+        proposal_id = str(uuid.uuid4())
+        session.add(
+            StrategyChangeProposal(
+                proposal_id=proposal_id,
+                base_strategy_version_id=base.strategy_version_id,
+                status="draft",
+                requested_change_json=requested_change,
+                proposed_definition_json=proposed,
+                diff_json=diff,
+                preview_json=None,
+                reason=reason,
+                created_by=created_by[:128],
+                approved_by=None,
+                approved_at=None,
+            )
+        )
+        session.flush()
+        return {
+            "proposal_id": proposal_id,
+            "status": "draft",
+            "base_strategy_version": base.version_key,
+            "diff": diff,
+        }
+
+
+def preview_strategy_proposal(proposal_id: str) -> dict[str, Any]:
+    with get_session() as session:
+        proposal = session.get(
+            StrategyChangeProposal,
+            proposal_id,
+            with_for_update=True,
+        )
+        if proposal is None:
+            raise LookupError(f"strategy proposal not found: {proposal_id}")
+        if proposal.status not in {"draft", "previewed"}:
+            raise RuntimeError(f"proposal cannot be previewed: {proposal.status}")
+        evaluations = list(
+            session.scalars(select(DemandEvaluationSnapshot)).all()
+        )
+        changes: dict[str, int] = {}
+        changed_items: list[dict[str, Any]] = []
+        definition = dict(proposal.proposed_definition_json)
+        for row in evaluations:
+            new_tier = _tier(definition, row)
+            if new_tier == row.action_tier:
+                continue
+            key = f"{row.action_tier}->{new_tier}"
+            changes[key] = changes.get(key, 0) + 1
+            changed_items.append(
+                {
+                    "evaluation_id": row.evaluation_id,
+                    "biz_dt": row.biz_dt,
+                    "before": row.action_tier,
+                    "after": new_tier,
+                }
+            )
+        preview = {
+            "evaluated_count": len(evaluations),
+            "changed_count": len(changed_items),
+            "tier_changes": changes,
+            "changed_items": changed_items[:500],
+            "truncated": len(changed_items) > 500,
+        }
+        proposal.preview_json = preview
+        proposal.status = "previewed"
+        session.flush()
+        return {
+            "proposal_id": proposal_id,
+            "status": proposal.status,
+            "preview": preview,
+        }
+
+
+def approve_strategy_proposal(
+    proposal_id: str,
+    *,
+    approved_by: str,
+) -> dict[str, Any]:
+    if not approved_by.strip():
+        raise ValueError("approved_by is required")
+    with get_session() as session:
+        proposal = session.get(
+            StrategyChangeProposal,
+            proposal_id,
+            with_for_update=True,
+        )
+        if proposal is None:
+            raise LookupError(f"strategy proposal not found: {proposal_id}")
+        if proposal.status == "approved":
+            version = session.scalar(
+                select(StrategyVersion).where(
+                    StrategyVersion.change_reason
+                    == f"approved proposal {proposal_id}: {proposal.reason}"
+                )
+            )
+            return {
+                "proposal_id": proposal_id,
+                "status": "approved",
+                "strategy_version": version.version_key if version else None,
+                "idempotent_replay": True,
+            }
+        if proposal.status != "previewed" or proposal.preview_json is None:
+            raise RuntimeError("proposal must be previewed before approval")
+        active_rows = list(
+            session.scalars(
+                select(StrategyVersion)
+                .where(StrategyVersion.status == "active")
+                .with_for_update()
+            ).all()
+        )
+        for row in active_rows:
+            row.status = "retired"
+        version_key = f"harness-{proposal_id}"
+        version = StrategyVersion(
+            strategy_version_id=str(uuid.uuid4()),
+            version_key=version_key,
+            status="active",
+            definition_json=proposal.proposed_definition_json,
+            change_reason=f"approved proposal {proposal_id}: {proposal.reason}",
+            created_by=approved_by[:128],
+            activated_at=china_now(),
+        )
+        session.add(version)
+        proposal.status = "approved"
+        proposal.approved_by = approved_by[:128]
+        proposal.approved_at = china_now()
+        session.flush()
+        return {
+            "proposal_id": proposal_id,
+            "status": proposal.status,
+            "strategy_version": version.version_key,
+            "strategy_version_id": version.strategy_version_id,
+            "idempotent_replay": False,
+        }

+ 150 - 0
supply_infra/category_match.py

@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from functools import lru_cache
+from threading import local
+from typing import Any
+
+import requests
+
+from supply_infra.config import InfraSettings, get_infra_settings
+
+_SOURCE_TYPE = "实质"
+_TOP_K = 1
+_MIN_SCORE = 0.8
+
+
+class CategoryMatchError(RuntimeError):
+    """The category matching service returned an unusable response."""
+
+
+@dataclass(frozen=True)
+class CategoryMatch:
+    term: str
+    stable_id: int
+    name: str
+    path: str
+    description: str
+    level: int
+    score: float
+
+    @property
+    def reason(self) -> str:
+        return (
+            "category_match_api_v2: "
+            f"path={self.path}; score={self.score:.4f}; stable_id={self.stable_id}"
+        )
+
+
+class CategoryMatchClient:
+    def __init__(
+        self,
+        settings: InfraSettings | None = None,
+        *,
+        session_factory: Callable[[], requests.Session] | None = None,
+    ) -> None:
+        self.settings = settings or get_infra_settings()
+        self._session_factory = session_factory or requests.Session
+        self._thread_local = local()
+
+    def _session(self) -> requests.Session:
+        session = getattr(self._thread_local, "session", None)
+        if session is None:
+            session = self._session_factory()
+            self._thread_local.session = session
+        return session
+
+    def match_one(
+        self,
+        term: str,
+        *,
+        description: str = "",
+    ) -> CategoryMatch | None:
+        normalized_term = str(term).strip()
+        if not normalized_term:
+            raise ValueError("term must not be empty")
+
+        payload = {
+            "source_type": _SOURCE_TYPE,
+            "top_k": _TOP_K,
+            "min_score": _MIN_SCORE,
+            "items": [
+                {
+                    "term": normalized_term,
+                    "description": str(description or ""),
+                }
+            ],
+        }
+        try:
+            response = self._session().post(
+                self.settings.category_match_api_url,
+                json=payload,
+                timeout=self.settings.category_match_timeout_seconds,
+            )
+            response.raise_for_status()
+            body = response.json()
+        except (requests.RequestException, ValueError) as exc:
+            raise CategoryMatchError(
+                f"category match request failed for term={normalized_term!r}"
+            ) from exc
+
+        return _parse_match_response(normalized_term, body)
+
+
+def _parse_match_response(
+    term: str,
+    body: Any,
+) -> CategoryMatch | None:
+    if not isinstance(body, dict) or body.get("success") is not True:
+        raise CategoryMatchError(f"category match response is unsuccessful for term={term!r}")
+
+    items = body.get("items")
+    if not isinstance(items, list) or len(items) != 1:
+        raise CategoryMatchError(
+            f"category match response must contain exactly one item for term={term!r}"
+        )
+    item = items[0]
+    if not isinstance(item, dict) or str(item.get("term", "")).strip() != term:
+        raise CategoryMatchError(f"category match response term mismatch for term={term!r}")
+
+    matches = item.get("matches")
+    if not isinstance(matches, list):
+        raise CategoryMatchError(f"category match response has invalid matches for term={term!r}")
+    if not matches:
+        return None
+
+    match = matches[0]
+    if not isinstance(match, dict):
+        raise CategoryMatchError(f"category match response has invalid match for term={term!r}")
+    try:
+        stable_id = int(match["stable_id"])
+        score = float(match["score"])
+        level = int(match["level"])
+    except (KeyError, TypeError, ValueError) as exc:
+        raise CategoryMatchError(
+            f"category match response lacks typed fields for term={term!r}"
+        ) from exc
+    if score < _MIN_SCORE:
+        return None
+
+    name = str(match.get("name") or "").strip()
+    path = str(match.get("path") or "").strip()
+    if not name or not path:
+        raise CategoryMatchError(
+            f"category match response lacks name/path for term={term!r}"
+        )
+    return CategoryMatch(
+        term=term,
+        stable_id=stable_id,
+        name=name,
+        path=path,
+        description=str(match.get("description") or ""),
+        level=level,
+        score=score,
+    )
+
+
+@lru_cache
+def get_category_match_client() -> CategoryMatchClient:
+    return CategoryMatchClient()

+ 24 - 0
supply_infra/config.py

@@ -239,6 +239,30 @@ class InfraSettings(BaseSettings):
     )
     log_oss_upload_enabled: bool = Field(default=True, alias="LOG_OSS_UPLOAD_ENABLED")
 
+    # Category path matching service
+    category_match_api_url: str = Field(
+        default="https://library.aiddit.com/api/search/categories/match-paths/v2",
+        alias="CATEGORY_MATCH_API_URL",
+    )
+    category_match_timeout_seconds: int = Field(
+        default=60,
+        ge=1,
+        le=300,
+        alias="CATEGORY_MATCH_TIMEOUT_SECONDS",
+    )
+    category_match_workers: int = Field(
+        default=16,
+        ge=1,
+        le=32,
+        alias="CATEGORY_MATCH_WORKERS",
+    )
+    global_category_min_retention_ratio: float = Field(
+        default=0.5,
+        ge=0,
+        le=1,
+        alias="GLOBAL_CATEGORY_MIN_RETENTION_RATIO",
+    )
+
     # AIGC platform
     aigc_api_token: str = Field(default="", alias="AIGC_API_TOKEN")
 

+ 34 - 2
supply_infra/db/models/__init__.py

@@ -3,6 +3,24 @@
 from supply_infra.db.models.agent_document_injection import AgentDocumentInjection
 from supply_infra.db.models.auth_session import AuthSession
 from supply_infra.db.models.auth_user import AuthUser
+from supply_infra.db.models.business_harness import (
+    DailyDemandPackage,
+    DailyDemandTask,
+    ContentDemandCoverage,
+    ContentPerformanceFact,
+    DemandAttributionSnapshot,
+    DemandEvaluationSnapshot,
+    DemandScoreContribution,
+    EvidencePackage,
+    PlatformDemand,
+    PlatformDemandCategoryRel,
+    PlatformDemandVersion,
+    RawDemandExpression,
+    StandardDemandAlias,
+    StandardDemandTerm,
+    StrategyVersion,
+    StrategyChangeProposal,
+)
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
@@ -21,7 +39,6 @@ from supply_infra.db.models.demand_video_expansion import (
 )
 from supply_infra.db.models.generated_demand import GeneratedDemand
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
-from supply_infra.db.models.global_tree_element import GlobalTreeElement
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
 from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
@@ -40,6 +57,22 @@ __all__ = [
     "AgentDocumentInjection",
     "AuthSession",
     "AuthUser",
+    "DailyDemandPackage",
+    "DailyDemandTask",
+    "ContentDemandCoverage",
+    "ContentPerformanceFact",
+    "DemandAttributionSnapshot",
+    "DemandEvaluationSnapshot",
+    "DemandScoreContribution",
+    "EvidencePackage",
+    "PlatformDemand",
+    "PlatformDemandCategoryRel",
+    "PlatformDemandVersion",
+    "RawDemandExpression",
+    "StandardDemandAlias",
+    "StandardDemandTerm",
+    "StrategyVersion",
+    "StrategyChangeProposal",
     "CategoryTreeWeight",
     "DemandBelongCategory",
     "DemandBelongPoolRel",
@@ -54,7 +87,6 @@ __all__ = [
     "DemandVideoExpansionRun",
     "GeneratedDemand",
     "GlobalTreeCategory",
-    "GlobalTreeElement",
     "MultiDemandPoolDi",
     "MultiDemandVideoDetail",
     "MultiDemandVideoPoint",

+ 635 - 0
supply_infra/db/models/business_harness.py

@@ -0,0 +1,635 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import (
+    BigInteger,
+    Boolean,
+    DateTime,
+    ForeignKey,
+    Index,
+    Integer,
+    JSON,
+    Numeric,
+    String,
+    Text,
+    UniqueConstraint,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class EvidencePackage(Base):
+    """Immutable evidence snapshot owned by one durable pipeline run."""
+
+    __tablename__ = "evidence_package"
+    __table_args__ = (
+        UniqueConstraint("run_id", name="uk_evidence_package_run"),
+        Index("idx_evidence_package_biz", "biz_dt", "created_at"),
+    )
+
+    package_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    run_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    package_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+    status: Mapped[str] = mapped_column(
+        String(24), nullable=False, default="frozen"
+    )
+    source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+    source_versions_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    evidence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class RawDemandExpression(Base):
+    """Unmodified source expression captured inside an evidence package."""
+
+    __tablename__ = "raw_demand_expression"
+    __table_args__ = (
+        UniqueConstraint(
+            "package_id",
+            "source_type",
+            "source_record_id",
+            name="uk_raw_expression_source",
+        ),
+        Index("idx_raw_expression_package", "package_id"),
+        Index("idx_raw_expression_hash", "content_hash"),
+    )
+
+    expression_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    package_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("evidence_package.package_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    source_type: Mapped[str] = mapped_column(String(64), nullable=False)
+    source_record_id: Mapped[str] = mapped_column(String(128), nullable=False)
+    raw_text: Mapped[str] = mapped_column(String(512), nullable=False)
+    original_payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+    observed_biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    data_quality: Mapped[str] = mapped_column(
+        String(24), nullable=False, default="available"
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class StandardDemandTerm(Base):
+    """Canonical term with stable identity across daily runs."""
+
+    __tablename__ = "standard_demand_term"
+    __table_args__ = (
+        UniqueConstraint("canonical_key", name="uk_standard_demand_term_key"),
+        Index("idx_standard_demand_term_text", "normalized_text"),
+    )
+
+    term_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    canonical_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    canonical_text: Mapped[str] = mapped_column(String(256), nullable=False)
+    normalized_text: Mapped[str] = mapped_column(String(256), nullable=False)
+    status: Mapped[str] = mapped_column(
+        String(24), nullable=False, default="active"
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
+    )
+
+
+class StandardDemandAlias(Base):
+    """Traceable relationship from a canonical term to a raw expression."""
+
+    __tablename__ = "standard_demand_alias"
+    __table_args__ = (
+        UniqueConstraint(
+            "term_id",
+            "expression_id",
+            name="uk_standard_demand_alias_expression",
+        ),
+        Index("idx_standard_demand_alias_term", "term_id"),
+    )
+
+    alias_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    term_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("standard_demand_term.term_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    expression_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("raw_demand_expression.expression_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    alias_text: Mapped[str] = mapped_column(String(512), nullable=False)
+    relation_type: Mapped[str] = mapped_column(
+        String(32), nullable=False, default="source_expression"
+    )
+    confidence: Mapped[Decimal] = mapped_column(
+        Numeric(6, 5), nullable=False, default=1
+    )
+    reason: Mapped[str] = mapped_column(Text, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class PlatformDemand(Base):
+    """Stable business object consumed by scoring, people and agents."""
+
+    __tablename__ = "platform_demand"
+    __table_args__ = (
+        UniqueConstraint("canonical_key", name="uk_platform_demand_key"),
+        Index("idx_platform_demand_name", "name"),
+        Index("idx_platform_demand_status", "status"),
+    )
+
+    platform_demand_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    canonical_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    name: Mapped[str] = mapped_column(String(256), nullable=False)
+    description: Mapped[str] = mapped_column(Text, nullable=False)
+    status: Mapped[str] = mapped_column(
+        String(24), nullable=False, default="active"
+    )
+    lifecycle_state: Mapped[str] = mapped_column(
+        String(24), nullable=False, default="new"
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
+    )
+
+
+class PlatformDemandVersion(Base):
+    """Append-only daily cognition version for a platform demand."""
+
+    __tablename__ = "platform_demand_version"
+    __table_args__ = (
+        UniqueConstraint(
+            "platform_demand_id",
+            "version_no",
+            name="uk_platform_demand_version_no",
+        ),
+        UniqueConstraint(
+            "platform_demand_id",
+            "run_id",
+            name="uk_platform_demand_version_run",
+        ),
+        Index("idx_platform_demand_version_biz", "biz_dt", "run_id"),
+    )
+
+    platform_demand_version_id: Mapped[str] = mapped_column(
+        String(36), primary_key=True
+    )
+    platform_demand_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("platform_demand.platform_demand_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    term_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("standard_demand_term.term_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    package_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("evidence_package.package_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    run_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    source_demand_grade_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    version_no: Mapped[int] = mapped_column(Integer, nullable=False)
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    name: Mapped[str] = mapped_column(String(256), nullable=False)
+    description: Mapped[str] = mapped_column(Text, nullable=False)
+    cognition_confidence: Mapped[Decimal] = mapped_column(
+        Numeric(6, 5), nullable=False
+    )
+    reason: Mapped[str] = mapped_column(Text, nullable=False)
+    change_type: Mapped[str] = mapped_column(String(24), nullable=False)
+    evidence_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class PlatformDemandCategoryRel(Base):
+    """Versioned graph edge from one platform-demand version to the tree."""
+
+    __tablename__ = "platform_demand_category_rel"
+    __table_args__ = (
+        UniqueConstraint(
+            "platform_demand_version_id",
+            "category_id",
+            "relation_type",
+            name="uk_platform_demand_category_rel",
+        ),
+        Index("idx_platform_demand_category_node", "category_id"),
+    )
+
+    rel_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    platform_demand_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey(
+            "platform_demand_version.platform_demand_version_id",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    category_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    relation_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    relation_source: Mapped[str] = mapped_column(String(64), nullable=False)
+    reason: Mapped[str] = mapped_column(Text, nullable=False)
+    confidence: Mapped[Decimal] = mapped_column(
+        Numeric(6, 5), nullable=False
+    )
+    is_inferred: Mapped[bool] = mapped_column(
+        Boolean, nullable=False, default=False
+    )
+    status: Mapped[str] = mapped_column(
+        String(24), nullable=False, default="active"
+    )
+    valid_from_biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class StrategyVersion(Base):
+    """Versioned scoring and allocation policy; published versions are immutable."""
+
+    __tablename__ = "strategy_version"
+    __table_args__ = (
+        UniqueConstraint("version_key", name="uk_strategy_version_key"),
+        Index("idx_strategy_version_status", "status", "activated_at"),
+    )
+
+    strategy_version_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    version_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    status: Mapped[str] = mapped_column(String(24), nullable=False)
+    definition_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    change_reason: Mapped[str] = mapped_column(Text, nullable=False)
+    created_by: Mapped[str] = mapped_column(String(128), nullable=False)
+    activated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class DemandEvaluationSnapshot(Base):
+    """Two reproducible 0-1 decisions for one platform-demand version."""
+
+    __tablename__ = "demand_evaluation_snapshot"
+    __table_args__ = (
+        UniqueConstraint(
+            "platform_demand_version_id",
+            name="uk_demand_evaluation_version",
+        ),
+        Index("idx_demand_evaluation_biz", "biz_dt", "action_tier"),
+    )
+
+    evaluation_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    platform_demand_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey(
+            "platform_demand_version.platform_demand_version_id",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    run_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    strategy_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    validity_score: Mapped[Decimal] = mapped_column(Numeric(6, 5), nullable=False)
+    local_supply_priority: Mapped[Decimal] = mapped_column(
+        Numeric(6, 5), nullable=False
+    )
+    data_confidence: Mapped[Decimal] = mapped_column(Numeric(6, 5), nullable=False)
+    posterior_state: Mapped[str] = mapped_column(String(24), nullable=False)
+    action_tier: Mapped[str] = mapped_column(String(32), nullable=False)
+    metrics_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    missing_dimensions_json: Mapped[list[str]] = mapped_column(JSON, nullable=False)
+    decision_reason: Mapped[str] = mapped_column(Text, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class DemandScoreContribution(Base):
+    """Expandable score contribution; contributions sum to the stored score."""
+
+    __tablename__ = "demand_score_contribution"
+    __table_args__ = (
+        UniqueConstraint(
+            "evaluation_id",
+            "score_type",
+            "dimension",
+            name="uk_demand_score_contribution",
+        ),
+        Index("idx_demand_score_contribution_eval", "evaluation_id"),
+    )
+
+    contribution_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    evaluation_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("demand_evaluation_snapshot.evaluation_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    score_type: Mapped[str] = mapped_column(String(24), nullable=False)
+    dimension: Mapped[str] = mapped_column(String(64), nullable=False)
+    raw_value_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    normalized_value: Mapped[Decimal] = mapped_column(
+        Numeric(8, 7), nullable=False
+    )
+    configured_weight: Mapped[Decimal] = mapped_column(
+        Numeric(8, 7), nullable=False
+    )
+    effective_weight: Mapped[Decimal] = mapped_column(
+        Numeric(8, 7), nullable=False
+    )
+    contribution: Mapped[Decimal] = mapped_column(Numeric(8, 7), nullable=False)
+    data_status: Mapped[str] = mapped_column(String(24), nullable=False)
+    reason: Mapped[str] = mapped_column(Text, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class DailyDemandPackage(Base):
+    """The only published daily contract shared by people and agents."""
+
+    __tablename__ = "daily_demand_package"
+    __table_args__ = (
+        UniqueConstraint("run_id", name="uk_daily_demand_package_run"),
+        Index("idx_daily_demand_package_biz", "biz_dt", "status"),
+    )
+
+    demand_package_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    run_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("pipeline_run.run_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    evidence_package_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("evidence_package.package_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    strategy_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    package_version: Mapped[int] = mapped_column(Integer, nullable=False)
+    status: Mapped[str] = mapped_column(String(24), nullable=False)
+    content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+    item_count: Mapped[int] = mapped_column(Integer, nullable=False)
+    published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class DailyDemandTask(Base):
+    """Immutable agent/human task view for one evaluated demand."""
+
+    __tablename__ = "daily_demand_task"
+    __table_args__ = (
+        UniqueConstraint(
+            "demand_package_id",
+            "platform_demand_version_id",
+            name="uk_daily_demand_task_version",
+        ),
+        Index("idx_daily_demand_task_tier", "demand_package_id", "action_tier"),
+    )
+
+    task_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    demand_package_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("daily_demand_package.demand_package_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    platform_demand_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey(
+            "platform_demand_version.platform_demand_version_id",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    evaluation_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("demand_evaluation_snapshot.evaluation_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    action_tier: Mapped[str] = mapped_column(String(32), nullable=False)
+    search_terms_json: Mapped[list[str]] = mapped_column(JSON, nullable=False)
+    exclude_terms_json: Mapped[list[str]] = mapped_column(JSON, nullable=False)
+    hit_rules_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    hypotheses_json: Mapped[list[str]] = mapped_column(JSON, nullable=False)
+    evidence_gaps_json: Mapped[list[str]] = mapped_column(JSON, nullable=False)
+    existing_content_json: Mapped[list[str]] = mapped_column(JSON, nullable=False)
+    allocation_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    task_payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class ContentDemandCoverage(Base):
+    """Many-to-many content coverage edge linked to a published task."""
+
+    __tablename__ = "content_demand_coverage"
+    __table_args__ = (
+        UniqueConstraint(
+            "platform_demand_version_id",
+            "content_id",
+            "coverage_role",
+            name="uk_content_demand_coverage",
+        ),
+        Index("idx_content_demand_coverage_content", "content_id"),
+    )
+
+    coverage_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    platform_demand_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey(
+            "platform_demand_version.platform_demand_version_id",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    task_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("daily_demand_task.task_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    content_id: Mapped[str] = mapped_column(String(128), nullable=False)
+    content_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
+    source_type: Mapped[str] = mapped_column(String(32), nullable=False)
+    coverage_role: Mapped[str] = mapped_column(String(24), nullable=False)
+    coverage_score: Mapped[Decimal] = mapped_column(Numeric(6, 5), nullable=False)
+    evidence_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    status: Mapped[str] = mapped_column(String(24), nullable=False)
+    discovered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    content_published_at: Mapped[datetime | None] = mapped_column(
+        DateTime, nullable=True
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class ContentPerformanceFact(Base):
+    """Immutable raw content performance observation."""
+
+    __tablename__ = "content_performance_fact"
+    __table_args__ = (
+        UniqueConstraint(
+            "content_id",
+            "biz_dt",
+            "source",
+            "payload_hash",
+            name="uk_content_performance_fact",
+        ),
+        Index("idx_content_performance_fact_content", "content_id", "biz_dt"),
+    )
+
+    performance_fact_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    content_id: Mapped[str] = mapped_column(String(128), nullable=False)
+    biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
+    source: Mapped[str] = mapped_column(String(64), nullable=False)
+    payload_hash: Mapped[str] = mapped_column(String(64), nullable=False)
+    exposure_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    sample_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+    content_quality: Mapped[Decimal | None] = mapped_column(
+        Numeric(6, 5), nullable=True
+    )
+    rov: Mapped[Decimal | None] = mapped_column(Numeric(16, 8), nullable=True)
+    vov: Mapped[Decimal | None] = mapped_column(Numeric(16, 8), nullable=True)
+    raw_payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    observed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class DemandAttributionSnapshot(Base):
+    """Reproducible attribution from one content fact to one demand version."""
+
+    __tablename__ = "demand_attribution_snapshot"
+    __table_args__ = (
+        UniqueConstraint(
+            "coverage_id",
+            "performance_fact_id",
+            "strategy_version_id",
+            name="uk_demand_attribution_snapshot",
+        ),
+        Index(
+            "idx_demand_attribution_version",
+            "platform_demand_version_id",
+            "created_at",
+        ),
+    )
+
+    attribution_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    platform_demand_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey(
+            "platform_demand_version.platform_demand_version_id",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    coverage_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("content_demand_coverage.coverage_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    performance_fact_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey(
+            "content_performance_fact.performance_fact_id",
+            ondelete="RESTRICT",
+        ),
+        nullable=False,
+    )
+    strategy_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    support_state: Mapped[str] = mapped_column(String(24), nullable=False)
+    attributable_rov: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True
+    )
+    attributable_vov: Mapped[Decimal | None] = mapped_column(
+        Numeric(16, 8), nullable=True
+    )
+    attribution_confidence: Mapped[Decimal] = mapped_column(
+        Numeric(6, 5), nullable=False
+    )
+    validity_influence: Mapped[Decimal] = mapped_column(
+        Numeric(8, 7), nullable=False
+    )
+    priority_influence: Mapped[Decimal] = mapped_column(
+        Numeric(8, 7), nullable=False
+    )
+    reason: Mapped[str] = mapped_column(Text, nullable=False)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )
+
+
+class StrategyChangeProposal(Base):
+    """Draft/preview/approval workflow for changing business strategy."""
+
+    __tablename__ = "strategy_change_proposal"
+    __table_args__ = (Index("idx_strategy_proposal_status", "status", "created_at"),)
+
+    proposal_id: Mapped[str] = mapped_column(String(36), primary_key=True)
+    base_strategy_version_id: Mapped[str] = mapped_column(
+        String(36),
+        ForeignKey("strategy_version.strategy_version_id", ondelete="RESTRICT"),
+        nullable=False,
+    )
+    status: Mapped[str] = mapped_column(String(24), nullable=False)
+    requested_change_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    proposed_definition_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    diff_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
+    preview_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
+    reason: Mapped[str] = mapped_column(Text, nullable=False)
+    created_by: Mapped[str] = mapped_column(String(128), nullable=False)
+    approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    approved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, nullable=False, server_default=func.now()
+    )

+ 65 - 1
supply_infra/db/models/demand_belong_pool_rel.py

@@ -1,8 +1,18 @@
 from __future__ import annotations
 
 from datetime import datetime
+from decimal import Decimal
 
-from sqlalchemy import BigInteger, Index, UniqueConstraint, func
+from sqlalchemy import (
+    BigInteger,
+    Boolean,
+    Index,
+    Numeric,
+    String,
+    Text,
+    UniqueConstraint,
+    func,
+)
 from sqlalchemy.orm import Mapped, mapped_column
 
 from supply_infra.db.base import Base
@@ -20,6 +30,7 @@ class DemandBelongPoolRel(Base):
         ),
         Index("idx_belong_category_id", "demand_belong_category_id"),
         Index("idx_pool_di_id", "multi_demand_pool_di_id"),
+        Index("idx_belong_pool_biz_status", "biz_dt", "status"),
     )
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
@@ -29,6 +40,59 @@ class DemandBelongPoolRel(Base):
     multi_demand_pool_di_id: Mapped[int] = mapped_column(
         BigInteger, nullable=False, comment="multi_demand_pool_di.id"
     )
+    biz_dt: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        default="legacy",
+        server_default="legacy",
+        comment="关系证据所属业务日",
+    )
+    relation_type: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        default="explicit_token",
+        server_default="explicit_token",
+        comment="关系类型",
+    )
+    relation_source: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        default="demand_pool_name_tokens",
+        server_default="legacy",
+        comment="关系来源",
+    )
+    reason: Mapped[str] = mapped_column(
+        Text,
+        nullable=False,
+        default="上游需求名称中的显式分词命中",
+        comment="建边理由",
+    )
+    confidence: Mapped[Decimal] = mapped_column(
+        Numeric(6, 5),
+        nullable=False,
+        default=Decimal("1.00000"),
+        server_default="1",
+    )
+    is_inferred: Mapped[bool] = mapped_column(
+        Boolean,
+        nullable=False,
+        default=True,
+        server_default="1",
+        comment="是否为系统推断关系",
+    )
+    status: Mapped[str] = mapped_column(
+        String(24),
+        nullable=False,
+        default="active",
+        server_default="active",
+    )
+    valid_from_biz_dt: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        default="legacy",
+        server_default="legacy",
+    )
+    valid_to_biz_dt: Mapped[str | None] = mapped_column(String(32), nullable=True)
     create_time: Mapped[datetime] = mapped_column(
         nullable=False,
         server_default=func.now(),

+ 22 - 0
supply_infra/db/models/demand_feedback.py

@@ -38,6 +38,11 @@ class DemandFeedback(Base):
             "feedback_user_id",
             "created_at",
         ),
+        Index(
+            "idx_demand_feedback_processing",
+            "processing_status",
+            "created_at",
+        ),
     )
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
@@ -94,6 +99,23 @@ class DemandFeedback(Base):
         nullable=False,
         comment="反馈人登录账号快照",
     )
+    processing_status: Mapped[str] = mapped_column(
+        String(24),
+        nullable=False,
+        default="pending",
+        comment="pending / accepted / rejected / needs_evidence",
+    )
+    platform_demand_id: Mapped[str | None] = mapped_column(
+        String(36), nullable=True
+    )
+    platform_demand_version_id: Mapped[str | None] = mapped_column(
+        String(36), nullable=True
+    )
+    processed_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    resolution_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+    consumed_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    impact_json: Mapped[str | None] = mapped_column(Text, nullable=True)
     created_at: Mapped[datetime] = mapped_column(
         DateTime,
         nullable=False,

+ 11 - 3
supply_infra/db/models/global_tree_category.py

@@ -9,7 +9,7 @@ from supply_infra.db.base import Base
 
 
 class GlobalTreeCategory(Base):
-    """全局树分类 — 从 ODPS public_pattern_mining_category 同步。"""
+    """全局树分类 — 从 ODPS global_category 同步。"""
 
     __tablename__ = "global_tree_category"
     __table_args__ = (UniqueConstraint("source_id", name="uk_source_id"),)
@@ -19,8 +19,16 @@ class GlobalTreeCategory(Base):
     description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="描述")
     level: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="层级")
     parent_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="父节点")
-    source_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="hive表id")
-    source_parent_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="hive表父id")
+    source_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        comment="global_category.stable_id",
+    )
+    source_parent_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        comment="global_category.parent_stable_id",
+    )
     is_delete: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="是否删除0-正常 1-删除")
     create_time: Mapped[datetime | None] = mapped_column(
         nullable=True,

+ 7 - 1
supply_infra/db/models/pipeline_outbox.py

@@ -48,10 +48,16 @@ class PipelineOutbox(Base):
     status: Mapped[str] = mapped_column(
         String(32),
         nullable=False,
-        default="dispatched",
+        default="pending",
     )
     dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
     record_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+    attempt_count: Mapped[int] = mapped_column(nullable=False, default=0)
+    last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    next_retry_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    external_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    external_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
+    response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
     created_at: Mapped[datetime] = mapped_column(
         DateTime,
         nullable=False,

+ 13 - 0
supply_infra/db/models/video_discovery.py

@@ -34,6 +34,10 @@ class VideoDiscoveryRun(Base):
         Index("idx_video_discovery_run_grade", "demand_grade_id"),
         Index("idx_video_discovery_run_biz_dt", "biz_dt"),
         Index("idx_video_discovery_run_status", "status"),
+        Index(
+            "idx_video_discovery_run_platform_version",
+            "platform_demand_version_id",
+        ),
     )
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
@@ -44,6 +48,15 @@ class VideoDiscoveryRun(Base):
     demand_grade_id: Mapped[int | None] = mapped_column(
         BigInteger, nullable=True, comment="可选 demand_grade.id"
     )
+    demand_package_id: Mapped[str | None] = mapped_column(
+        String(36), nullable=True, comment="统一每日需求任务包 id"
+    )
+    daily_demand_task_id: Mapped[str | None] = mapped_column(
+        String(36), nullable=True, comment="统一需求任务 id"
+    )
+    platform_demand_version_id: Mapped[str | None] = mapped_column(
+        String(36), nullable=True, comment="平台需求日版本 id"
+    )
     demand_word: Mapped[str] = mapped_column(
         String(256), nullable=False, comment="用户给定需求词"
     )

+ 0 - 2
supply_infra/db/repositories/__init__.py

@@ -29,7 +29,6 @@ from supply_infra.db.repositories.demand_video_expansion_repo import (
 )
 from supply_infra.db.repositories.generated_demand_repo import GeneratedDemandRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
-from supply_infra.db.repositories.global_tree_element_repo import GlobalTreeElementRepository
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
 from supply_infra.db.repositories.multi_demand_video_detail_repo import (
     MultiDemandVideoDetailRepository,
@@ -60,7 +59,6 @@ __all__ = [
     "DemandVideoExpansionRunRepository",
     "GeneratedDemandRepository",
     "GlobalTreeCategoryRepository",
-    "GlobalTreeElementRepository",
     "MultiDemandPoolDiRepository",
     "MultiDemandVideoDetailRepository",
     "MultiDemandVideoPointRepository",

+ 31 - 3
supply_infra/db/repositories/demand_belong_category_repo.py

@@ -6,6 +6,7 @@ from sqlalchemy import select, update
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
+from supply_infra.db.models.global_tree_category import GlobalTreeCategory
 from supply_infra.db.repositories.base import BaseRepository
 
 _BATCH_SIZE = 1000
@@ -39,7 +40,7 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
         return list(self.session.scalars(stmt).all())
 
     def get_existing_names(self, names: Iterable[str]) -> set[str]:
-        """返回 names 中已存在于表内的名称(含软删除)。"""
+        """返回仍挂在有效分类节点上的活动名称。"""
         name_list = [n for n in names if n]
         if not name_list:
             return set()
@@ -47,8 +48,17 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
         existing: set[str] = set()
         for i in range(0, len(name_list), _BATCH_SIZE):
             batch = name_list[i : i + _BATCH_SIZE]
-            stmt = select(DemandBelongCategory.name).where(
-                DemandBelongCategory.name.in_(batch)
+            stmt = (
+                select(DemandBelongCategory.name)
+                .join(
+                    GlobalTreeCategory,
+                    GlobalTreeCategory.id == DemandBelongCategory.category_id,
+                )
+                .where(
+                    DemandBelongCategory.name.in_(batch),
+                    DemandBelongCategory.is_delete == 0,
+                    GlobalTreeCategory.is_delete == 0,
+                )
             )
             existing.update(n for n in self.session.scalars(stmt).all() if n)
         return existing
@@ -122,6 +132,24 @@ class DemandBelongCategoryRepository(BaseRepository[DemandBelongCategory]):
             inserted += result.rowcount
         return inserted
 
+    def upsert_category_matches(self, rows: list[dict]) -> int:
+        """按 name 写入或刷新接口返回的分类结果。"""
+        if not rows:
+            return 0
+
+        affected = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(DemandBelongCategory).values(batch)
+            stmt = stmt.on_duplicate_key_update(
+                category_id=stmt.inserted.category_id,
+                reason=stmt.inserted.reason,
+                is_delete=0,
+            )
+            result = self.session.execute(stmt)
+            affected += result.rowcount or 0
+        return affected
+
     def update_video_lists(self, updates: dict[int, str | None]) -> int:
         """按 id 批量更新 video_list。"""
         if not updates:

+ 29 - 3
supply_infra/db/repositories/demand_belong_pool_rel_repo.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from collections.abc import Iterable
 
-from sqlalchemy import select, tuple_
+from sqlalchemy import delete, select, tuple_
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
@@ -35,7 +35,12 @@ class DemandBelongPoolRelRepository(BaseRepository[DemandBelongPoolRel]):
                 result.setdefault(int(pool_id), []).append(int(belong_id))
         return result
 
-    def get_pool_ids_by_belong_ids(self, belong_ids: Iterable[int]) -> dict[int, list[int]]:
+    def get_pool_ids_by_belong_ids(
+        self,
+        belong_ids: Iterable[int],
+        *,
+        biz_dt: str | None = None,
+    ) -> dict[int, list[int]]:
         """正向查询归属词关联的需求池行:belong_id -> [pool_id, ...]。"""
         id_list = [int(belong_id) for belong_id in belong_ids]
         if not id_list:
@@ -47,7 +52,12 @@ class DemandBelongPoolRelRepository(BaseRepository[DemandBelongPoolRel]):
             stmt = select(
                 DemandBelongPoolRel.demand_belong_category_id,
                 DemandBelongPoolRel.multi_demand_pool_di_id,
-            ).where(DemandBelongPoolRel.demand_belong_category_id.in_(batch))
+            ).where(
+                DemandBelongPoolRel.demand_belong_category_id.in_(batch),
+                DemandBelongPoolRel.status == "active",
+            )
+            if biz_dt is not None:
+                stmt = stmt.where(DemandBelongPoolRel.biz_dt == biz_dt)
             for belong_id, pool_id in self.session.execute(stmt).all():
                 result.setdefault(int(belong_id), []).append(int(pool_id))
         return result
@@ -75,6 +85,22 @@ class DemandBelongPoolRelRepository(BaseRepository[DemandBelongPoolRel]):
             )
         return existing
 
+    def delete_by_pool_ids(self, pool_ids: Iterable[int]) -> int:
+        """删除指定需求池行的关系,用于源行修订/删除后的精确重建。"""
+        id_list = sorted({int(pool_id) for pool_id in pool_ids})
+        if not id_list:
+            return 0
+
+        deleted = 0
+        for i in range(0, len(id_list), _BATCH_SIZE):
+            batch = id_list[i : i + _BATCH_SIZE]
+            stmt = delete(DemandBelongPoolRel).where(
+                DemandBelongPoolRel.multi_demand_pool_di_id.in_(batch)
+            )
+            result = self.session.execute(stmt)
+            deleted += result.rowcount or 0
+        return deleted
+
     def bulk_insert_ignore(self, rows: list[dict]) -> int:
         """批量插入,忽略已存在的唯一键。"""
         if not rows:

+ 56 - 1
supply_infra/db/repositories/global_tree_category_repo.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from sqlalchemy import func, select
+from sqlalchemy import func, select, update
 from sqlalchemy.dialects.mysql import insert
 
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
@@ -38,11 +38,30 @@ class GlobalTreeCategoryRepository(BaseRepository[GlobalTreeCategory]):
         stmt = select(GlobalTreeCategory.source_id, GlobalTreeCategory.id)
         return {int(row.source_id): int(row.id) for row in self.session.execute(stmt).all()}
 
+    def get_active_source_id_map(self) -> dict[int, int]:
+        """返回有效分类的 stable_id → 本地 id 映射,供挂树结果校验。"""
+        stmt = select(
+            GlobalTreeCategory.source_id,
+            GlobalTreeCategory.id,
+        ).where(GlobalTreeCategory.is_delete == 0)
+        return {
+            int(row.source_id): int(row.id)
+            for row in self.session.execute(stmt).all()
+        }
+
     def get_max_id(self) -> int:
         """返回当前表内最大 id,用于分配新 id(兼容无 source_id 的历史数据)。"""
         max_id = self.session.scalar(select(func.max(GlobalTreeCategory.id)))
         return int(max_id) if max_id is not None else 0
 
+    def count_active(self) -> int:
+        count = self.session.scalar(
+            select(func.count())
+            .select_from(GlobalTreeCategory)
+            .where(GlobalTreeCategory.is_delete == 0)
+        )
+        return int(count or 0)
+
     def bulk_insert_ignore(self, rows: list[dict]) -> int:
         """批量插入,MySQL 按 source_id 唯一索引忽略已存在行(历史数据不变)。"""
         if not rows:
@@ -55,3 +74,39 @@ class GlobalTreeCategoryRepository(BaseRepository[GlobalTreeCategory]):
             result = self.session.execute(stmt)
             inserted += result.rowcount
         return inserted
+
+    def bulk_upsert(self, rows: list[dict]) -> int:
+        """按 source_id 写入或刷新活动分类,不改变已经分配的本地 id。"""
+        if not rows:
+            return 0
+
+        affected = 0
+        for i in range(0, len(rows), _BATCH_SIZE):
+            batch = rows[i : i + _BATCH_SIZE]
+            stmt = insert(GlobalTreeCategory).values(batch)
+            stmt = stmt.on_duplicate_key_update(
+                name=stmt.inserted.name,
+                description=stmt.inserted.description,
+                level=stmt.inserted.level,
+                parent_id=stmt.inserted.parent_id,
+                source_parent_id=stmt.inserted.source_parent_id,
+                is_delete=0,
+            )
+            result = self.session.execute(stmt)
+            affected += result.rowcount or 0
+        return affected
+
+    def mark_missing_deleted(self, active_source_ids: set[int]) -> int:
+        """把本次有效树中已不存在的历史分类标记为删除。"""
+        if not active_source_ids:
+            return 0
+        stmt = (
+            update(GlobalTreeCategory)
+            .where(
+                GlobalTreeCategory.is_delete == 0,
+                GlobalTreeCategory.source_id.not_in(active_source_ids),
+            )
+            .values(is_delete=1)
+        )
+        result = self.session.execute(stmt)
+        return result.rowcount or 0

+ 52 - 5
supply_infra/db/repositories/multi_demand_pool_di_repo.py

@@ -165,6 +165,47 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
         )
         return {(str(s), str(d)) for s, d in self.session.execute(stmt).all()}
 
+    def list_source_rows_by_biz_dt(self, biz_dt: str) -> list[dict]:
+        """返回同步差分需要的源字段,不包含后续步骤回填的 ROV/VOV。"""
+        stmt = select(
+            MultiDemandPoolDi.id,
+            MultiDemandPoolDi.strategy,
+            MultiDemandPoolDi.demand_id,
+            MultiDemandPoolDi.demand_name,
+            MultiDemandPoolDi.weight,
+            MultiDemandPoolDi.type,
+            MultiDemandPoolDi.video_count,
+            MultiDemandPoolDi.video_list,
+            MultiDemandPoolDi.extend,
+            MultiDemandPoolDi.biz_dt,
+        ).where(MultiDemandPoolDi.biz_dt == biz_dt)
+        return [
+            {
+                "id": int(row_id),
+                "strategy": str(strategy),
+                "demand_id": str(demand_id),
+                "demand_name": str(demand_name),
+                "weight": float(weight) if weight is not None else None,
+                "type": str(demand_type) if demand_type is not None else None,
+                "video_count": int(video_count) if video_count is not None else 0,
+                "video_list": video_list,
+                "extend": extend,
+                "biz_dt": str(row_biz_dt),
+            }
+            for (
+                row_id,
+                strategy,
+                demand_id,
+                demand_name,
+                weight,
+                demand_type,
+                video_count,
+                video_list,
+                extend,
+                row_biz_dt,
+            ) in self.session.execute(stmt).all()
+        ]
+
     def delete_by_biz_dt(self, biz_dt: str) -> int:
         """删除指定业务日期的全部数据,便于按日重跑。"""
         stmt = delete(MultiDemandPoolDi).where(MultiDemandPoolDi.biz_dt == biz_dt)
@@ -202,13 +243,16 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
         )
         return [text for text in self.session.scalars(stmt).all() if text]
 
-    def list_id_name_video_lists(self) -> list[tuple[int, str, str | None]]:
-        """查询全表 (id, demand_name, video_list)。"""
+    def list_id_name_video_lists(
+        self,
+        biz_dt: str,
+    ) -> list[tuple[int, str, str | None]]:
+        """查询指定业务日的 (id, demand_name, video_list)。"""
         stmt = select(
             MultiDemandPoolDi.id,
             MultiDemandPoolDi.demand_name,
             MultiDemandPoolDi.video_list,
-        )
+        ).where(MultiDemandPoolDi.biz_dt == biz_dt)
         return [
             (int(row_id), str(name), video_list)
             for row_id, name, video_list in self.session.execute(stmt).all()
@@ -303,8 +347,8 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
             updated += result.rowcount or 0
         return updated
 
-    def update_video_fields(self, biz_dt: str, rows: list[dict]) -> int:
-        """按 (strategy, demand_id) 批量更新 video_list / video_count / weight。"""
+    def update_source_fields(self, biz_dt: str, rows: list[dict]) -> int:
+        """按 (strategy, demand_id) 更新所有会影响下游的上游源字段。"""
         if not rows:
             return 0
 
@@ -318,9 +362,12 @@ class MultiDemandPoolDiRepository(BaseRepository[MultiDemandPoolDi]):
                     MultiDemandPoolDi.demand_id == row["demand_id"],
                 )
                 .values(
+                    demand_name=row["demand_name"],
                     video_list=row.get("video_list"),
                     video_count=row.get("video_count"),
                     weight=row.get("weight"),
+                    type=row.get("type"),
+                    extend=row.get("extend"),
                 )
             )
             result = self.session.execute(stmt)

+ 17 - 1
supply_infra/db/repositories/pipeline_lock_repo.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from datetime import datetime, timedelta
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 
 from supply_infra.db.models.pipeline_lock import PipelineLock
 from supply_infra.db.repositories.base import BaseRepository
@@ -16,6 +16,22 @@ class PipelineLockRepository(BaseRepository[PipelineLock]):
     def get(self, lock_key: str) -> PipelineLock | None:
         return self.session.get(PipelineLock, lock_key)
 
+    def count_active_with_prefix(
+        self,
+        lock_key_prefix: str,
+        *,
+        now: datetime,
+    ) -> int:
+        count = self.session.scalar(
+            select(func.count())
+            .select_from(PipelineLock)
+            .where(
+                PipelineLock.lock_key.startswith(lock_key_prefix),
+                PipelineLock.lease_until > now,
+            )
+        )
+        return int(count or 0)
+
     def lock_control_plane(self, *, now: datetime) -> PipelineLock:
         """Lock the singleton row used for atomic scheduling decisions."""
         lock = self.session.scalar(

+ 70 - 3
supply_infra/db/repositories/pipeline_outbox_repo.py

@@ -1,12 +1,14 @@
 from __future__ import annotations
 
 import uuid
+from datetime import datetime
 from typing import Any
 
 from sqlalchemy import select
 
 from supply_infra.db.models.pipeline_outbox import PipelineOutbox
 from supply_infra.db.repositories.base import BaseRepository
+from supply_infra.pipeline.enums import OutboxStatus
 
 
 class PipelineOutboxRepository(BaseRepository[PipelineOutbox]):
@@ -16,7 +18,13 @@ class PipelineOutboxRepository(BaseRepository[PipelineOutbox]):
         stmt = select(PipelineOutbox).where(PipelineOutbox.idempotency_key == key)
         return self.session.scalar(stmt)
 
-    def record_effect(
+    def get(self, outbox_id: str, *, for_update: bool = False) -> PipelineOutbox | None:
+        stmt = select(PipelineOutbox).where(PipelineOutbox.outbox_id == outbox_id)
+        if for_update:
+            stmt = stmt.with_for_update()
+        return self.session.scalar(stmt)
+
+    def enqueue(
         self,
         *,
         run_id: str,
@@ -26,9 +34,15 @@ class PipelineOutboxRepository(BaseRepository[PipelineOutbox]):
         payload_hash: str,
         payload: dict[str, Any] | None,
         payload_uri: str | None = None,
+        dry_run: bool = False,
     ) -> PipelineOutbox:
         existing = self.get_by_idempotency_key(idempotency_key)
         if existing is not None:
+            if existing.payload_hash != payload_hash:
+                raise ValueError(
+                    "idempotency key reused with a different payload: "
+                    f"{idempotency_key}"
+                )
             return existing
         return self.add(
             PipelineOutbox(
@@ -40,11 +54,64 @@ class PipelineOutboxRepository(BaseRepository[PipelineOutbox]):
                 payload_hash=payload_hash,
                 payload_json=payload,
                 payload_uri=payload_uri,
-                status="dispatched",
-                dry_run=False,
+                status=OutboxStatus.PENDING.value,
+                dry_run=dry_run,
             )
         )
 
+    def mark_sending(self, row: PipelineOutbox, *, now: datetime) -> None:
+        row.status = OutboxStatus.SENDING.value
+        row.attempt_count = int(row.attempt_count or 0) + 1
+        row.last_attempt_at = now
+        row.next_retry_at = None
+        row.record_error = None
+        self.session.flush()
+
+    def mark_created(
+        self,
+        row: PipelineOutbox,
+        *,
+        external_id: str,
+        external_name: str | None,
+        response: dict[str, Any] | None,
+    ) -> None:
+        row.status = OutboxStatus.CREATED.value
+        row.external_id = external_id
+        row.external_name = external_name
+        row.response_json = response
+        row.record_error = None
+        self.session.flush()
+
+    def mark_retryable_failed(
+        self,
+        row: PipelineOutbox,
+        *,
+        error: str,
+        response: dict[str, Any] | None = None,
+    ) -> None:
+        row.status = OutboxStatus.RETRYABLE_FAILED.value
+        row.record_error = error
+        if response is not None:
+            row.response_json = response
+        self.session.flush()
+
+    def mark_ambiguous(self, row: PipelineOutbox, *, error: str) -> None:
+        row.status = OutboxStatus.AMBIGUOUS.value
+        row.record_error = error
+        self.session.flush()
+
+    def mark_succeeded(
+        self,
+        row: PipelineOutbox,
+        *,
+        response: dict[str, Any] | None = None,
+    ) -> None:
+        row.status = OutboxStatus.SUCCEEDED.value
+        row.record_error = None
+        if response is not None:
+            row.response_json = response
+        self.session.flush()
+
     def list_for_run(self, run_id: str) -> list[PipelineOutbox]:
         stmt = (
             select(PipelineOutbox)

+ 6 - 27
supply_infra/odps/client.py

@@ -51,36 +51,15 @@ class ODPSClient:
             return [dict(zip(columns, row.values)) for row in reader]
         return []
 
-
-    def fetch_pattern_mining_elements(self, bizdate: str) -> list[dict[str, Any]]:
-        """拉取 pattern_mining_element 元素(name, category_id)。"""
-        sql = f"""
-        SELECT  t1.name
-                ,t1.category_id
-        FROM    loghubods.pattern_mining_element t1
-        LEFT JOIN loghubods.post t2
-        ON      t1.post_id = t2.post_id
-        AND     t2.dt = '{bizdate}'
-        WHERE   t1.dt = '{bizdate}'
-        AND     t1.execution_id = 401
-        AND     t1.source_table = 'post_decode_topic_point_element'
-        AND     t1.name is NOT NULL
-        AND     t1.category_id IS NOT NULL
-        AND     t1.element_type = '实质'
-        AND     t2.platform = 'piaoquan'
-        GROUP BY t1.name
-                 ,t1.category_id
-        """
-        return self.execute_sql(sql)
-
-    def fetch_pattern_mining_categories(self, bizdate: str) -> list[dict[str, Any]]:
-        """拉取 public_pattern_mining_category 分类。"""
+    def fetch_global_categories(self, bizdate: str) -> list[dict[str, Any]]:
+        """拉取 global_category 中仍有效的“实质”分类树。"""
         sql = f"""
-        SELECT  id,name,description,level,parent_id
-        FROM    loghubods.public_pattern_mining_category
+        SELECT  stable_id,name,description,level,parent_stable_id
+        FROM    loghubods.global_category
         WHERE   dt = '{bizdate}'
-        AND     execution_id = 401
+        AND     retired_at_execution_id IS NULL
         AND     source_type = '实质'
+        ORDER BY level
         """
         return self.execute_sql(sql)
 

+ 330 - 0
supply_infra/pipeline/aigc_outbox.py

@@ -0,0 +1,330 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+from typing import Any
+
+from supply_infra.aigc.client import AigcClient
+from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
+from supply_infra.db.session import get_session
+from supply_infra.pipeline.contracts import StepContext
+from supply_infra.pipeline.dates import china_now
+from supply_infra.pipeline.enums import OutboxStatus
+from supply_infra.scheduler.jobs.publish_videos_from_discovery import (
+    prepare_publish_batches,
+)
+from supply_infra.services.video_discovery_service import (
+    get_video_discovery_service,
+)
+
+logger = logging.getLogger(__name__)
+
+_EFFECT_TYPE = "aigc_publish_batch"
+_CLAIMABLE_STATUSES = {
+    OutboxStatus.PENDING.value,
+    OutboxStatus.CREATED.value,
+    OutboxStatus.RETRYABLE_FAILED.value,
+}
+
+
+def _canonical_payload(payload: dict[str, Any]) -> tuple[str, str]:
+    canonical = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    return canonical, hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def _enqueue_batches(
+    context: StepContext,
+    batches: list[dict[str, Any]],
+) -> list[str]:
+    outbox_ids: list[str] = []
+    with get_session() as session:
+        repo = PipelineOutboxRepository(session)
+        for payload in batches:
+            _, payload_hash = _canonical_payload(payload)
+            batch_key = str(payload["batch_key"])
+            mode = "dry_run" if context.dry_run else "live"
+            idempotency_key = (
+                f"aigc_publish:{context.biz_dt}:{mode}:{batch_key}"
+            )
+            row = repo.enqueue(
+                run_id=context.run_id,
+                step_run_id=context.step_run_id,
+                effect_type=_EFFECT_TYPE,
+                idempotency_key=idempotency_key,
+                payload_hash=payload_hash,
+                payload=payload,
+                dry_run=context.dry_run,
+            )
+            outbox_ids.append(str(row.outbox_id))
+    return outbox_ids
+
+
+def _mark_ambiguous(outbox_id: str, error: str) -> dict[str, Any]:
+    with get_session() as session:
+        repo = PipelineOutboxRepository(session)
+        row = repo.get(outbox_id, for_update=True)
+        if row is None:
+            raise ValueError(f"outbox event not found: {outbox_id}")
+        repo.mark_ambiguous(row, error=error)
+    return {
+        "success": False,
+        "outbox_id": outbox_id,
+        "status": OutboxStatus.AMBIGUOUS.value,
+        "error_code": "aigc_outbox_ambiguous",
+        "error": error,
+    }
+
+
+def _mark_retryable(
+    outbox_id: str,
+    error: str,
+    response: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    with get_session() as session:
+        repo = PipelineOutboxRepository(session)
+        row = repo.get(outbox_id, for_update=True)
+        if row is None:
+            raise ValueError(f"outbox event not found: {outbox_id}")
+        repo.mark_retryable_failed(row, error=error, response=response)
+    return {
+        "success": False,
+        "outbox_id": outbox_id,
+        "status": OutboxStatus.RETRYABLE_FAILED.value,
+        "error_code": "aigc_outbox_retryable",
+        "error": error,
+    }
+
+
+def dispatch_aigc_outbox_event(
+    outbox_id: str,
+    *,
+    client: AigcClient | None = None,
+) -> dict[str, Any]:
+    """
+    Dispatch one durable event.
+
+    A stale ``sending`` event is deliberately moved to ``ambiguous`` instead
+    of being replayed: the remote create request may have succeeded before the
+    process died. This trades automatic recovery for duplicate-action safety.
+    """
+    with get_session() as session:
+        repo = PipelineOutboxRepository(session)
+        row = repo.get(outbox_id, for_update=True)
+        if row is None:
+            raise ValueError(f"outbox event not found: {outbox_id}")
+        if row.dry_run:
+            repo.mark_succeeded(
+                row,
+                response={
+                    "dry_run": True,
+                    "external_request_made": False,
+                    "message": "AIGC dispatch intentionally skipped",
+                },
+            )
+            return {
+                "success": True,
+                "outbox_id": outbox_id,
+                "status": OutboxStatus.SUCCEEDED.value,
+                "dry_run": True,
+                "external_request_made": False,
+            }
+        if row.status == OutboxStatus.SUCCEEDED.value:
+            return {
+                "success": True,
+                "outbox_id": outbox_id,
+                "status": row.status,
+                "skipped_already_succeeded": True,
+                "external_id": row.external_id,
+            }
+        if row.status == OutboxStatus.AMBIGUOUS.value:
+            return {
+                "success": False,
+                "outbox_id": outbox_id,
+                "status": row.status,
+                "error_code": "aigc_outbox_ambiguous",
+                "error": row.record_error or "外部请求结果不确定,需要人工核对",
+            }
+        if row.status == OutboxStatus.SENDING.value:
+            repo.mark_ambiguous(
+                row,
+                error=(
+                    "检测到未确认的发送中事件;为避免重复创建 AIGC 计划,"
+                    "已停止自动重放,请按确定性计划名人工核对"
+                ),
+            )
+            return {
+                "success": False,
+                "outbox_id": outbox_id,
+                "status": OutboxStatus.AMBIGUOUS.value,
+                "error_code": "aigc_outbox_ambiguous",
+                "error": row.record_error,
+            }
+        if row.status not in _CLAIMABLE_STATUSES:
+            return {
+                "success": False,
+                "outbox_id": outbox_id,
+                "status": row.status,
+                "error_code": "aigc_outbox_not_dispatchable",
+                "error": f"事件状态不允许发送: {row.status}",
+            }
+
+        payload = dict(row.payload_json or {})
+        external_id = str(row.external_id or "")
+        external_name = str(row.external_name or "")
+        repo.mark_sending(row, now=china_now())
+
+    required = {
+        "aweme_ids",
+        "candidate_ids",
+        "plan_name",
+        "plan_label",
+        "produce_plan_id",
+        "publish_plan_id",
+    }
+    missing = sorted(required - payload.keys())
+    if missing:
+        return _mark_retryable(
+            outbox_id,
+            f"Outbox payload 缺少字段: {', '.join(missing)}",
+        )
+
+    active_client = client or AigcClient()
+    if not external_id:
+        try:
+            create_result = active_client.create_video_crawler_plan(
+                [str(value) for value in payload["aweme_ids"]],
+                plan_name=str(payload["plan_name"]),
+            )
+        except Exception as exc:
+            logger.exception("AIGC create request outcome is ambiguous: outbox=%s", outbox_id)
+            return _mark_ambiguous(
+                outbox_id,
+                f"创建请求异常,远端是否成功未知: {exc}",
+            )
+
+        if not create_result.get("success"):
+            return _mark_retryable(
+                outbox_id,
+                str(create_result.get("error") or "创建爬取计划失败"),
+                response=create_result,
+            )
+        external_id = str(create_result.get("crawler_plan_id") or "")
+        external_name = str(
+            create_result.get("crawler_plan_name") or payload["plan_name"]
+        )
+        if not external_id:
+            return _mark_ambiguous(
+                outbox_id,
+                "AIGC 返回成功但未提供 crawler_plan_id,无法安全重试",
+            )
+        with get_session() as session:
+            repo = PipelineOutboxRepository(session)
+            row = repo.get(outbox_id, for_update=True)
+            if row is None:
+                raise ValueError(f"outbox event not found: {outbox_id}")
+            repo.mark_created(
+                row,
+                external_id=external_id,
+                external_name=external_name,
+                response=create_result,
+            )
+
+    try:
+        bind_result = active_client.bind_crawler_to_produce_plan(
+            external_id,
+            str(payload["produce_plan_id"]),
+            crawler_plan_name=external_name or str(payload["plan_name"]),
+        )
+    except Exception as exc:
+        logger.exception("AIGC bind failed after create: outbox=%s", outbox_id)
+        return _mark_retryable(outbox_id, f"绑定生成计划异常: {exc}")
+
+    if not bind_result.get("success"):
+        return _mark_retryable(
+            outbox_id,
+            str(bind_result.get("error") or "绑定生成计划失败"),
+            response=bind_result,
+        )
+
+    try:
+        updated = get_video_discovery_service().mark_candidates_aigc_plans(
+            [int(value) for value in payload["candidate_ids"]],
+            crawler_plan_id=external_id,
+            produce_plan_id=str(payload["produce_plan_id"]),
+            publish_plan_id=str(payload["publish_plan_id"]),
+            plan_label=str(payload["plan_label"]),
+        )
+    except Exception as exc:
+        logger.exception("Failed to persist AIGC result: outbox=%s", outbox_id)
+        return _mark_retryable(outbox_id, f"本地保存 AIGC 结果失败: {exc}")
+
+    final_response = {
+        "create": {
+            "crawler_plan_id": external_id,
+            "crawler_plan_name": external_name,
+        },
+        "bind": bind_result,
+        "candidate_rows_updated": updated,
+    }
+    with get_session() as session:
+        repo = PipelineOutboxRepository(session)
+        row = repo.get(outbox_id, for_update=True)
+        if row is None:
+            raise ValueError(f"outbox event not found: {outbox_id}")
+        repo.mark_succeeded(row, response=final_response)
+
+    return {
+        "success": True,
+        "outbox_id": outbox_id,
+        "status": OutboxStatus.SUCCEEDED.value,
+        "external_id": external_id,
+        "candidate_rows_updated": updated,
+    }
+
+
+def execute_aigc_outbox(context: StepContext) -> dict[str, Any]:
+    prepared = prepare_publish_batches(biz_dt=context.biz_dt)
+    outbox_ids = _enqueue_batches(context, list(prepared["batches"]))
+
+    # A retry can happen after candidate rows were marked but before the event
+    # was acknowledged. Include all nonterminal events belonging to this run.
+    with get_session() as session:
+        for row in PipelineOutboxRepository(session).list_for_run(context.run_id):
+            if (
+                row.effect_type == _EFFECT_TYPE
+                and row.status != OutboxStatus.SUCCEEDED.value
+            ):
+                outbox_ids.append(str(row.outbox_id))
+
+    ordered_ids = list(dict.fromkeys(outbox_ids))
+    results = [dispatch_aigc_outbox_event(outbox_id) for outbox_id in ordered_ids]
+    failures = [result for result in results if result.get("success") is False]
+    return {
+        "success": not failures,
+        "effect_recorded": bool(ordered_ids),
+        "external_request_made": bool(ordered_ids) and not context.dry_run,
+        "dry_run": context.dry_run,
+        "biz_dt": context.biz_dt,
+        "candidate_count": prepared["candidate_count"],
+        "batch_count": len(ordered_ids),
+        "failed_batch_count": len(failures),
+        "outbox_ids": ordered_ids,
+        "batches": results,
+        "error_code": (
+            str(failures[0].get("error_code") or "aigc_publish_failed")
+            if failures
+            else None
+        ),
+        "error": (
+            str(failures[0].get("error") or "AIGC Outbox 发送失败")
+            if failures
+            else None
+        ),
+    }

+ 6 - 0
supply_infra/pipeline/cli.py

@@ -18,6 +18,11 @@ def _parser() -> argparse.ArgumentParser:
     submit = sub.add_parser("submit")
     submit.add_argument("--biz-dt")
     submit.add_argument("--reason")
+    submit.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="execute all local steps but record AIGC effects without dispatching them",
+    )
     inspect = sub.add_parser("inspect")
     inspect.add_argument("--run-id", required=True)
     sub.add_parser("worker")
@@ -38,6 +43,7 @@ def main() -> None:
             trigger_type="cli",
             trigger_source="cli",
             trigger_reason=args.reason,
+            dry_run=args.dry_run,
         ).to_dict()
         print(json.dumps(result, ensure_ascii=False))
         return

+ 1 - 0
supply_infra/pipeline/contracts.py

@@ -13,6 +13,7 @@ class StepContext:
     date_snapshot: dict[str, Any]
     config_snapshot: dict[str, Any]
     input_snapshot: dict[str, Any] = field(default_factory=dict)
+    dry_run: bool = False
 
 
 @dataclass(frozen=True)

+ 4 - 1
supply_infra/pipeline/dag.py

@@ -17,15 +17,18 @@ class StepDefinition:
 _RAW_STEPS: tuple[tuple[str, int, bool], ...] = (
     ("global_tree_sync", 3600, True),
     ("demand_pool_source_sync", 3600, True),
-    ("demand_classify", 21600, False),
+    ("demand_classify", 21600, True),
     ("demand_belong_rel_sync", 3600, True),
     ("real_metrics_sync", 3600, True),
     ("popularity_stats", 3600, True),
     ("category_tree_weight", 3600, True),
     ("source_video_sync", 7200, True),
     ("demand_grade", 21600, False),
+    ("platform_demand_materialize", 3600, True),
+    ("daily_demand_package", 3600, True),
     ("demand_expand", 21600, False),
     ("video_discovery", 21600, False),
+    ("content_feedback_materialize", 3600, True),
     ("aigc_write_record", 1800, True),
 )
 

+ 7 - 2
supply_infra/pipeline/enums.py

@@ -28,5 +28,10 @@ class StepStatus(StrEnum):
 
 
 class OutboxStatus(StrEnum):
-    DISPATCHED = "dispatched"
-    RECORD_FAILED = "record_failed"
+    PENDING = "pending"
+    SENDING = "sending"
+    CREATED = "created"
+    RETRYABLE_FAILED = "retryable_failed"
+    AMBIGUOUS = "ambiguous"
+    SUCCEEDED = "succeeded"
+    PERMANENT_FAILED = "permanent_failed"

+ 6 - 5
supply_infra/pipeline/gates.py

@@ -19,9 +19,6 @@ def evaluate_step_gate(step_key: str, payload: dict[str, Any]) -> GateDecision:
             error_message=str(payload.get("error") or "Step returned success=false"),
         )
 
-    if step_key == "demand_classify" and payload.get("failed_batches"):
-        return GateDecision(False, "classification_failed_batches", "Classification has failed batches")
-
     if step_key in {"demand_grade", "demand_expand", "video_discovery"}:
         failed = int(payload.get("failed", payload.get("failed_count", 0)) or 0)
         if failed > 0:
@@ -32,11 +29,15 @@ def evaluate_step_gate(step_key: str, payload: dict[str, Any]) -> GateDecision:
             )
 
     if step_key == "aigc_write_record":
-        if payload.get("effect_recorded") is not True or not payload.get("payload_hash"):
+        request_made = payload.get("external_request_made") is True
+        if request_made and (
+            payload.get("effect_recorded") is not True
+            or not payload.get("outbox_ids")
+        ):
             return GateDecision(
                 False,
                 "aigc_record_incomplete",
-                "AIGC publish payload/hash was not durably recorded",
+                "AIGC publish batches were not durably recorded before dispatch",
             )
         failed_batches = int(payload.get("failed_batch_count", 0) or 0)
         if failed_batches > 0:

+ 78 - 1
supply_infra/pipeline/health.py

@@ -10,6 +10,8 @@ from supply_infra.db.session import get_session
 from supply_infra.pipeline.dates import china_now
 
 SCHEDULER_HEARTBEAT_KEY = "pipeline:scheduler"
+RECONCILER_HEARTBEAT_KEY = "pipeline:reconciler"
+WORKER_HEARTBEAT_PREFIX = "pipeline:worker:"
 _ACTIVE_STATUSES = {
     "queued",
     "running",
@@ -36,6 +38,42 @@ def touch_scheduler_heartbeat(
         )
 
 
+def touch_worker_heartbeat(
+    *,
+    owner: str,
+    settings: InfraSettings | None = None,
+) -> None:
+    active = settings or get_infra_settings()
+    with get_session() as session:
+        PipelineLockRepository(session).touch(
+            lock_key=f"{WORKER_HEARTBEAT_PREFIX}{owner}",
+            owner=owner,
+            lease_seconds=max(
+                active.pipeline_lease_seconds,
+                active.pipeline_heartbeat_seconds * 3,
+            ),
+            now=china_now(),
+        )
+
+
+def touch_reconciler_heartbeat(
+    *,
+    owner: str,
+    settings: InfraSettings | None = None,
+) -> None:
+    active = settings or get_infra_settings()
+    with get_session() as session:
+        PipelineLockRepository(session).touch(
+            lock_key=RECONCILER_HEARTBEAT_KEY,
+            owner=owner,
+            lease_seconds=max(
+                active.pipeline_lease_seconds,
+                active.pipeline_reconcile_seconds * 3,
+            ),
+            now=china_now(),
+        )
+
+
 def run_alert_level(
     *,
     status: str,
@@ -59,13 +97,34 @@ def run_alert_level(
     return None
 
 
+def runtime_components_ready(
+    *,
+    scheduler_enabled: bool,
+    scheduler_running: bool,
+    active_worker_count: int,
+    expected_worker_count: int,
+    reconciler_running: bool,
+) -> bool:
+    return bool(
+        (not scheduler_enabled or scheduler_running)
+        and active_worker_count >= expected_worker_count
+        and reconciler_running
+    )
+
+
 def pipeline_health_snapshot(
     settings: InfraSettings | None = None,
 ) -> dict[str, Any]:
     active = settings or get_infra_settings()
     now = china_now()
     with get_session() as session:
-        scheduler_lock = PipelineLockRepository(session).get(SCHEDULER_HEARTBEAT_KEY)
+        lock_repo = PipelineLockRepository(session)
+        scheduler_lock = lock_repo.get(SCHEDULER_HEARTBEAT_KEY)
+        reconciler_lock = lock_repo.get(RECONCILER_HEARTBEAT_KEY)
+        active_worker_count = lock_repo.count_active_with_prefix(
+            WORKER_HEARTBEAT_PREFIX,
+            now=now,
+        )
         latest = PipelineRunRepository(session).list_recent(limit=1)
         latest_run = latest[0] if latest else None
         scheduler_heartbeat_at = (
@@ -74,6 +133,9 @@ def pipeline_health_snapshot(
         scheduler_lease_until = (
             scheduler_lock.lease_until if scheduler_lock is not None else None
         )
+        reconciler_lease_until = (
+            reconciler_lock.lease_until if reconciler_lock is not None else None
+        )
         latest_snapshot = (
             {
                 "run_id": latest_run.run_id,
@@ -96,6 +158,10 @@ def pipeline_health_snapshot(
         and scheduler_lease_until is not None
         and scheduler_lease_until > now
     )
+    reconciler_running = bool(
+        reconciler_lease_until is not None and reconciler_lease_until > now
+    )
+    workers_running = active_worker_count >= active.pipeline_worker_processes
     alert_level = None
     if latest_snapshot is not None:
         alert_level = run_alert_level(
@@ -110,6 +176,17 @@ def pipeline_health_snapshot(
         "scheduler_enabled": active.scheduler_enabled,
         "scheduler_running": scheduler_running,
         "scheduler_heartbeat_at": scheduler_heartbeat,
+        "worker_expected_count": active.pipeline_worker_processes,
+        "worker_active_count": active_worker_count,
+        "workers_running": workers_running,
+        "reconciler_running": reconciler_running,
+        "runtime_components_ready": runtime_components_ready(
+            scheduler_enabled=active.scheduler_enabled,
+            scheduler_running=scheduler_running,
+            active_worker_count=active_worker_count,
+            expected_worker_count=active.pipeline_worker_processes,
+            reconciler_running=reconciler_running,
+        ),
         "latest_run_id": latest_snapshot["run_id"] if latest_snapshot else None,
         "latest_run_status": latest_snapshot["status"] if latest_snapshot else None,
         "latest_run_alert_level": alert_level,

+ 1 - 0
supply_infra/pipeline/orchestrator.py

@@ -12,6 +12,7 @@ from supply_infra.pipeline.enums import RunStatus, StepStatus
 from supply_infra.pipeline.gates import evaluate_step_gate
 
 _TRANSIENT_ERRORS = {
+    "category_match_failed_items",
     "step_timed_out",
     "step_launch_failed",
     "db_pool_exhausted",

+ 230 - 0
supply_infra/pipeline/preflight.py

@@ -0,0 +1,230 @@
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from alembic.config import Config
+from alembic.migration import MigrationContext
+from alembic.script import ScriptDirectory
+from sqlalchemy import inspect, text
+
+from supply_agent.config import Settings
+from supply_infra.aigc.plan_map import list_unique_plan_pairs
+from supply_infra.config import InfraSettings
+from supply_infra.db.session import dispose_engine, get_engine
+from supply_infra.pipeline.dag import PIPELINE_STEPS, validate_dag
+from supply_infra.pipeline.registry import STEP_REGISTRY
+
+REQUIRED_PIPELINE_TABLES = frozenset(
+    {
+        "pipeline_run",
+        "pipeline_step_run",
+        "pipeline_lock",
+        "pipeline_outbox",
+        "global_tree_category",
+        "multi_demand_pool_di",
+        "demand_belong_category",
+        "demand_belong_pool_rel",
+        "demand_popularity_stats",
+        "category_tree_weight",
+        "multi_demand_video_detail",
+        "multi_demand_video_point",
+        "demand_grade",
+        "demand_grade_category_rel",
+        "demand_grade_plan",
+        "demand_grade_plan_group",
+        "demand_grade_plan_group_item",
+        "demand_video_expansion",
+        "demand_video_expansion_run",
+        "video_discovery_run",
+        "video_discovery_search",
+        "video_discovery_candidate",
+        "evidence_package",
+        "raw_demand_expression",
+        "standard_demand_term",
+        "standard_demand_alias",
+        "platform_demand",
+        "platform_demand_version",
+        "platform_demand_category_rel",
+        "strategy_version",
+        "demand_evaluation_snapshot",
+        "demand_score_contribution",
+        "daily_demand_package",
+        "daily_demand_task",
+        "content_demand_coverage",
+        "content_performance_fact",
+        "demand_attribution_snapshot",
+        "strategy_change_proposal",
+        "demand_feedback",
+    }
+)
+
+
+@dataclass(frozen=True)
+class PreflightResult:
+    passed: bool
+    errors: list[str]
+    warnings: list[str]
+    checks: dict[str, Any]
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "passed": self.passed,
+            "errors": self.errors,
+            "warnings": self.warnings,
+            "checks": self.checks,
+        }
+
+
+def _env_true(name: str, *, default: bool) -> bool:
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def evaluate_preflight(
+    *,
+    infra: InfraSettings,
+    openrouter_configured: bool,
+    table_names: set[str],
+    current_revision: str | None,
+    expected_revision: str,
+    require_scheduler: bool,
+    require_secure_cookie: bool,
+) -> PreflightResult:
+    errors: list[str] = []
+    warnings: list[str] = []
+    missing_tables = sorted(REQUIRED_PIPELINE_TABLES - table_names)
+    dag_keys = {step.key for step in PIPELINE_STEPS}
+    registry_keys = set(STEP_REGISTRY)
+
+    if require_scheduler and not infra.scheduler_enabled:
+        errors.append(
+            "SCHEDULER_ENABLED 必须为 true,否则不会自动提交每日任务"
+        )
+    if not infra.mysql_password:
+        errors.append("MYSQL_PASSWORD 未配置")
+    if not (infra.odps_access_id and infra.odps_access_key and infra.odps_project):
+        errors.append("ODPS_ACCESS_ID/ODPS_ACCESS_KEY/ODPS_PROJECT 未完整配置")
+    if not openrouter_configured:
+        errors.append("OPENROUTER_API_KEY 未配置,Agent 步骤无法运行")
+    if not infra.category_match_api_url.strip():
+        errors.append("CATEGORY_MATCH_API_URL 未配置,需求词无法挂树")
+    if not infra.aigc_api_token:
+        errors.append("AIGC_API_TOKEN 未配置,最后发布步骤无法运行")
+    if infra.log_oss_upload_enabled and not (
+        infra.aliyun_oss_access_key_id
+        and infra.aliyun_oss_access_key_secret
+        and infra.aliyun_oss_bucket
+    ):
+        errors.append("LOG_OSS_UPLOAD_ENABLED=true,但 OSS 凭证未完整配置")
+    if require_secure_cookie and not infra.auth_cookie_secure:
+        errors.append("HTTPS 上线要求 AUTH_COOKIE_SECURE=true")
+    elif not infra.auth_cookie_secure:
+        warnings.append("AUTH_COOKIE_SECURE=false;仅适合非 HTTPS 本地环境")
+    if current_revision != expected_revision:
+        errors.append(
+            "数据库迁移版本不一致:"
+            f"current={current_revision or 'none'} expected={expected_revision}"
+        )
+    if missing_tables:
+        errors.append(f"缺少流水线依赖表:{', '.join(missing_tables)}")
+    if dag_keys != registry_keys:
+        errors.append(
+            "DAG 与处理器注册不一致:"
+            f"missing={sorted(dag_keys - registry_keys)} "
+            f"orphan={sorted(registry_keys - dag_keys)}"
+        )
+    if not list_unique_plan_pairs():
+        errors.append("AIGC 生成/发布计划映射为空")
+
+    checks = {
+        "database_revision": current_revision,
+        "expected_revision": expected_revision,
+        "database_table_count": len(table_names),
+        "missing_table_count": len(missing_tables),
+        "pipeline_step_count": len(PIPELINE_STEPS),
+        "scheduler_enabled": infra.scheduler_enabled,
+        "scheduler_cron": (
+            f"{infra.scheduler_cron_hour:02d}:"
+            f"{infra.scheduler_cron_minute:02d} "
+            f"{infra.scheduler_timezone}"
+        ),
+        "worker_processes": infra.pipeline_worker_processes,
+        "max_active_steps": infra.pipeline_max_active_steps,
+        "mysql_required_connections": infra.pipeline_required_connections,
+        "mysql_connection_budget": infra.mysql_connection_budget,
+        "aigc_plan_pair_count": len(list_unique_plan_pairs()),
+        "category_match_api_configured": bool(
+            infra.category_match_api_url.strip()
+        ),
+    }
+    return PreflightResult(
+        passed=not errors,
+        errors=errors,
+        warnings=warnings,
+        checks=checks,
+    )
+
+
+def run_preflight() -> PreflightResult:
+    infra = InfraSettings()
+    agent = Settings()
+    validate_dag()
+
+    config_path = Path(__file__).resolve().parents[2] / "alembic.ini"
+    alembic_config = Config(str(config_path))
+    expected_revision = ScriptDirectory.from_config(
+        alembic_config
+    ).get_current_head()
+
+    engine = get_engine()
+    try:
+        with engine.connect() as connection:
+            connection.execute(text("SELECT 1"))
+            table_names = set(inspect(connection).get_table_names())
+            current_revision = MigrationContext.configure(
+                connection
+            ).get_current_revision()
+    except Exception as exc:
+        return PreflightResult(
+            passed=False,
+            errors=[
+                "MySQL 连接或元数据检查失败:"
+                f"{type(exc).__name__}(详细信息见数据库日志)"
+            ],
+            warnings=[],
+            checks={"database_connected": False},
+        )
+    finally:
+        dispose_engine()
+
+    return evaluate_preflight(
+        infra=infra,
+        openrouter_configured=bool(agent.openrouter_api_key.strip()),
+        table_names=table_names,
+        current_revision=current_revision,
+        expected_revision=expected_revision,
+        require_scheduler=_env_true(
+            "PIPELINE_REQUIRE_SCHEDULER",
+            default=True,
+        ),
+        require_secure_cookie=_env_true(
+            "PIPELINE_REQUIRE_SECURE_COOKIE",
+            default=False,
+        ),
+    )
+
+
+def main() -> None:
+    result = run_preflight()
+    print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
+    raise SystemExit(0 if result.passed else 1)
+
+
+if __name__ == "__main__":
+    main()

+ 13 - 2
supply_infra/pipeline/reconciler.py

@@ -1,7 +1,9 @@
 from __future__ import annotations
 
 import logging
+import os
 import signal
+import socket
 import threading
 from datetime import timedelta
 from zoneinfo import ZoneInfo
@@ -12,7 +14,10 @@ from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunR
 from supply_infra.db.session import dispose_engine, get_session
 from supply_infra.pipeline.dates import CHINA_TIMEZONE, china_now
 from supply_infra.pipeline.enums import RunStatus, StepStatus
-from supply_infra.pipeline.health import run_alert_level
+from supply_infra.pipeline.health import (
+    run_alert_level,
+    touch_reconciler_heartbeat,
+)
 from supply_infra.pipeline.run_service import PIPELINE_KEY, submit_pipeline_run
 
 logger = logging.getLogger(__name__)
@@ -205,6 +210,7 @@ def reconcile_once() -> dict[str, int]:
 class PipelineReconciler:
     def __init__(self, settings: InfraSettings | None = None) -> None:
         self.settings = settings or get_infra_settings()
+        self.owner = f"{socket.gethostname()}:{os.getpid()}"
         self._stop = threading.Event()
 
     def stop(self, *_args: object) -> None:
@@ -217,7 +223,12 @@ class PipelineReconciler:
         try:
             while not self._stop.is_set():
                 try:
-                    logger.info("Pipeline reconcile result: %s", reconcile_once())
+                    result = reconcile_once()
+                    touch_reconciler_heartbeat(
+                        owner=self.owner,
+                        settings=self.settings,
+                    )
+                    logger.info("Pipeline reconcile result: %s", result)
                 except Exception:
                     logger.exception("Pipeline reconcile failed")
                 self._stop.wait(self.settings.pipeline_reconcile_seconds)

+ 34 - 74
supply_infra/pipeline/registry.py

@@ -1,19 +1,11 @@
 from __future__ import annotations
 
-import hashlib
-import json
 from collections.abc import Callable
-from pathlib import Path
 from typing import Any
 
-from supply_infra.config import get_infra_settings
-from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
-from supply_infra.db.session import get_session
 from supply_infra.pipeline.contracts import StepContext
 
 StepHandler = Callable[[StepContext], dict[str, Any]]
-_REPO_ROOT = Path(__file__).resolve().parents[2]
-_MAX_INLINE_EFFECT_BYTES = 512_000
 
 
 def _global_tree(context: StepContext) -> dict[str, Any]:
@@ -38,12 +30,14 @@ def _demand_classify(context: StepContext) -> dict[str, Any]:
     return _classify_words(context.biz_dt)
 
 
-def _demand_rel(_context: StepContext) -> dict[str, Any]:
+def _demand_rel(context: StepContext) -> dict[str, Any]:
     from supply_infra.scheduler.jobs.demand_pool.belong_rel import (
         sync_demand_belong_pool_rel,
     )
 
-    return sync_demand_belong_pool_rel()
+    return sync_demand_belong_pool_rel(
+        str(context.date_snapshot["demand_pool_partition"])
+    )
 
 
 def _real_metrics(context: StepContext) -> dict[str, Any]:
@@ -81,6 +75,22 @@ def _grade(context: StepContext) -> dict[str, Any]:
     return grade_demand_pool(context.biz_dt)
 
 
+def _platform_demand_materialize(context: StepContext) -> dict[str, Any]:
+    from supply_infra.business_harness.materialize import (
+        materialize_platform_demands,
+    )
+
+    return materialize_platform_demands(context)
+
+
+def _daily_demand_package(context: StepContext) -> dict[str, Any]:
+    from supply_infra.business_harness.evaluate import (
+        publish_daily_demand_package,
+    )
+
+    return publish_daily_demand_package(context)
+
+
 def _expand(context: StepContext) -> dict[str, Any]:
     from supply_infra.scheduler.jobs.expand_demand_from_video_points import (
         expand_demand_from_video_points,
@@ -101,75 +111,22 @@ def _discover(context: StepContext) -> dict[str, Any]:
     return discover_videos_from_demands(
         context.biz_dt,
         workers=PIPELINE_FIND_AGENT_WORKERS,
+        package_run_id=context.run_id,
     )
 
 
-def _aigc_write_record(context: StepContext) -> dict[str, Any]:
-    from supply_infra.scheduler.jobs.publish_videos_from_discovery import (
-        publish_videos_from_discovery,
+def _content_feedback(context: StepContext) -> dict[str, Any]:
+    from supply_infra.business_harness.content_feedback import (
+        materialize_content_feedback,
     )
 
-    settings = get_infra_settings()
-    payload = publish_videos_from_discovery(biz_dt=context.biz_dt)
-    publish_success = bool(payload.get("success", True))
-    canonical = json.dumps(
-        payload,
-        ensure_ascii=False,
-        sort_keys=True,
-        separators=(",", ":"),
-        default=str,
-    )
-    encoded = canonical.encode("utf-8")
-    payload_hash = hashlib.sha256(encoded).hexdigest()
-    idempotency_key = f"aigc_write_record:{context.biz_dt}:{payload_hash}"
-    payload_uri: str | None = None
-    stored_payload: dict[str, Any] | None = payload
-    if len(encoded) > _MAX_INLINE_EFFECT_BYTES:
-        log_dir = Path(settings.pipeline_log_dir)
-        if not log_dir.is_absolute():
-            log_dir = _REPO_ROOT / log_dir
-        effect_dir = log_dir / context.run_id / "effects"
-        effect_dir.mkdir(parents=True, exist_ok=True)
-        effect_path = effect_dir / f"{context.step_run_id}-{payload_hash}.json"
-        temporary_path = effect_path.with_suffix(".json.tmp")
-        temporary_path.write_bytes(encoded)
-        temporary_path.replace(effect_path)
-        payload_uri = str(effect_path)
-        stored_payload = {
-            "externalized": True,
-            "payload_bytes": len(encoded),
-            "payload_hash": payload_hash,
-        }
-    with get_session() as session:
-        record = PipelineOutboxRepository(session).record_effect(
-            run_id=context.run_id,
-            step_run_id=context.step_run_id,
-            effect_type="aigc_write_record",
-            idempotency_key=idempotency_key,
-            payload_hash=payload_hash,
-            payload=stored_payload,
-            payload_uri=payload_uri,
-        )
-        outbox_id = record.outbox_id
-
-    result: dict[str, Any] = {
-        "success": publish_success,
-        "effect_recorded": True,
-        "external_request_made": int(payload.get("candidate_count", 0) or 0) > 0,
-        "outbox_id": outbox_id,
-        "payload_hash": payload_hash,
-        "payload_uri": payload_uri,
-        "candidate_count": payload.get("candidate_count", 0),
-        "batch_count": payload.get("batch_count", 0),
-        "failed_batch_count": payload.get("failed_batch_count", 0),
-    }
-    if not publish_success:
-        result["error"] = str(
-            payload.get("error")
-            or f"AIGC publish failed ({result['failed_batch_count']} batch(es))"
-        )
-        result["error_code"] = "aigc_publish_failed"
-    return result
+    return materialize_content_feedback(context)
+
+
+def _aigc_write_record(context: StepContext) -> dict[str, Any]:
+    from supply_infra.pipeline.aigc_outbox import execute_aigc_outbox
+
+    return execute_aigc_outbox(context)
 
 
 STEP_REGISTRY: dict[str, StepHandler] = {
@@ -182,8 +139,11 @@ STEP_REGISTRY: dict[str, StepHandler] = {
     "category_tree_weight": _tree_weight,
     "source_video_sync": _source_videos,
     "demand_grade": _grade,
+    "platform_demand_materialize": _platform_demand_materialize,
+    "daily_demand_package": _daily_demand_package,
     "demand_expand": _expand,
     "video_discovery": _discover,
+    "content_feedback_materialize": _content_feedback,
     "aigc_write_record": _aigc_write_record,
 }
 

+ 14 - 6
supply_infra/pipeline/run_service.py

@@ -72,11 +72,13 @@ def submit_pipeline_run(
     trigger_type: str = "api",
     trigger_source: str | None = None,
     trigger_reason: str | None = None,
+    dry_run: bool = False,
     settings: InfraSettings | None = None,
 ) -> RunSubmission:
     active = settings or get_infra_settings()
     resolved_biz_dt = resolve_biz_dt(biz_dt, settings=active)
-    dedupe_key = f"{PIPELINE_KEY}:{resolved_biz_dt}:full"
+    run_mode = "dry_run" if dry_run else "full"
+    dedupe_key = f"{PIPELINE_KEY}:{resolved_biz_dt}:{run_mode}"
     run_id = str(uuid.uuid4())
     date_snapshot = build_date_snapshot(resolved_biz_dt)
 
@@ -91,7 +93,7 @@ def submit_pipeline_run(
                     created=False,
                     status=existing.status,
                     biz_dt=existing.biz_dt,
-                    dry_run=False,
+                    dry_run=bool(existing.dry_run),
                 )
 
             # All entry points share the same no-overlap rule. Linking each new
@@ -112,8 +114,8 @@ def submit_pipeline_run(
                     "trigger_type": trigger_type,
                     "trigger_source": trigger_source or trigger_type,
                     "trigger_reason": trigger_reason,
-                    "run_mode": "full",
-                    "dry_run": False,
+                    "run_mode": run_mode,
+                    "dry_run": dry_run,
                     "status": initial_status,
                     "deadline_at": deadline_for_trigger(
                         trigger_type,
@@ -167,7 +169,7 @@ def submit_pipeline_run(
                 created=True,
                 status=run.status,
                 biz_dt=run.biz_dt,
-                dry_run=False,
+                dry_run=dry_run,
             )
     except IntegrityError:
         with get_session() as session:
@@ -179,7 +181,7 @@ def submit_pipeline_run(
                 created=False,
                 status=existing.status,
                 biz_dt=existing.biz_dt,
-                dry_run=False,
+                dry_run=bool(existing.dry_run),
             )
 
 
@@ -201,6 +203,12 @@ def get_pipeline_run(run_id: str) -> dict[str, Any] | None:
                     "payload_hash": item.payload_hash,
                     "payload_uri": item.payload_uri,
                     "status": item.status,
+                    "attempt_count": item.attempt_count,
+                    "last_attempt_at": _iso(item.last_attempt_at),
+                    "next_retry_at": _iso(item.next_retry_at),
+                    "external_id": item.external_id,
+                    "external_name": item.external_name,
+                    "error": item.record_error,
                     "dry_run": item.dry_run,
                     "created_at": _iso(item.created_at),
                 }

+ 1 - 0
supply_infra/pipeline/step_cli.py

@@ -31,6 +31,7 @@ def execute_step_run(step_run_id: str) -> dict[str, Any]:
             date_snapshot=dict(run.date_snapshot_json or {}),
             config_snapshot=dict(run.config_snapshot_json or {}),
             input_snapshot=dict(step.input_snapshot_json or {}),
+            dry_run=bool(run.dry_run),
         )
     return execute_registered_step(context)
 

+ 23 - 0
supply_infra/pipeline/worker.py

@@ -14,6 +14,7 @@ from supply_infra.db.repositories.pipeline_run_repo import PipelineRunRepository
 from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
 from supply_infra.db.session import dispose_engine, get_session
 from supply_infra.pipeline.dates import china_now
+from supply_infra.pipeline.health import touch_worker_heartbeat
 from supply_infra.pipeline.orchestrator import complete_step
 from supply_infra.pipeline.contracts import StepResult
 from supply_infra.pipeline.step_runner import StepExecutionRequest, run_step_subprocess
@@ -102,6 +103,20 @@ class PipelineWorker:
             except Exception:
                 logger.exception("Pipeline heartbeat failed: step=%s", claimed.step_run_id)
 
+    def _runtime_heartbeat_loop(self) -> None:
+        while not self._stop.is_set():
+            try:
+                touch_worker_heartbeat(
+                    owner=self.worker_id,
+                    settings=self.settings,
+                )
+            except Exception:
+                logger.exception(
+                    "Worker runtime heartbeat failed: worker=%s",
+                    self.worker_id,
+                )
+            self._stop.wait(self.settings.pipeline_heartbeat_seconds)
+
     def run_once(self) -> bool:
         claimed = self.claim()
         if claimed is None:
@@ -161,10 +176,18 @@ class PipelineWorker:
         signal.signal(signal.SIGTERM, self.stop)
         signal.signal(signal.SIGINT, self.stop)
         logger.info("Pipeline worker started: worker_id=%s", self.worker_id)
+        runtime_heartbeat = threading.Thread(
+            target=self._runtime_heartbeat_loop,
+            name=f"worker-runtime-heartbeat-{self.worker_id[-8:]}",
+            daemon=True,
+        )
+        runtime_heartbeat.start()
         try:
             while not self._stop.is_set():
                 if not self.run_once():
                     self._stop.wait(self.settings.pipeline_worker_poll_seconds)
         finally:
+            self._stop.set()
+            runtime_heartbeat.join(timeout=2)
             dispose_engine()
             logger.info("Pipeline worker stopped: worker_id=%s", self.worker_id)

+ 41 - 9
supply_infra/scheduler/jobs/demand_pool/belong_rel.py

@@ -53,19 +53,33 @@ def _merge_video_ids(video_lists: list[str | None], limit: int = _VIDEO_LIST_LIM
     return json.dumps(ordered, ensure_ascii=False)
 
 
-def sync_demand_belong_pool_rel() -> dict[str, Any]:
+def _matches_explicit_token(name: str, demand_name: str) -> bool:
+    """只接受上游空格分词的显式词,不把任意子串直接升级为正式关系。"""
+    normalized_name = name.strip()
+    normalized_demand = demand_name.strip()
+    if not normalized_name or not normalized_demand:
+        return False
+    return normalized_name in {
+        token.strip() for token in normalized_demand.split() if token.strip()
+    }
+
+
+def sync_demand_belong_pool_rel(biz_dt: str) -> dict[str, Any]:
     """
     增量建立词 ↔ 需求池匹配边,并更新词级 video_list。
 
-    - name 是 demand_name 子串则建边
-    - 已有边跳过;只插缺失
+    - 只读取 biz_dt 当日需求池,禁止跨日串数
+    - name 必须是 demand_name 的显式空格分词,不接受任意子串
+    - 当日关系先清理再精确重建,源内容修订后不会保留陈旧边
     - video_list:匹配行视频按顺序去重,最多 10 个
     """
-    logger.info("Starting demand_belong_pool_rel sync")
+    if len(str(biz_dt)) != 8 or not str(biz_dt).isdigit():
+        raise ValueError(f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}")
+    logger.info("Starting demand_belong_pool_rel sync: biz_dt=%s", biz_dt)
 
     with get_session() as session:
         words = DemandBelongCategoryRepository(session).list_active_id_name()
-        pool_rows = MultiDemandPoolDiRepository(session).list_id_name_video_lists()
+        pool_rows = MultiDemandPoolDiRepository(session).list_id_name_video_lists(biz_dt)
 
     logger.info("Loaded words=%d pool_rows=%d", len(words), len(pool_rows))
 
@@ -73,7 +87,10 @@ def sync_demand_belong_pool_rel() -> dict[str, Any]:
         result = {
             "words": len(words),
             "pool_rows": len(pool_rows),
+            "biz_dt": biz_dt,
             "matched_edges": 0,
+            "rejected_substring_candidates": 0,
+            "replaced_edges": 0,
             "inserted": 0,
             "video_updated": 0,
         }
@@ -83,14 +100,17 @@ def sync_demand_belong_pool_rel() -> dict[str, Any]:
     candidate_pairs: list[tuple[int, int]] = []
     video_updates: dict[int, str | None] = {}
     matched_edges = 0
+    rejected_substring_candidates = 0
 
     for belong_id, name in words:
         matched_pool_ids: list[int] = []
         matched_video_lists: list[str | None] = []
         for pool_id, demand_name, video_list in pool_rows:
-            if name in demand_name:
+            if _matches_explicit_token(name, demand_name):
                 matched_pool_ids.append(pool_id)
                 matched_video_lists.append(video_list)
+            elif name and name in demand_name:
+                rejected_substring_candidates += 1
 
         if not matched_pool_ids:
             video_updates[belong_id] = None
@@ -103,14 +123,24 @@ def sync_demand_belong_pool_rel() -> dict[str, Any]:
 
     with get_session() as session:
         rel_repo = DemandBelongPoolRelRepository(session)
-        existing = rel_repo.get_existing_pairs(candidate_pairs)
+        replaced_edges = rel_repo.delete_by_pool_ids(
+            pool_id for pool_id, _, _ in pool_rows
+        )
         insert_rows = [
             {
                 "demand_belong_category_id": belong_id,
                 "multi_demand_pool_di_id": pool_id,
+                "biz_dt": biz_dt,
+                "relation_type": "explicit_token",
+                "relation_source": "demand_pool_name_tokens",
+                "reason": "需求归属词完整命中上游 demand_name 的显式空格分词",
+                "confidence": 1.0,
+                "is_inferred": True,
+                "status": "active",
+                "valid_from_biz_dt": biz_dt,
+                "valid_to_biz_dt": None,
             }
             for belong_id, pool_id in candidate_pairs
-            if (belong_id, pool_id) not in existing
         ]
         inserted = rel_repo.bulk_insert_ignore(insert_rows)
         video_updated = DemandBelongCategoryRepository(session).update_video_lists(
@@ -118,11 +148,13 @@ def sync_demand_belong_pool_rel() -> dict[str, Any]:
         )
 
     result = {
+        "biz_dt": biz_dt,
         "words": len(words),
         "pool_rows": len(pool_rows),
         "matched_edges": matched_edges,
         "candidate_pairs": len(candidate_pairs),
-        "existing_pairs": len(existing),
+        "rejected_substring_candidates": rejected_substring_candidates,
+        "replaced_edges": replaced_edges,
         "inserted": inserted,
         "video_updated": video_updated,
     }

+ 164 - 60
supply_infra/scheduler/jobs/demand_pool/sync.py

@@ -11,26 +11,37 @@ Related pipeline stages live in sibling modules (belong_rel / tree_weight / vide
 """
 from __future__ import annotations
 
+import hashlib
 import json
 import logging
+from concurrent.futures import ThreadPoolExecutor
 from datetime import datetime, timedelta
 from decimal import Decimal
 from typing import Any
 
-from agents.demand_belong_category_agent.run import main as classify_demand_words
+from supply_infra.category_match import (
+    CategoryMatchClient,
+    get_category_match_client,
+)
+from supply_infra.config import get_infra_settings
 from supply_infra.db.repositories.demand_belong_category_repo import (
     DemandBelongCategoryRepository,
 )
+from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
+    DemandBelongPoolRelRepository,
+)
 from supply_infra.db.repositories.demand_popularity_stats_repo import (
     DemandPopularityStatsRepository,
 )
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
 from supply_infra.db.session import get_session
 from supply_infra.odps.client import get_odps_client
 
 logger = logging.getLogger(__name__)
 
-_WORD_BATCH_SIZE = 50
 _STATS_UPSERT_BATCH = 200
 _REAL_METRIC_LIMIT = 1000
 _REAL_METRIC_LOOKBACK_DAYS = 7
@@ -65,6 +76,40 @@ def _row_key(row: dict[str, Any]) -> RowKey:
     return (row["strategy"], row["demand_id"])
 
 
+_SOURCE_COMPARE_FIELDS = (
+    "strategy",
+    "demand_id",
+    "demand_name",
+    "weight",
+    "type",
+    "video_count",
+    "video_list",
+    "extend",
+    "biz_dt",
+)
+
+
+def _source_signature(row: dict[str, Any]) -> tuple[Any, ...]:
+    """生成稳定的源字段比较值;排除数据库 id 和下游回填字段。"""
+    return tuple(row.get(field) for field in _SOURCE_COMPARE_FIELDS)
+
+
+def _source_snapshot_hash(rows: list[dict[str, Any]]) -> str:
+    """生成与行顺序无关的内容哈希,供运行审计和水位比较。"""
+    canonical = [
+        {field: row.get(field) for field in _SOURCE_COMPARE_FIELDS}
+        for row in sorted(rows, key=_row_key)
+    ]
+    payload = json.dumps(
+        canonical,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    ).encode("utf-8")
+    return hashlib.sha256(payload).hexdigest()
+
+
 def _normalize_video_list(raw: Any) -> tuple[str | None, int]:
     """取前 N 个 video_id,返回 (JSON 文本, count),二者保持一致。"""
     if raw is None:
@@ -147,91 +192,169 @@ def _build_word_set(demand_names: list[str]) -> set[str]:
     return words
 
 
-def _chunked(items: list[str], size: int) -> list[list[str]]:
-    return [items[i : i + size] for i in range(0, len(items), size)]
-
-
-def _classify_words(biz_dt: str) -> dict:
-    """查询 demand_name → 空格分词入 set → 过滤已有词 → 100 词一批调用 agent。"""
+def _classify_words(
+    biz_dt: str,
+    *,
+    client: CategoryMatchClient | None = None,
+    workers: int | None = None,
+) -> dict:
+    """逐词调用分类路径接口,将 stable_id 映射到本地分类后写入。"""
     with get_session() as session:
-        demand_names = MultiDemandPoolDiRepository(session).list_demand_names_by_biz_dt(biz_dt)
+        demand_names = MultiDemandPoolDiRepository(
+            session
+        ).list_demand_names_by_biz_dt(biz_dt)
         word_set = _build_word_set(demand_names)
         existing = DemandBelongCategoryRepository(session).get_existing_names(word_set)
         pending = word_set - existing
-
-    word_list = list(pending)
-    batches = _chunked(word_list, _WORD_BATCH_SIZE)
+        source_id_to_mysql_id = GlobalTreeCategoryRepository(
+            session
+        ).get_active_source_id_map()
+
+    word_list = sorted(pending)
+    matcher = client or get_category_match_client()
+    worker_count = min(
+        max(1, workers or get_infra_settings().category_match_workers),
+        max(1, len(word_list)),
+    )
 
     logger.info(
-        "Classify prepare: demand_names=%d words=%d existing=%d pending=%d batches=%d",
+        "Category match prepare: demand_names=%d words=%d existing=%d "
+        "pending=%d workers=%d",
         len(demand_names),
         len(word_set),
         len(existing),
         len(pending),
-        len(batches),
+        worker_count,
     )
 
-    failed_batches: list[dict[str, Any]] = []
-    for idx, batch in enumerate(batches, start=1):
-        logger.info("Classifying batch %d/%d (%d words)", idx, len(batches), len(batch))
+    rows: list[dict[str, Any]] = []
+    unmatched_terms: list[str] = []
+    failed_items: list[dict[str, Any]] = []
+
+    def match_term(item: tuple[int, str]) -> tuple[str, Any, Exception | None]:
+        idx, term = item
+        logger.info("Matching category %d/%d: %s", idx, len(word_list), term)
         try:
-            classify_demand_words(batch)
+            return term, matcher.match_one(term, description=""), None
         except Exception as exc:
             logger.exception(
-                "Demand belong classification batch failed; continue remaining batches: "
-                "biz_dt=%s batch=%s/%s",
+                "Category match failed; continue remaining items: "
+                "biz_dt=%s item=%s/%s term=%s",
                 biz_dt,
                 idx,
-                len(batches),
+                len(word_list),
+                term,
             )
-            failed_batches.append(
+            return term, None, exc
+
+    indexed_terms = list(enumerate(word_list, start=1))
+    with ThreadPoolExecutor(
+        max_workers=worker_count,
+        thread_name_prefix="category-match",
+    ) as executor:
+        outcomes = list(executor.map(match_term, indexed_terms))
+
+    for term, match, error_value in outcomes:
+        if error_value is not None:
+            failed_items.append(
                 {
-                    "batch": idx,
-                    "size": len(batch),
-                    "error": str(exc),
+                    "term": term,
+                    "error": str(error_value),
                 }
             )
+            continue
+        if match is None:
+            unmatched_terms.append(term)
+            continue
 
-    return {
-        "success": not failed_batches,
+        category_id = source_id_to_mysql_id.get(match.stable_id)
+        if category_id is None:
+            error = (
+                f"matched stable_id={match.stable_id} is absent from "
+                "global_tree_category"
+            )
+            logger.error("Category match cannot be persisted: term=%s %s", term, error)
+            failed_items.append({"term": term, "error": error})
+            continue
+        rows.append(
+            {
+                "name": term,
+                "category_id": category_id,
+                "reason": match.reason,
+                "is_delete": 0,
+            }
+        )
+
+    with get_session() as session:
+        persisted = DemandBelongCategoryRepository(
+            session
+        ).upsert_category_matches(rows)
+
+    result = {
+        "success": not failed_items,
         "demand_names": len(demand_names),
         "words": len(word_set),
         "existing_filtered": len(existing),
         "pending": len(pending),
-        "batches": len(batches),
-        "failed_batches": failed_batches,
+        "api_calls": len(word_list),
+        "matched": len(rows),
+        "persisted": persisted,
+        "unmatched": len(unmatched_terms),
+        "unmatched_terms": unmatched_terms,
+        "failed_items": failed_items,
     }
+    if failed_items:
+        result["error_code"] = "category_match_failed_items"
+        result["error"] = f"{len(failed_items)} category match item(s) failed"
+    return result
 
 
 def _sync_diff(partition_date: str) -> dict[str, Any]:
-    """行数不同时拉取 ODPS,只同步 (strategy, demand_id) 差异,并回填已有行的 video/weight 字段。"""
+    """按主键和完整源内容同步;行数相同也会识别修订。"""
     odps = get_odps_client()
     raw_rows = odps.fetch_multi_demand_pool(partition_date)
     mysql_rows = _to_mysql_rows(raw_rows, partition_date)
-    odps_keys = {_row_key(r) for r in mysql_rows}
     odps_by_key = {_row_key(r): r for r in mysql_rows}
+    odps_keys = set(odps_by_key)
 
     with get_session() as session:
         repo = MultiDemandPoolDiRepository(session)
-        mysql_keys = repo.list_keys_by_biz_dt(partition_date)
+        existing_rows = repo.list_source_rows_by_biz_dt(partition_date)
+        mysql_by_key = {_row_key(row): row for row in existing_rows}
+        mysql_keys = set(mysql_by_key)
 
         to_insert_keys = odps_keys - mysql_keys
         to_delete_keys = mysql_keys - odps_keys
-        to_update_keys = odps_keys & mysql_keys
+        shared_keys = odps_keys & mysql_keys
+        to_update_keys = {
+            key
+            for key in shared_keys
+            if _source_signature(odps_by_key[key])
+            != _source_signature(mysql_by_key[key])
+        }
 
         insert_rows = [odps_by_key[k] for k in to_insert_keys]
         update_rows = [odps_by_key[k] for k in to_update_keys]
+        deleted_pool_ids = [mysql_by_key[key]["id"] for key in to_delete_keys]
+        deleted_relations = DemandBelongPoolRelRepository(session).delete_by_pool_ids(
+            deleted_pool_ids
+        )
         deleted = repo.delete_by_keys(partition_date, list(to_delete_keys))
         inserted = repo.bulk_insert(insert_rows)
-        updated = repo.update_video_fields(partition_date, update_rows)
+        updated = (
+            repo.update_source_fields(partition_date, update_rows)
+            if update_rows
+            else 0
+        )
 
     logger.info(
-        "Diff sync: odps=%d mysql_before=%d insert=%d delete=%d video_update=%d",
+        "Diff sync: odps=%d mysql_before=%d insert=%d delete=%d update=%d unchanged=%d",
         len(odps_keys),
         len(mysql_keys),
         inserted,
         deleted,
         updated,
+        len(shared_keys) - len(to_update_keys),
     )
     return {
         "fetched": len(raw_rows),
@@ -239,7 +362,10 @@ def _sync_diff(partition_date: str) -> dict[str, Any]:
         "mysql_before": len(mysql_keys),
         "inserted": inserted,
         "deleted": deleted,
-        "video_updated": updated,
+        "relations_deleted": deleted_relations,
+        "updated": updated,
+        "unchanged": len(shared_keys) - len(to_update_keys),
+        "source_snapshot_hash": _source_snapshot_hash(mysql_rows),
         "skipped_invalid": len(raw_rows) - len(mysql_rows),
     }
 
@@ -467,27 +593,5 @@ def compute_popularity_stats(biz_dt: str) -> dict[str, Any]:
 
 
 def _sync_pool_rows(partition_date: str) -> dict[str, Any]:
-    """同步当天需求池主数据;供完整任务按独立阶段捕获异常。"""
-    odps = get_odps_client()
-    odps_count = odps.count_multi_demand_pool(partition_date)
-    with get_session() as session:
-        mysql_count = MultiDemandPoolDiRepository(session).count_by_biz_dt(partition_date)
-
-    logger.info("Count check: odps=%d mysql=%d", odps_count, mysql_count)
-
-    if odps_count == mysql_count:
-        logger.info("Same count, skip ODPS data sync")
-        return {
-            "skipped_same_count": True,
-            "odps_count": odps_count,
-            "mysql_count": mysql_count,
-            "fetched": 0,
-            "inserted": 0,
-            "deleted": 0,
-        }
-    return {
-        "skipped_same_count": False,
-        "odps_count": odps_count,
-        "mysql_count": mysql_count,
-        **_sync_diff(partition_date),
-    }
+    """同步当天需求池主数据;内容哈希差分替代不可靠的行数门禁。"""
+    return _sync_diff(partition_date)

+ 6 - 1
supply_infra/scheduler/jobs/discover_videos_from_demands.py

@@ -133,6 +133,7 @@ def discover_videos_from_demands(
     top_limit: int | None = None,
     skip_finished: bool = True,
     force: bool = False,
+    package_run_id: str | None = None,
 ) -> dict[str, Any]:
     """
     对指定业务日全部 S/A 级需求(有拓展点位)逐条调用 find_agent。
@@ -153,9 +154,12 @@ def discover_videos_from_demands(
     ensure_mysql_pool_capacity(1)
 
     try:
+        context_kwargs: dict[str, Any] = {"top_limit": top_limit}
+        if package_run_id is not None:
+            context_kwargs["package_run_id"] = package_run_id
         resolved_biz_dt, contexts = list_find_demand_contexts(
             biz_dt,
-            top_limit=top_limit,
+            **context_kwargs,
         )
     except Exception as exc:
         logger.exception("discover_videos_from_demands preflight failed")
@@ -202,6 +206,7 @@ def discover_videos_from_demands(
         "started_at": started_at.isoformat(),
         "offset": int(offset),
         "top_limit": top_limit,
+        "package_run_id": package_run_id,
         **preload_stats,
         "total_records": len(contexts),
         "workers": 0,

+ 159 - 84
supply_infra/scheduler/jobs/publish_videos_from_discovery.py

@@ -1,10 +1,10 @@
 from __future__ import annotations
 
+import hashlib
+import json
 import logging
 from dataclasses import dataclass
-from datetime import datetime
 from typing import Any
-from zoneinfo import ZoneInfo
 
 from supply_infra.aigc.client import AigcClient, MAX_VIDEOS_PER_CRAWLER_PLAN
 from supply_infra.aigc.plan_map import (
@@ -14,7 +14,6 @@ from supply_infra.aigc.plan_map import (
     distribute_evenly,
     list_unique_plan_pairs,
 )
-from supply_infra.config import get_infra_settings
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
 from supply_infra.db.session import get_session
 from supply_infra.services.video_discovery_service import (
@@ -57,6 +56,32 @@ class PublishBatchResult:
         }
 
 
+@dataclass(frozen=True)
+class PublishBatchPlan:
+    biz_dt: str | None
+    source_run_id: str | None
+    plan_label: str
+    produce_plan_id: str
+    publish_plan_id: str
+    aweme_ids: list[str]
+    candidate_ids: list[int]
+    plan_name: str
+    batch_key: str
+
+    def to_payload(self) -> dict[str, Any]:
+        return {
+            "biz_dt": self.biz_dt,
+            "source_run_id": self.source_run_id,
+            "plan_label": self.plan_label,
+            "produce_plan_id": self.produce_plan_id,
+            "publish_plan_id": self.publish_plan_id,
+            "aweme_ids": self.aweme_ids,
+            "candidate_ids": self.candidate_ids,
+            "plan_name": self.plan_name,
+            "batch_key": self.batch_key,
+        }
+
+
 def _resolve_biz_dt(biz_dt: str | None) -> str | None:
     if biz_dt:
         text = str(biz_dt).strip()
@@ -81,19 +106,14 @@ def _group_candidates_by_plan(
     return grouped
 
 
-def publish_videos_from_discovery(
+def prepare_publish_batches(
     *,
     biz_dt: str | None = None,
     run_id: str | None = None,
     skip_published: bool = True,
     limit: int | None = None,
 ) -> dict[str, Any]:
-    """
-    从 video_discovery_candidate 读取视频,按轮询均匀分配到各 AIGC 计划对。
-
-    仅处理 decision_bucket 为 primary 且 aweme_id 非空的候选,不区分品类。
-    """
-    settings = get_infra_settings()
+    """Build deterministic AIGC requests without making external calls."""
     resolved_biz_dt = _resolve_biz_dt(biz_dt)
     plan_pairs = list_unique_plan_pairs()
     if not plan_pairs:
@@ -105,101 +125,156 @@ def publish_videos_from_discovery(
         skip_published=skip_published,
         limit=limit,
     )
+    grouped = _group_candidates_by_plan(candidates, plan_pairs)
+    distribution_preview = assignment_summary(
+        [plan for plan, _ in grouped],
+        [bucket for _, bucket in grouped],
+    )
+
+    plans: list[PublishBatchPlan] = []
+    for plan_pair, plan_candidates in grouped:
+        for candidate_batch in chunk_list(
+            plan_candidates,
+            MAX_VIDEOS_PER_CRAWLER_PLAN,
+        ):
+            aweme_ids = [str(item.aweme_id) for item in candidate_batch]
+            candidate_ids = [int(item.id) for item in candidate_batch]
+            identity = {
+                "biz_dt": resolved_biz_dt,
+                "plan_label": plan_pair.label,
+                "produce_plan_id": plan_pair.produce_plan_id,
+                "publish_plan_id": plan_pair.publish_plan_id,
+                "candidate_ids": candidate_ids,
+                "aweme_ids": aweme_ids,
+            }
+            canonical = json.dumps(
+                identity,
+                ensure_ascii=False,
+                sort_keys=True,
+                separators=(",", ":"),
+            )
+            batch_key = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+            plan_name = (
+                f"【SupplyAgent】{plan_pair.label}-"
+                f"{resolved_biz_dt or 'manual'}-{batch_key[:12]}"
+            )
+            plans.append(
+                PublishBatchPlan(
+                    biz_dt=resolved_biz_dt,
+                    source_run_id=run_id,
+                    plan_label=plan_pair.label,
+                    produce_plan_id=plan_pair.produce_plan_id,
+                    publish_plan_id=plan_pair.publish_plan_id,
+                    aweme_ids=aweme_ids,
+                    candidate_ids=candidate_ids,
+                    plan_name=plan_name,
+                    batch_key=batch_key,
+                )
+            )
 
-    if not candidates:
+    return {
+        "biz_dt": resolved_biz_dt,
+        "run_id": run_id,
+        "plan_count": len(plan_pairs),
+        "candidate_count": len(candidates),
+        "decision_buckets": list(_PUBLISHABLE_BUCKETS),
+        "distribution": distribution_preview,
+        "batches": [plan.to_payload() for plan in plans],
+    }
+
+
+def publish_videos_from_discovery(
+    *,
+    biz_dt: str | None = None,
+    run_id: str | None = None,
+    skip_published: bool = True,
+    limit: int | None = None,
+) -> dict[str, Any]:
+    """
+    从 video_discovery_candidate 读取视频,按轮询均匀分配到各 AIGC 计划对。
+
+    仅处理 decision_bucket 为 primary 且 aweme_id 非空的候选,不区分品类。
+    """
+    prepared = prepare_publish_batches(
+        biz_dt=biz_dt,
+        run_id=run_id,
+        skip_published=skip_published,
+        limit=limit,
+    )
+    batch_plans = list(prepared["batches"])
+    if not batch_plans:
         return {
             "success": True,
             "message": "没有待发布的候选视频",
-            "biz_dt": resolved_biz_dt,
+            **prepared,
             "run_id": run_id,
-            "plan_count": len(plan_pairs),
-            "candidate_count": 0,
-            "decision_buckets": list(_PUBLISHABLE_BUCKETS),
             "batches": [],
         }
 
-    grouped = _group_candidates_by_plan(candidates, plan_pairs)
-    distribution_preview = assignment_summary(
-        [plan for plan, _ in grouped],
-        [bucket for _, bucket in grouped],
-    )
-
     client = AigcClient()
-    timezone = ZoneInfo(settings.scheduler_timezone)
-    timestamp = datetime.now(timezone).strftime("%Y%m%d%H%M%S")
     batch_results: list[PublishBatchResult] = []
 
-    for plan_pair, plan_candidates in grouped:
-        candidate_batches = chunk_list(plan_candidates, MAX_VIDEOS_PER_CRAWLER_PLAN)
-        for batch_index, candidate_batch in enumerate(candidate_batches, start=1):
-            aweme_batch = [str(item.aweme_id) for item in candidate_batch]
-            id_batch = [int(item.id) for item in candidate_batch]
-            plan_name = (
-                f"【SupplyAgent】{plan_pair.label}-均匀分发-"
-                f"{timestamp}-批次{batch_index}"
-            )
-            create_result = client.create_video_crawler_plan(
-                aweme_batch,
-                plan_name=plan_name,
-            )
-            batch = PublishBatchResult(
-                plan_label=plan_pair.label,
-                produce_plan_id=plan_pair.produce_plan_id,
-                publish_plan_id=plan_pair.publish_plan_id,
-                aweme_ids=aweme_batch,
-                candidate_ids=id_batch,
-            )
+    for batch_plan in batch_plans:
+        aweme_batch = list(batch_plan["aweme_ids"])
+        id_batch = [int(value) for value in batch_plan["candidate_ids"]]
+        plan_name = str(batch_plan["plan_name"])
+        create_result = client.create_video_crawler_plan(
+            aweme_batch,
+            plan_name=plan_name,
+        )
+        batch = PublishBatchResult(
+            plan_label=str(batch_plan["plan_label"]),
+            produce_plan_id=str(batch_plan["produce_plan_id"]),
+            publish_plan_id=str(batch_plan["publish_plan_id"]),
+            aweme_ids=aweme_batch,
+            candidate_ids=id_batch,
+        )
 
-            if not create_result.get("success"):
-                batch.error = str(create_result.get("error") or "创建爬取计划失败")
-                batch_results.append(batch)
-                continue
+        if not create_result.get("success"):
+            batch.error = str(create_result.get("error") or "创建爬取计划失败")
+            batch_results.append(batch)
+            continue
 
-            crawler_plan_id = str(create_result.get("crawler_plan_id") or "")
-            crawler_plan_name = str(create_result.get("crawler_plan_name") or plan_name)
-            batch.crawler_plan_id = crawler_plan_id
-            batch.crawler_plan_name = crawler_plan_name
+        crawler_plan_id = str(create_result.get("crawler_plan_id") or "")
+        crawler_plan_name = str(create_result.get("crawler_plan_name") or plan_name)
+        batch.crawler_plan_id = crawler_plan_id
+        batch.crawler_plan_name = crawler_plan_name
 
-            bind_result = client.bind_crawler_to_produce_plan(
+        bind_result = client.bind_crawler_to_produce_plan(
+            crawler_plan_id,
+            str(batch_plan["produce_plan_id"]),
+            crawler_plan_name=crawler_plan_name,
+        )
+        batch.bind_success = bool(bind_result.get("success"))
+        if not batch.bind_success:
+            batch.bind_error = str(bind_result.get("error") or "绑定生成计划失败")
+
+        if crawler_plan_id:
+            plan_label = str(batch_plan["plan_label"])
+            if not batch.bind_success:
+                plan_label = f"BIND_FAILED:{batch.bind_error}"
+            updated = get_video_discovery_service().mark_candidates_aigc_plans(
+                id_batch,
+                crawler_plan_id=crawler_plan_id,
+                produce_plan_id=str(batch_plan["produce_plan_id"]),
+                publish_plan_id=str(batch_plan["publish_plan_id"]),
+                plan_label=plan_label,
+            )
+            logger.info(
+                "published batch: plan=%s crawler=%s videos=%d db_updated=%d bind_success=%s",
+                batch_plan["plan_label"],
                 crawler_plan_id,
-                plan_pair.produce_plan_id,
-                crawler_plan_name=crawler_plan_name,
+                len(aweme_batch),
+                updated,
+                batch.bind_success,
             )
-            batch.bind_success = bool(bind_result.get("success"))
-            if not batch.bind_success:
-                batch.bind_error = str(bind_result.get("error") or "绑定生成计划失败")
-
-            if crawler_plan_id:
-                plan_label = plan_pair.label
-                if not batch.bind_success:
-                    plan_label = f"BIND_FAILED:{batch.bind_error}"
-                updated = get_video_discovery_service().mark_candidates_aigc_plans(
-                    id_batch,
-                    crawler_plan_id=crawler_plan_id,
-                    produce_plan_id=plan_pair.produce_plan_id,
-                    publish_plan_id=plan_pair.publish_plan_id,
-                    plan_label=plan_label,
-                )
-                logger.info(
-                    "published batch: plan=%s crawler=%s videos=%d db_updated=%d bind_success=%s",
-                    plan_pair.label,
-                    crawler_plan_id,
-                    len(aweme_batch),
-                    updated,
-                    batch.bind_success,
-                )
 
-            batch_results.append(batch)
+        batch_results.append(batch)
 
     failed_batches = [item for item in batch_results if item.error or not item.bind_success]
     return {
         "success": not failed_batches,
-        "biz_dt": resolved_biz_dt,
-        "run_id": run_id,
-        "plan_count": len(plan_pairs),
-        "candidate_count": len(candidates),
-        "decision_buckets": list(_PUBLISHABLE_BUCKETS),
-        "distribution": distribution_preview,
+        **{key: value for key, value in prepared.items() if key != "batches"},
         "batch_count": len(batch_results),
         "failed_batch_count": len(failed_batches),
         "batches": [item.to_dict() for item in batch_results],

+ 133 - 190
supply_infra/scheduler/jobs/sync_global_tree_odps_to_mysql.py

@@ -1,13 +1,8 @@
 """
-定时任务:从 ODPS 同步全局树元素与分类到 MySQL。
+定时任务:从 ODPS global_category 同步有效“实质”分类树到 MySQL。
 
-流程:
-1. 拉取 pattern_mining_element(name, category_id)
-2. 拉取 public_pattern_mining_category 全量分类
-3. 筛选元素关联的分类,并沿 parent_id 向上追溯至顶层
-4. 查询 MySQL 已有分类,按 source_id 去重(仅插入新增)
-5. 为新分类分配 MySQL id/parent_id,INSERT IGNORE 写入(历史分类不变)
-6. 重载 source_id 映射,将元素写入 global_tree_element(INSERT IGNORE 去重)
+global_tree_element 已退出该流程。分类树直接以 stable_id /
+parent_stable_id 建立层级,本地表继续保留稳定的 MySQL id 供现有下游引用。
 """
 from __future__ import annotations
 
@@ -15,177 +10,132 @@ import logging
 from datetime import datetime, timedelta
 from typing import Any
 
-from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
-from supply_infra.db.repositories.global_tree_element_repo import GlobalTreeElementRepository
+from supply_infra.config import get_infra_settings
+from supply_infra.db.repositories.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
 from supply_infra.db.session import get_session
 from supply_infra.odps.client import get_odps_client
 
 logger = logging.getLogger(__name__)
 
 
-def _collect_categories_with_ancestors(
-    seed_ids: set[int],
-    category_by_id: dict[int, dict[str, Any]],
+def _to_category_source_rows(
+    categories: list[dict[str, Any]],
 ) -> list[dict[str, Any]]:
-    """从种子 category_id 出发,沿 parent_id 向上追溯,收集完整祖先链。"""
-    collected: dict[int, dict[str, Any]] = {}
-    pending = set(seed_ids)
-
-    while pending:
-        cat_id = pending.pop()
-        if cat_id in collected:
-            continue
-
-        category = category_by_id.get(cat_id)
-        if category is None:
-            continue
-
-        collected[cat_id] = category
-        parent_id = category.get("parent_id")
-        if parent_id is not None and int(parent_id) != 0 and int(parent_id) not in collected:
-            pending.add(int(parent_id))
-
-    return list(collected.values())
-
-
-def _dedupe_category_source_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
-    """按 source_id 去重,保留最后一条(同日 ODPS 数据可能重复)。"""
+    """规范 stable_id 字段并按 stable_id 去重,保留最后一条。"""
     by_source_id: dict[int, dict[str, Any]] = {}
-    for row in rows:
-        by_source_id[int(row["source_id"])] = row
-    return list(by_source_id.values())
-
-
-def _dedupe_element_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
-    """按 (name, category_id) 去重,与表唯一键一致。"""
-    seen: set[tuple[str, int]] = set()
-    result: list[dict[str, Any]] = []
-    for row in rows:
-        key = (row["name"], row["category_id"])
-        if key in seen:
+    for category in categories:
+        stable_id = category.get("stable_id")
+        name = category.get("name")
+        if stable_id is None or name is None or not str(name).strip():
+            logger.warning("Skip global category with missing stable_id/name: %s", category)
             continue
-        seen.add(key)
-        result.append(row)
-    return result
-
-
-def _to_category_source_rows(categories: list[dict[str, Any]]) -> list[dict[str, Any]]:
-    """将 ODPS 分类转为含 source_id / source_parent_id 的中间结构。"""
-    rows: list[dict[str, Any]] = []
-    for cat in categories:
-        parent_id = cat.get("parent_id")
-        rows.append(
-            {
-                "source_id": int(cat["id"]),
-                "source_parent_id": int(parent_id) if parent_id is not None else 0,
-                "name": str(cat["name"]) if cat.get("name") is not None else None,
-                "description": cat.get("description"),
-                "level": int(cat["level"]) if cat.get("level") is not None else None,
-            }
-        )
-    return rows
+        parent_stable_id = category.get("parent_stable_id")
+        source_id = int(stable_id)
+        by_source_id[source_id] = {
+            "source_id": source_id,
+            "source_parent_id": (
+                int(parent_stable_id)
+                if parent_stable_id is not None and int(parent_stable_id) != 0
+                else 0
+            ),
+            "name": str(name).strip(),
+            "description": (
+                str(category["description"])
+                if category.get("description") is not None
+                else None
+            ),
+            "level": (
+                int(category["level"])
+                if category.get("level") is not None
+                else None
+            ),
+        }
+    return sorted(
+        by_source_id.values(),
+        key=lambda row: (row.get("level") or 0, int(row["source_id"])),
+    )
 
 
-def _prepare_new_category_rows(
+def _allocate_local_ids(
     categories: list[dict[str, Any]],
     source_id_to_mysql_id: dict[int, int],
     next_id: int,
-) -> tuple[list[dict[str, Any]], dict[int, int], int]:
-    """
-    过滤已存在 source_id,多轮扫描为新分类分配 MySQL id 与 parent_id。
-
-    按 level 初排后多轮插入:仅当父节点已在映射中(库内已有或本轮已分配)才写入,
-    避免同批次内因 level 数据异常导致子节点被误跳过。
-
-    Returns:
-        (待插入行, 更新后的 source_id → mysql_id 映射, 下一可用 id)
-    """
-    existing_source_ids = set(source_id_to_mysql_id.keys())
-    pending = [c for c in categories if int(c["source_id"]) not in existing_source_ids]
-    pending.sort(key=lambda c: (c.get("level") or 0, int(c["source_id"])))
-
+) -> tuple[dict[int, int], set[int]]:
+    """为新 stable_id 分配本地 id;缺少父节点的行不会进入正式树。"""
     updated_map = dict(source_id_to_mysql_id)
-    rows: list[dict[str, Any]] = []
+    pending = [
+        category
+        for category in categories
+        if int(category["source_id"]) not in updated_map
+    ]
+    failed: set[int] = set()
 
     while pending:
-        still_pending: list[dict[str, Any]] = []
+        next_pending: list[dict[str, Any]] = []
         progress = False
-
-        for cat in pending:
-            source_id = int(cat["source_id"])
-            source_parent_id = int(cat["source_parent_id"])
-
+        for category in pending:
+            source_id = int(category["source_id"])
+            source_parent_id = int(category["source_parent_id"])
             if source_parent_id != 0 and source_parent_id not in updated_map:
-                still_pending.append(cat)
+                next_pending.append(category)
                 continue
-
-            mysql_parent_id: int | None = None
-            if source_parent_id != 0:
-                mysql_parent_id = updated_map[source_parent_id]
-
             updated_map[source_id] = next_id
-            rows.append(
-                {
-                    "id": next_id,
-                    "name": cat["name"],
-                    "description": cat.get("description"),
-                    "level": cat.get("level"),
-                    "parent_id": mysql_parent_id,
-                    "source_id": source_id,
-                    "source_parent_id": source_parent_id,
-                }
-            )
             next_id += 1
             progress = True
 
-        if not progress and still_pending:
-            for cat in still_pending:
+        if not progress:
+            failed.update(int(category["source_id"]) for category in next_pending)
+            for category in next_pending:
                 logger.warning(
-                    "Parent source_id=%s not found for category source_id=%s, skip",
-                    cat["source_parent_id"],
-                    cat["source_id"],
+                    "Parent stable_id=%s not found for stable_id=%s, skip",
+                    category["source_parent_id"],
+                    category["source_id"],
                 )
             break
+        pending = next_pending
 
-        pending = still_pending
+    return updated_map, failed
 
-    return rows, updated_map, next_id
 
-
-def _to_element_rows(
-    elements: list[dict[str, Any]],
+def _to_mysql_rows(
+    categories: list[dict[str, Any]],
     source_id_to_mysql_id: dict[int, int],
+    failed_source_ids: set[int],
 ) -> list[dict[str, Any]]:
-    """将 ODPS 元素的 hive category_id 映射为 MySQL 分类 id。"""
     rows: list[dict[str, Any]] = []
-    for row in elements:
-        if row.get("name") is None or row.get("category_id") is None:
+    for category in categories:
+        source_id = int(category["source_id"])
+        if source_id in failed_source_ids:
             continue
-
-        source_category_id = int(row["category_id"])
-        mysql_category_id = source_id_to_mysql_id.get(source_category_id)
-        if mysql_category_id is None:
-            logger.warning(
-                "Category source_id=%s not found for element %s, skip",
-                source_category_id,
-                row["name"],
-            )
+        source_parent_id = int(category["source_parent_id"])
+        parent_id = (
+            source_id_to_mysql_id.get(source_parent_id)
+            if source_parent_id != 0
+            else None
+        )
+        if source_parent_id != 0 and parent_id is None:
+            failed_source_ids.add(source_id)
             continue
-
         rows.append(
             {
-                "name": str(row["name"]),
-                "category_id": mysql_category_id,
-                "source_category_id": source_category_id,
+                "id": source_id_to_mysql_id[source_id],
+                "name": category["name"],
+                "description": category.get("description"),
+                "level": category.get("level"),
+                "parent_id": parent_id,
+                "source_id": source_id,
+                "source_parent_id": source_parent_id,
+                "is_delete": 0,
             }
         )
-
     return rows
 
 
 def sync_global_tree_odps_to_mysql(partition_date: str | None = None) -> dict:
     """
-    从 ODPS 拉取全局树元素与分类,写入 MySQL。
+    从 global_category 拉取完整有效分类树并同步到 MySQL。
 
     Args:
         partition_date: 分区日期 (YYYYMMDD),默认昨天
@@ -193,72 +143,65 @@ def sync_global_tree_odps_to_mysql(partition_date: str | None = None) -> dict:
     if partition_date is None:
         partition_date = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
 
-    logger.info("Starting global tree ODPS → MySQL sync for partition: %s", partition_date)
-
-    odps = get_odps_client()
-    raw_elements = odps.fetch_pattern_mining_elements(partition_date)
-    raw_categories = odps.fetch_pattern_mining_categories(partition_date)
-    logger.info(
-        "Fetched %d elements and %d categories from ODPS",
-        len(raw_elements),
-        len(raw_categories),
-    )
-
-    category_by_id = {int(c["id"]): c for c in raw_categories if c.get("id") is not None}
-
-    seed_ids = {
-        int(row["category_id"])
-        for row in raw_elements
-        if row.get("category_id") is not None
-    }
-    categories = _collect_categories_with_ancestors(seed_ids, category_by_id)
-    category_source_rows = _dedupe_category_source_rows(_to_category_source_rows(categories))
-
     logger.info(
-        "Resolved %d elements and %d categories (with ancestors) for sync",
-        len(raw_elements),
-        len(category_source_rows),
+        "Starting global_category ODPS → MySQL sync for partition: %s",
+        partition_date,
     )
+    raw_categories = get_odps_client().fetch_global_categories(partition_date)
+    categories = _to_category_source_rows(raw_categories)
+    if not categories:
+        raise RuntimeError(
+            f"global_category returned no active categories for dt={partition_date}"
+        )
 
     with get_session() as session:
-        element_repo = GlobalTreeElementRepository(session)
-        category_repo = GlobalTreeCategoryRepository(session)
-
-        source_id_to_mysql_id = category_repo.get_source_id_map()
-        logger.info("Loaded %d existing categories from MySQL", len(source_id_to_mysql_id))
-
-        existing_source_ids = set(source_id_to_mysql_id.keys())
-        categories_already_exist = sum(
-            1 for c in category_source_rows if int(c["source_id"]) in existing_source_ids
+        repository = GlobalTreeCategoryRepository(session)
+        existing_active_count = repository.count_active()
+        minimum_retained = int(
+            existing_active_count
+            * get_infra_settings().global_category_min_retention_ratio
         )
-
-        next_id = category_repo.get_max_id() + 1
-        new_category_rows, _, _ = _prepare_new_category_rows(
-            category_source_rows,
+        if existing_active_count and len(categories) < minimum_retained:
+            raise RuntimeError(
+                "global_category active row count dropped below safety threshold: "
+                f"current={existing_active_count} incoming={len(categories)} "
+                f"minimum={minimum_retained}"
+            )
+        existing_map = repository.get_source_id_map()
+        source_id_to_mysql_id, failed_source_ids = _allocate_local_ids(
+            categories,
+            existing_map,
+            repository.get_max_id() + 1,
+        )
+        rows = _to_mysql_rows(
+            categories,
             source_id_to_mysql_id,
-            next_id,
+            failed_source_ids,
         )
-        category_inserted = category_repo.bulk_insert_ignore(new_category_rows)
-
-        # 插入后从 DB 重载映射,确保元素 category_id 与库内真实 id 一致
-        source_id_to_mysql_id = category_repo.get_source_id_map()
-        elements = _dedupe_element_rows(_to_element_rows(raw_elements, source_id_to_mysql_id))
-        element_inserted = element_repo.bulk_insert_ignore(elements)
-
-    categories_failed = len(category_source_rows) - categories_already_exist - len(new_category_rows)
-
+        affected = repository.bulk_upsert(rows)
+        active_source_ids = {int(category["source_id"]) for category in categories}
+        retired = repository.mark_missing_deleted(active_source_ids)
+
+    existing_source_ids = set(existing_map)
+    inserted = sum(
+        1
+        for row in rows
+        if int(row["source_id"]) not in existing_source_ids
+    )
     result = {
         "partition_date": partition_date,
-        "elements_fetched": len(raw_elements),
-        "elements_inserted": element_inserted,
-        "elements_skipped": len(raw_elements) - len(elements),
-        "categories_fetched": len(category_source_rows),
-        "categories_inserted": category_inserted,
-        "categories_already_exist": categories_already_exist,
-        "categories_failed": categories_failed,
+        "source_type": "实质",
+        "categories_fetched": len(raw_categories),
+        "categories_valid": len(categories),
+        "categories_inserted": inserted,
+        "categories_existing_synced": len(rows) - inserted,
+        "categories_failed": len(failed_source_ids),
+        "categories_retired": retired,
+        "rows_affected": affected,
+        "global_tree_element_used": False,
         "synced_at": datetime.now().isoformat(),
     }
-    logger.info("Global tree sync completed: %s", result)
+    logger.info("Global category sync completed: %s", result)
     return result
 
 

+ 2 - 1
supply_infra/scheduler/plan_group_batch.py

@@ -119,7 +119,8 @@ def resolve_demands_for_category_ids(
 
     belongs = DemandBelongCategoryRepository(session).list_by_category_ids(selected_ids)
     pool_ids_by_belong = DemandBelongPoolRelRepository(session).get_pool_ids_by_belong_ids(
-        [int(row.id) for row in belongs]
+        [int(row.id) for row in belongs],
+        biz_dt=biz_dt,
     )
     pool_ids = sorted({pool_id for values in pool_ids_by_belong.values() for pool_id in values})
     pool_repo = MultiDemandPoolDiRepository(session)

+ 3 - 0
tests/api/test_demand_feedback.py

@@ -86,6 +86,9 @@ def test_create_feedback_uses_server_target_and_current_user(monkeypatch) -> Non
     saved: list[DemandFeedback] = []
 
     class Session:
+        def scalar(self, _statement):
+            return None
+
         def refresh(self, row) -> None:
             row.id = 99
             row.created_at = datetime(2026, 7, 30, 15, 20)

+ 213 - 0
tests/supply_infra/pipeline/test_aigc_outbox.py

@@ -0,0 +1,213 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from unittest.mock import MagicMock
+
+from sqlalchemy import create_engine, select
+from sqlalchemy.orm import sessionmaker
+
+from supply_infra.db.base import Base
+from supply_infra.db.models.pipeline_outbox import PipelineOutbox
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
+from supply_infra.pipeline import aigc_outbox
+from supply_infra.pipeline.contracts import StepContext
+from supply_infra.pipeline.enums import OutboxStatus
+
+
+def _payload() -> dict:
+    return {
+        "biz_dt": "20260730",
+        "source_run_id": None,
+        "plan_label": "plan-a",
+        "produce_plan_id": "produce-1",
+        "publish_plan_id": "publish-1",
+        "aweme_ids": ["aweme-1"],
+        "candidate_ids": [101],
+        "plan_name": "【SupplyAgent】plan-a-20260730-stable",
+        "batch_key": "a" * 64,
+    }
+
+
+def _context(*, dry_run: bool = False) -> StepContext:
+    return StepContext(
+        run_id="run-1",
+        step_run_id="step-1",
+        step_key="aigc_write_record",
+        biz_dt="20260730",
+        date_snapshot={},
+        config_snapshot={},
+        input_snapshot={},
+        dry_run=dry_run,
+    )
+
+
+def _database(monkeypatch):
+    engine = create_engine("sqlite+pysqlite:///:memory:")
+    Base.metadata.create_all(
+        engine,
+        tables=[
+            PipelineRun.__table__,
+            PipelineStepRun.__table__,
+            PipelineOutbox.__table__,
+        ],
+    )
+    session_factory = sessionmaker(bind=engine, expire_on_commit=False)
+
+    @contextmanager
+    def get_test_session():
+        with session_factory() as session:
+            try:
+                yield session
+                session.commit()
+            except Exception:
+                session.rollback()
+                raise
+
+    monkeypatch.setattr(aigc_outbox, "get_session", get_test_session)
+    return session_factory
+
+
+def _prepared() -> dict:
+    return {
+        "biz_dt": "20260730",
+        "run_id": None,
+        "plan_count": 1,
+        "candidate_count": 1,
+        "decision_buckets": ["primary"],
+        "distribution": [],
+        "batches": [_payload()],
+    }
+
+
+def test_event_is_committed_before_external_call_and_replay_is_idempotent(
+    monkeypatch,
+) -> None:
+    session_factory = _database(monkeypatch)
+    monkeypatch.setattr(aigc_outbox, "prepare_publish_batches", lambda **_: _prepared())
+    service = MagicMock()
+    service.mark_candidates_aigc_plans.return_value = 1
+    monkeypatch.setattr(aigc_outbox, "get_video_discovery_service", lambda: service)
+
+    calls = {"create": 0}
+
+    class Client:
+        def create_video_crawler_plan(self, _aweme_ids, *, plan_name):
+            calls["create"] += 1
+            with session_factory() as session:
+                row = session.scalar(select(PipelineOutbox))
+                assert row is not None
+                assert row.status == OutboxStatus.SENDING.value
+                assert row.payload_json["plan_name"] == plan_name
+            return {
+                "success": True,
+                "crawler_plan_id": "crawler-1",
+                "crawler_plan_name": plan_name,
+            }
+
+        def bind_crawler_to_produce_plan(self, *_args, **_kwargs):
+            return {"success": True}
+
+    monkeypatch.setattr(aigc_outbox, "AigcClient", Client)
+
+    first = aigc_outbox.execute_aigc_outbox(_context())
+    second = aigc_outbox.execute_aigc_outbox(_context())
+
+    assert first["success"] is True
+    assert second["success"] is True
+    assert calls["create"] == 1
+    with session_factory() as session:
+        row = session.scalar(select(PipelineOutbox))
+        assert row is not None
+        assert row.status == OutboxStatus.SUCCEEDED.value
+        assert row.external_id == "crawler-1"
+        assert row.attempt_count == 1
+
+
+def test_dry_run_records_batches_without_calling_aigc(monkeypatch) -> None:
+    session_factory = _database(monkeypatch)
+    monkeypatch.setattr(
+        aigc_outbox,
+        "prepare_publish_batches",
+        lambda **_: _prepared(),
+    )
+    client = MagicMock(side_effect=AssertionError("AIGC must not be called"))
+    monkeypatch.setattr(aigc_outbox, "AigcClient", client)
+
+    result = aigc_outbox.execute_aigc_outbox(_context(dry_run=True))
+
+    assert result["success"] is True
+    assert result["dry_run"] is True
+    assert result["effect_recorded"] is True
+    assert result["external_request_made"] is False
+    client.assert_not_called()
+    with session_factory() as session:
+        row = session.scalar(select(PipelineOutbox))
+        assert row is not None
+        assert row.dry_run is True
+        assert row.status == OutboxStatus.SUCCEEDED.value
+        assert row.attempt_count == 0
+        assert row.external_id is None
+        assert row.response_json["external_request_made"] is False
+
+
+def test_timeout_becomes_ambiguous_and_is_not_automatically_replayed(
+    monkeypatch,
+) -> None:
+    session_factory = _database(monkeypatch)
+    service = MagicMock()
+    monkeypatch.setattr(aigc_outbox, "get_video_discovery_service", lambda: service)
+    event_id = aigc_outbox._enqueue_batches(_context(), [_payload()])[0]
+
+    class TimeoutClient:
+        calls = 0
+
+        def create_video_crawler_plan(self, *_args, **_kwargs):
+            self.calls += 1
+            raise TimeoutError("remote timeout")
+
+    client = TimeoutClient()
+    first = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=client)
+    second = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=client)
+
+    assert first["status"] == OutboxStatus.AMBIGUOUS.value
+    assert second["status"] == OutboxStatus.AMBIGUOUS.value
+    assert client.calls == 1
+    service.mark_candidates_aigc_plans.assert_not_called()
+    with session_factory() as session:
+        row = session.get(PipelineOutbox, event_id)
+        assert row is not None
+        assert row.status == OutboxStatus.AMBIGUOUS.value
+
+
+def test_bind_retry_reuses_persisted_external_plan(monkeypatch) -> None:
+    session_factory = _database(monkeypatch)
+    service = MagicMock()
+    service.mark_candidates_aigc_plans.return_value = 1
+    monkeypatch.setattr(aigc_outbox, "get_video_discovery_service", lambda: service)
+    event_id = aigc_outbox._enqueue_batches(_context(), [_payload()])[0]
+
+    first_client = MagicMock()
+    first_client.create_video_crawler_plan.return_value = {
+        "success": True,
+        "crawler_plan_id": "crawler-1",
+        "crawler_plan_name": "stable-name",
+    }
+    first_client.bind_crawler_to_produce_plan.side_effect = TimeoutError("bind timeout")
+
+    first = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=first_client)
+    assert first["status"] == OutboxStatus.RETRYABLE_FAILED.value
+
+    retry_client = MagicMock()
+    retry_client.bind_crawler_to_produce_plan.return_value = {"success": True}
+    second = aigc_outbox.dispatch_aigc_outbox_event(event_id, client=retry_client)
+
+    assert second["success"] is True
+    retry_client.create_video_crawler_plan.assert_not_called()
+    retry_client.bind_crawler_to_produce_plan.assert_called_once()
+    with session_factory() as session:
+        row = session.get(PipelineOutbox, event_id)
+        assert row is not None
+        assert row.status == OutboxStatus.SUCCEEDED.value
+        assert row.external_id == "crawler-1"
+        assert row.attempt_count == 2

+ 46 - 0
tests/supply_infra/pipeline/test_aigc_safety.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+from unittest.mock import MagicMock
+
 from supply_infra.aigc.client import AigcClient
 from supply_infra.config import InfraSettings
 
@@ -45,3 +47,47 @@ def test_aigc_client_posts_to_platform(monkeypatch) -> None:
     assert result["success"] is True
     assert result["crawler_plan_id"] == "plan-1"
     assert len(calls) == 1
+
+
+def test_bind_skips_duplicate_existing_crawler_source(monkeypatch) -> None:
+    settings = InfraSettings(_env_file=None)
+    monkeypatch.setattr(
+        "supply_infra.aigc.client.get_infra_settings",
+        lambda: settings,
+    )
+    client = AigcClient(token="secret")
+    monkeypatch.setattr(
+        client,
+        "_get_produce_plan_detail",
+        lambda _plan_id: (
+            {
+                "name": "produce-plan",
+                "inputSourceGroups": [
+                    {
+                        "inputSources": [
+                            {
+                                "contentType": 1,
+                                "inputSourceType": 2,
+                                "inputSourceValue": "crawler-1",
+                                "inputSourceModal": 4,
+                                "inputSourceChannel": 2,
+                            }
+                        ]
+                    }
+                ],
+            },
+            None,
+        ),
+    )
+    post = MagicMock()
+    monkeypatch.setattr(client, "_post", post)
+
+    result = client.bind_crawler_to_produce_plan(
+        "crawler-1",
+        "produce-1",
+        crawler_plan_name="crawler-name",
+    )
+
+    assert result["success"] is True
+    assert result["already_bound"] is True
+    post.assert_not_called()

+ 22 - 5
tests/supply_infra/pipeline/test_dag_and_gates.py

@@ -4,28 +4,44 @@ from supply_infra.pipeline.dag import PIPELINE_STEPS
 from supply_infra.pipeline.gates import evaluate_step_gate
 
 
-def test_pipeline_has_twelve_strictly_ordered_steps() -> None:
-    assert len(PIPELINE_STEPS) == 12
+def test_pipeline_has_fifteen_strictly_ordered_steps() -> None:
+    assert len(PIPELINE_STEPS) == 15
     assert PIPELINE_STEPS[0].key == "global_tree_sync"
     assert PIPELINE_STEPS[-1].key == "aigc_write_record"
     for previous, current in zip(PIPELINE_STEPS, PIPELINE_STEPS[1:]):
         assert current.dependencies == (previous.key,)
         assert current.critical is True
+    classify = next(step for step in PIPELINE_STEPS if step.key == "demand_classify")
+    assert classify.retryable is True
+    assert classify.max_attempts == 2
 
 
 def test_aigc_gate_requires_durable_effect_record() -> None:
-    failed = evaluate_step_gate("aigc_write_record", {"success": True})
+    failed = evaluate_step_gate(
+        "aigc_write_record",
+        {"success": True, "external_request_made": True},
+    )
     assert failed.passed is False
     live_ok = evaluate_step_gate(
         "aigc_write_record",
         {
             "success": True,
             "effect_recorded": True,
-            "payload_hash": "abc",
+            "outbox_ids": ["outbox-1"],
             "external_request_made": True,
         },
     )
     assert live_ok.passed is True
+    no_candidates = evaluate_step_gate(
+        "aigc_write_record",
+        {
+            "success": True,
+            "effect_recorded": False,
+            "external_request_made": False,
+            "candidate_count": 0,
+        },
+    )
+    assert no_candidates.passed is True
 
 
 def test_aigc_gate_rejects_failed_batches() -> None:
@@ -34,7 +50,8 @@ def test_aigc_gate_rejects_failed_batches() -> None:
         {
             "success": True,
             "effect_recorded": True,
-            "payload_hash": "abc",
+            "outbox_ids": ["outbox-1"],
+            "external_request_made": True,
             "failed_batch_count": 2,
         },
     )

+ 118 - 0
tests/supply_infra/pipeline/test_full_flow_contract.py

@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+from supply_infra.db.models.pipeline_run import PipelineRun
+from supply_infra.db.models.pipeline_step_run import PipelineStepRun
+from supply_infra.pipeline.contracts import StepResult
+from supply_infra.pipeline.dag import PIPELINE_STEPS
+from supply_infra.pipeline.orchestrator import complete_step
+from supply_infra.pipeline.registry import STEP_REGISTRY
+
+
+def _session() -> Session:
+    engine = create_engine("sqlite+pysqlite:///:memory:")
+    PipelineRun.__table__.create(engine)
+    PipelineStepRun.__table__.create(engine)
+    return Session(engine, expire_on_commit=False)
+
+
+def _seed_full_run(session: Session) -> tuple[PipelineRun, list[PipelineStepRun]]:
+    run = PipelineRun(
+        run_id="full-run",
+        dedupe_key="supply_pipeline:20260731:full",
+        pipeline_key="supply_pipeline",
+        biz_dt="20260731",
+        trigger_type="test",
+        run_mode="full",
+        dry_run=True,
+        status="queued",
+        deadline_at=(
+            datetime.now(timezone.utc).replace(tzinfo=None)
+            + timedelta(days=1)
+        ),
+        config_snapshot_json={},
+        date_snapshot_json={},
+    )
+    steps = [
+        PipelineStepRun(
+            step_run_id=f"step-{definition.order}",
+            run_id=run.run_id,
+            step_key=definition.key,
+            step_order=definition.order,
+            attempt=1,
+            status="ready" if definition.order == 1 else "pending",
+            critical=definition.critical,
+            dependency_snapshot_json=list(definition.dependencies),
+            input_snapshot_json={},
+            timeout_seconds=definition.timeout_seconds,
+            max_attempts=definition.max_attempts,
+            retryable=definition.retryable,
+        )
+        for definition in PIPELINE_STEPS
+    ]
+    session.add_all([run, *steps])
+    session.flush()
+    return run, steps
+
+
+def test_all_fifteen_steps_advance_to_terminal_success() -> None:
+    with _session() as session:
+        run, steps = _seed_full_run(session)
+        assert {step.step_key for step in steps} == set(STEP_REGISTRY)
+
+        for index, step in enumerate(steps):
+            step.status = "running"
+            step.lease_owner = "worker"
+            run.status = "running"
+            state = complete_step(
+                session,
+                step_run_id=step.step_run_id,
+                owner="worker",
+                result=StepResult(
+                    success=True,
+                    payload={"success": True},
+                    exit_code=0,
+                ),
+            )
+            if index < len(steps) - 1:
+                assert state == "queued"
+                assert steps[index + 1].status == "ready"
+            else:
+                assert state == "succeeded"
+                assert run.status == "succeeded"
+
+
+def test_business_failure_blocks_every_remaining_step() -> None:
+    with _session() as session:
+        run, steps = _seed_full_run(session)
+        target = next(
+            step for step in steps if step.step_key == "video_discovery"
+        )
+        for step in steps[: target.step_order - 1]:
+            step.status = "succeeded"
+        target.status = "running"
+        target.lease_owner = "worker"
+        run.status = "running"
+
+        state = complete_step(
+            session,
+            step_run_id=target.step_run_id,
+            owner="worker",
+            result=StepResult(
+                success=True,
+                payload={"success": True, "failed": 1},
+                exit_code=0,
+            ),
+        )
+
+        assert state == "failed"
+        assert run.status == "failed"
+        assert all(
+            step.status == "blocked"
+            for step in steps
+            if step.step_order > target.step_order
+        )

+ 38 - 1
tests/supply_infra/pipeline/test_health.py

@@ -4,7 +4,10 @@ from datetime import timedelta
 
 from supply_infra.config import InfraSettings
 from supply_infra.pipeline.dates import china_now
-from supply_infra.pipeline.health import run_alert_level
+from supply_infra.pipeline.health import (
+    run_alert_level,
+    runtime_components_ready,
+)
 
 
 def _settings() -> InfraSettings:
@@ -91,3 +94,37 @@ def test_manual_run_without_deadline_only_uses_duration_warning() -> None:
         )
         == "warning"
     )
+
+
+def test_runtime_readiness_requires_all_workers_and_reconciler() -> None:
+    assert runtime_components_ready(
+        scheduler_enabled=True,
+        scheduler_running=True,
+        active_worker_count=4,
+        expected_worker_count=4,
+        reconciler_running=True,
+    )
+    assert not runtime_components_ready(
+        scheduler_enabled=True,
+        scheduler_running=True,
+        active_worker_count=3,
+        expected_worker_count=4,
+        reconciler_running=True,
+    )
+    assert not runtime_components_ready(
+        scheduler_enabled=True,
+        scheduler_running=True,
+        active_worker_count=4,
+        expected_worker_count=4,
+        reconciler_running=False,
+    )
+
+
+def test_runtime_readiness_allows_intentionally_disabled_scheduler() -> None:
+    assert runtime_components_ready(
+        scheduler_enabled=False,
+        scheduler_running=False,
+        active_worker_count=1,
+        expected_worker_count=1,
+        reconciler_running=True,
+    )

+ 29 - 0
tests/supply_infra/pipeline/test_orchestrator.py

@@ -107,6 +107,35 @@ def test_success_only_releases_immediate_next_step() -> None:
         assert second.status == "ready"
 
 
+def test_category_match_item_failures_create_retry_attempt() -> None:
+    with _session() as session:
+        run, first, second = _seed(session)
+        first.step_key = "demand_classify"
+        first.retryable = True
+        first.max_attempts = 2
+
+        state = complete_step(
+            session,
+            step_run_id=first.step_run_id,
+            owner="worker-1",
+            result=StepResult(
+                success=False,
+                payload={
+                    "success": False,
+                    "failed_items": [{"term": "牺牲", "error": "timeout"}],
+                },
+                error_code="category_match_failed_items",
+                error_message="1 category match item(s) failed",
+                exit_code=1,
+            ),
+        )
+
+        assert state == "retry_wait"
+        assert run.status == "queued"
+        assert first.status == "failed"
+        assert second.status == "pending"
+
+
 def test_completion_cannot_revive_a_cancelled_run() -> None:
     with _session() as session:
         run, first, second = _seed(session)

+ 62 - 0
tests/supply_infra/pipeline/test_preflight.py

@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from supply_infra.config import InfraSettings
+from supply_infra.pipeline.preflight import (
+    REQUIRED_PIPELINE_TABLES,
+    evaluate_preflight,
+)
+
+
+def _settings(**overrides) -> InfraSettings:
+    values = {
+        "MYSQL_PASSWORD": "mysql-secret",
+        "ODPS_ACCESS_ID": "odps-id",
+        "ODPS_ACCESS_KEY": "odps-secret",
+        "ODPS_PROJECT": "project",
+        "SCHEDULER_ENABLED": True,
+        "AIGC_API_TOKEN": "aigc-secret",
+        "LOG_OSS_UPLOAD_ENABLED": False,
+    }
+    values.update(overrides)
+    return InfraSettings(_env_file=None, **values)
+
+
+def test_preflight_passes_for_complete_runtime_contract() -> None:
+    result = evaluate_preflight(
+        infra=_settings(AUTH_COOKIE_SECURE=True),
+        openrouter_configured=True,
+        table_names=set(REQUIRED_PIPELINE_TABLES),
+        current_revision="head-1",
+        expected_revision="head-1",
+        require_scheduler=True,
+        require_secure_cookie=True,
+    )
+
+    assert result.passed is True
+    assert result.errors == []
+    assert result.checks["pipeline_step_count"] == 15
+
+
+def test_preflight_blocks_silent_partial_startup() -> None:
+    result = evaluate_preflight(
+        infra=_settings(
+            SCHEDULER_ENABLED=False,
+            AIGC_API_TOKEN="",
+            CATEGORY_MATCH_API_URL="",
+        ),
+        openrouter_configured=False,
+        table_names={"pipeline_run"},
+        current_revision="old-head",
+        expected_revision="new-head",
+        require_scheduler=True,
+        require_secure_cookie=True,
+    )
+
+    assert result.passed is False
+    assert any("SCHEDULER_ENABLED" in item for item in result.errors)
+    assert any("OPENROUTER_API_KEY" in item for item in result.errors)
+    assert any("AIGC_API_TOKEN" in item for item in result.errors)
+    assert any("CATEGORY_MATCH_API_URL" in item for item in result.errors)
+    assert any("迁移版本不一致" in item for item in result.errors)
+    assert any("缺少流水线依赖表" in item for item in result.errors)
+    assert any("AUTH_COOKIE_SECURE" in item for item in result.errors)

+ 70 - 0
tests/supply_infra/scheduler/test_demand_belong_rel.py

@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from supply_infra.scheduler.jobs.demand_pool.belong_rel import (
+    sync_demand_belong_pool_rel,
+)
+
+
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.belong_rel.DemandBelongPoolRelRepository"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.belong_rel.MultiDemandPoolDiRepository"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.belong_rel.DemandBelongCategoryRepository"
+)
+@patch("supply_infra.scheduler.jobs.demand_pool.belong_rel.get_session")
+def test_relations_are_date_scoped_and_require_explicit_tokens(
+    mock_get_session,
+    mock_belong_repo_cls,
+    mock_pool_repo_cls,
+    mock_rel_repo_cls,
+) -> None:
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    mock_belong_repo_cls.return_value.list_active_id_name.return_value = [
+        (1, "老年"),
+        (2, "年"),
+    ]
+    mock_pool_repo_cls.return_value.list_id_name_video_lists.return_value = [
+        (101, "老年 健身", '["v-1"]'),
+        (102, "老年健身", '["v-2"]'),
+    ]
+    mock_rel_repo_cls.return_value.delete_by_pool_ids.return_value = 2
+    mock_rel_repo_cls.return_value.bulk_insert_ignore.return_value = 1
+    mock_belong_repo_cls.return_value.update_video_lists.return_value = 2
+
+    result = sync_demand_belong_pool_rel("20260730")
+
+    mock_pool_repo_cls.return_value.list_id_name_video_lists.assert_called_once_with(
+        "20260730"
+    )
+    inserted_rows = mock_rel_repo_cls.return_value.bulk_insert_ignore.call_args.args[0]
+    assert inserted_rows == [
+        {
+            "demand_belong_category_id": 1,
+            "multi_demand_pool_di_id": 101,
+            "biz_dt": "20260730",
+            "relation_type": "explicit_token",
+            "relation_source": "demand_pool_name_tokens",
+            "reason": "需求归属词完整命中上游 demand_name 的显式空格分词",
+            "confidence": 1.0,
+            "is_inferred": True,
+            "status": "active",
+            "valid_from_biz_dt": "20260730",
+            "valid_to_biz_dt": None,
+        }
+    ]
+    assert result["biz_dt"] == "20260730"
+    assert result["matched_edges"] == 1
+    assert result["rejected_substring_candidates"] == 3
+    assert result["replaced_edges"] == 2
+
+
+def test_relation_sync_rejects_invalid_business_date() -> None:
+    with pytest.raises(ValueError, match="YYYYMMDD"):
+        sync_demand_belong_pool_rel("2026-07-30")

+ 138 - 0
tests/supply_infra/scheduler/test_sync_global_tree.py

@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from supply_infra.odps.client import ODPSClient
+from supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql import (
+    _allocate_local_ids,
+    _to_category_source_rows,
+    sync_global_tree_odps_to_mysql,
+)
+
+
+def test_global_category_sql_uses_stable_tree_and_no_element_table() -> None:
+    client = ODPSClient("id", "key", "project", "endpoint")
+    client.execute_sql = MagicMock(return_value=[])
+
+    client.fetch_global_categories("20260731")
+
+    sql = client.execute_sql.call_args.args[0]
+    assert "loghubods.global_category" in sql
+    assert "stable_id,name,description,level,parent_stable_id" in sql
+    assert "dt = '20260731'" in sql
+    assert "retired_at_execution_id IS NULL" in sql
+    assert "source_type = '实质'" in sql
+    assert "ORDER BY level" in sql
+    assert "global_tree_element" not in sql
+    assert "pattern_mining_element" not in sql
+
+
+def test_stable_ids_are_mapped_to_existing_and_new_local_ids() -> None:
+    categories = _to_category_source_rows(
+        [
+            {
+                "stable_id": 1,
+                "name": "理念",
+                "description": "",
+                "level": 1,
+                "parent_stable_id": None,
+            },
+            {
+                "stable_id": 7392,
+                "name": "壮烈牺牲",
+                "description": "描述",
+                "level": 6,
+                "parent_stable_id": 1,
+            },
+        ]
+    )
+
+    mapping, failed = _allocate_local_ids(categories, {1: 10}, 11)
+
+    assert mapping == {1: 10, 7392: 11}
+    assert failed == set()
+
+
+@patch(
+    "supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql."
+    "GlobalTreeCategoryRepository"
+)
+@patch("supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql.get_session")
+@patch("supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql.get_odps_client")
+def test_tree_sync_does_not_read_or_write_global_tree_element(
+    mock_get_odps,
+    mock_get_session,
+    mock_repo_cls,
+) -> None:
+    mock_get_odps.return_value.fetch_global_categories.return_value = [
+        {
+            "stable_id": 1,
+            "name": "理念",
+            "description": "root",
+            "level": 1,
+            "parent_stable_id": None,
+        },
+        {
+            "stable_id": 7392,
+            "name": "壮烈牺牲",
+            "description": "leaf",
+            "level": 2,
+            "parent_stable_id": 1,
+        },
+    ]
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    repository = mock_repo_cls.return_value
+    repository.count_active.return_value = 1
+    repository.get_source_id_map.return_value = {1: 10}
+    repository.get_max_id.return_value = 10
+    repository.bulk_upsert.return_value = 2
+    repository.mark_missing_deleted.return_value = 0
+
+    result = sync_global_tree_odps_to_mysql("20260731")
+
+    assert result["categories_inserted"] == 1
+    assert result["global_tree_element_used"] is False
+    rows = repository.bulk_upsert.call_args.args[0]
+    assert rows[1]["source_id"] == 7392
+    assert rows[1]["parent_id"] == 10
+
+
+@patch("supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql.get_odps_client")
+def test_empty_global_category_partition_fails_closed(mock_get_odps) -> None:
+    mock_get_odps.return_value.fetch_global_categories.return_value = []
+
+    with pytest.raises(RuntimeError, match="returned no active categories"):
+        sync_global_tree_odps_to_mysql("20260731")
+
+
+@patch(
+    "supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql."
+    "GlobalTreeCategoryRepository"
+)
+@patch("supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql.get_session")
+@patch("supply_infra.scheduler.jobs.sync_global_tree_odps_to_mysql.get_odps_client")
+def test_partial_tree_partition_does_not_retire_existing_tree(
+    mock_get_odps,
+    mock_get_session,
+    mock_repo_cls,
+) -> None:
+    mock_get_odps.return_value.fetch_global_categories.return_value = [
+        {
+            "stable_id": 1,
+            "name": "理念",
+            "description": "",
+            "level": 1,
+            "parent_stable_id": None,
+        }
+    ]
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    repository = mock_repo_cls.return_value
+    repository.count_active.return_value = 100
+
+    with pytest.raises(RuntimeError, match="dropped below safety threshold"):
+        sync_global_tree_odps_to_mysql("20260731")
+
+    repository.bulk_upsert.assert_not_called()
+    repository.mark_missing_deleted.assert_not_called()

+ 169 - 11
tests/supply_infra/scheduler/test_sync_multi_demand_pool.py

@@ -3,11 +3,15 @@ from __future__ import annotations
 
 from unittest.mock import MagicMock, patch
 
-from supply_infra.scheduler.jobs.demand_pool.sync import _classify_words
+from supply_infra.category_match import CategoryMatch
+from supply_infra.scheduler.jobs.demand_pool.sync import (
+    _classify_words,
+    _sync_pool_rows,
+)
 
 
 @patch(
-    "supply_infra.scheduler.jobs.demand_pool.sync.classify_demand_words"
+    "supply_infra.scheduler.jobs.demand_pool.sync.GlobalTreeCategoryRepository"
 )
 @patch(
     "supply_infra.scheduler.jobs.demand_pool.sync.DemandBelongCategoryRepository"
@@ -16,24 +20,178 @@ from supply_infra.scheduler.jobs.demand_pool.sync import _classify_words
     "supply_infra.scheduler.jobs.demand_pool.sync.MultiDemandPoolDiRepository"
 )
 @patch("supply_infra.scheduler.jobs.demand_pool.sync.get_session")
-def test_classification_batch_failure_continues_remaining_batches(
+def test_classification_calls_match_api_once_per_term_and_keeps_partial_success(
     mock_get_session,
     mock_pool_repo_cls,
     mock_belong_repo_cls,
-    mock_classify,
+    mock_tree_repo_cls,
 ) -> None:
     mock_get_session.return_value.__enter__.return_value = MagicMock()
     mock_pool_repo_cls.return_value.list_demand_names_by_biz_dt.return_value = [
-        f"需求{i:03d}" for i in range(120)
+        "牺牲 未知词",
+        "失败词",
     ]
     mock_belong_repo_cls.return_value.get_existing_names.return_value = set()
-    mock_classify.side_effect = [RuntimeError("first batch failed"), None, None]
+    mock_belong_repo_cls.return_value.upsert_category_matches.return_value = 1
+    mock_tree_repo_cls.return_value.get_active_source_id_map.return_value = {
+        7392: 88
+    }
+    client = MagicMock()
+
+    def _match(term: str, *, description: str):
+        assert description == ""
+        if term == "失败词":
+            raise RuntimeError("upstream unavailable")
+        if term == "未知词":
+            return None
+        return CategoryMatch(
+            term="牺牲",
+            stable_id=7392,
+            name="壮烈牺牲",
+            path="/理念/事件/军事事件/军人事迹/英勇战功/壮烈牺牲",
+            description="为国为民献出生命",
+            level=6,
+            score=1.0,
+        )
+
+    client.match_one.side_effect = _match
 
-    result = _classify_words("20260721")
+    result = _classify_words("20260721", client=client)
 
-    assert mock_classify.call_count == 3
+    assert client.match_one.call_count == 3
+    client.match_one.assert_any_call("牺牲", description="")
+    client.match_one.assert_any_call("未知词", description="")
+    client.match_one.assert_any_call("失败词", description="")
     assert result["success"] is False
-    assert result["batches"] == 3
-    assert result["failed_batches"] == [
-        {"batch": 1, "size": 50, "error": "first batch failed"}
+    assert result["api_calls"] == 3
+    assert result["matched"] == 1
+    assert result["persisted"] == 1
+    assert result["unmatched_terms"] == ["未知词"]
+    assert result["failed_items"] == [
+        {"term": "失败词", "error": "upstream unavailable"}
+    ]
+    rows = mock_belong_repo_cls.return_value.upsert_category_matches.call_args.args[0]
+    assert rows == [
+        {
+            "name": "牺牲",
+            "category_id": 88,
+            "reason": (
+                "category_match_api_v2: "
+                "path=/理念/事件/军事事件/军人事迹/英勇战功/壮烈牺牲; "
+                "score=1.0000; stable_id=7392"
+            ),
+            "is_delete": 0,
+        }
+    ]
+
+
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.DemandBelongPoolRelRepository"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.MultiDemandPoolDiRepository"
+)
+@patch("supply_infra.scheduler.jobs.demand_pool.sync.get_session")
+@patch("supply_infra.scheduler.jobs.demand_pool.sync.get_odps_client")
+def test_same_count_content_revision_is_updated(
+    mock_get_odps,
+    mock_get_session,
+    mock_pool_repo_cls,
+    mock_rel_repo_cls,
+) -> None:
+    mock_get_odps.return_value.fetch_multi_demand_pool.return_value = [
+        {
+            "strategy": "逐月",
+            "demand_id": "d-1",
+            "demand_name": "修订后的需求",
+            "weight": 0.8,
+            "type": "topic",
+            "video_list": ["v-2"],
+            "extend": "new",
+        }
+    ]
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    repo = mock_pool_repo_cls.return_value
+    repo.list_source_rows_by_biz_dt.return_value = [
+        {
+            "id": 11,
+            "strategy": "逐月",
+            "demand_id": "d-1",
+            "demand_name": "修订前的需求",
+            "weight": 0.5,
+            "type": "topic",
+            "video_count": 1,
+            "video_list": '["v-1"]',
+            "extend": "old",
+            "biz_dt": "20260730",
+        }
     ]
+    repo.delete_by_keys.return_value = 0
+    repo.bulk_insert.return_value = 0
+    repo.update_source_fields.return_value = 1
+    mock_rel_repo_cls.return_value.delete_by_pool_ids.return_value = 0
+
+    result = _sync_pool_rows("20260730")
+
+    assert result["fetched"] == 1
+    assert result["inserted"] == 0
+    assert result["deleted"] == 0
+    assert result["updated"] == 1
+    assert result["unchanged"] == 0
+    assert len(result["source_snapshot_hash"]) == 64
+    repo.update_source_fields.assert_called_once()
+    updated_row = repo.update_source_fields.call_args.args[1][0]
+    assert updated_row["demand_name"] == "修订后的需求"
+    assert updated_row["video_list"] == '["v-2"]'
+
+
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.DemandBelongPoolRelRepository"
+)
+@patch(
+    "supply_infra.scheduler.jobs.demand_pool.sync.MultiDemandPoolDiRepository"
+)
+@patch("supply_infra.scheduler.jobs.demand_pool.sync.get_session")
+@patch("supply_infra.scheduler.jobs.demand_pool.sync.get_odps_client")
+def test_unchanged_content_is_not_rewritten(
+    mock_get_odps,
+    mock_get_session,
+    mock_pool_repo_cls,
+    mock_rel_repo_cls,
+) -> None:
+    upstream = {
+        "strategy": "逐月",
+        "demand_id": "d-1",
+        "demand_name": "老年 健身",
+        "weight": 0.8,
+        "type": "topic",
+        "video_list": ["v-1"],
+        "extend": "same",
+    }
+    mock_get_odps.return_value.fetch_multi_demand_pool.return_value = [upstream]
+    mock_get_session.return_value.__enter__.return_value = MagicMock()
+    repo = mock_pool_repo_cls.return_value
+    repo.list_source_rows_by_biz_dt.return_value = [
+        {
+            "id": 11,
+            "strategy": "逐月",
+            "demand_id": "d-1",
+            "demand_name": "老年 健身",
+            "weight": 0.8,
+            "type": "topic",
+            "video_count": 1,
+            "video_list": '["v-1"]',
+            "extend": "same",
+            "biz_dt": "20260730",
+        }
+    ]
+    repo.delete_by_keys.return_value = 0
+    repo.bulk_insert.return_value = 0
+    repo.update_source_fields.return_value = 0
+    mock_rel_repo_cls.return_value.delete_by_pool_ids.return_value = 0
+
+    result = _sync_pool_rows("20260730")
+
+    assert result["updated"] == 0
+    assert result["unchanged"] == 1
+    repo.update_source_fields.assert_not_called()

+ 8 - 1
web/src/types/pipeline.ts

@@ -44,7 +44,14 @@ export interface PipelineEffect {
   effect_type: string
   payload_hash: string
   payload_uri: string | null
-  status: 'dry_run_recorded' | 'record_failed'
+  status:
+    | 'pending'
+    | 'sending'
+    | 'created'
+    | 'retryable_failed'
+    | 'ambiguous'
+    | 'succeeded'
+    | 'permanent_failed'
   dry_run: boolean
   created_at: string | null
 }

+ 6 - 4
web/src/views/GlobalDemandMapView.vue

@@ -15,12 +15,14 @@ const error = ref<string | null>(null)
 
 onMounted(async () => {
   try {
-    const [tree, demands] = await Promise.all([
-      fetchCategoryTree(),
-      fetchDemandGrade(),
-    ])
+    const tree = await fetchCategoryTree()
     nodes.value = tree.nodes ?? []
     bizDt.value = tree.biz_dt ?? null
+    if (!tree.biz_dt) {
+      demandsByCategory.value = {}
+      return
+    }
+    const demands = await fetchDemandGrade(tree.biz_dt)
     demandsByCategory.value = groupDemandsByCategory(demands.items ?? [])
   } catch (e) {
     error.value = e instanceof Error ? e.message : String(e)