Explorar o código

Merge branch 'dev-optimize-scheduled' of Server/SupplyAgent into master

xueyiming hai 3 días
pai
achega
3c98c492ae
Modificáronse 39 ficheiros con 722 adicións e 996 borrados
  1. 15 17
      PRD.md
  2. 1 1
      agents/README.md
  3. 422 152
      agents/find_agent/PRD.md
  4. 21 16
      agents/find_agent/README.md
  5. 29 17
      agents/find_agent/VALIDATION.md
  6. 14 17
      agents/find_agent/agent.py
  7. 17 17
      agents/find_agent/output_sync.py
  8. 41 35
      agents/find_agent/prompt/system_prompt.md
  9. 15 25
      agents/find_agent/tools/decision_support.py
  10. 4 6
      agents/find_agent/tools/qwen_video_analyze.py
  11. 13 26
      agents/find_agent/tools/video_discovery_store.py
  12. 75 0
      alembic/versions/20260728_01_drop_unused_scheduler_artifacts.py
  13. 1 30
      api/app.py
  14. 0 6
      api/services/scheduler.py
  15. 1 4
      sql/video_discovery_add_audit_evidence.sql
  16. 10 0
      sql/video_discovery_drop_unused_columns.sql
  17. 1 6
      sql/video_discovery_tables.sql
  18. 5 5
      supply_infra/aigc/plan_map.py
  19. 0 2
      supply_infra/db/models/__init__.py
  20. 0 3
      supply_infra/db/models/pipeline_run.py
  21. 0 1
      supply_infra/db/models/pipeline_step_run.py
  22. 0 51
      supply_infra/db/models/scheduler_job_execution.py
  23. 1 16
      supply_infra/db/models/video_discovery.py
  24. 0 4
      supply_infra/db/repositories/__init__.py
  25. 0 50
      supply_infra/db/repositories/scheduler_job_execution_repo.py
  26. 3 6
      supply_infra/db/repositories/video_discovery_repo.py
  27. 0 4
      supply_infra/pipeline/run_service.py
  28. 0 38
      supply_infra/scheduler/app.py
  29. 0 4
      supply_infra/scheduler/constants.py
  30. 0 120
      supply_infra/scheduler/job_execution.py
  31. 1 5
      supply_infra/scheduler/jobs/demand_pool/__init__.py
  32. 0 15
      supply_infra/scheduler/jobs/demand_pool/__main__.py
  33. 10 95
      supply_infra/scheduler/jobs/demand_pool/sync.py
  34. 1 1
      supply_infra/scheduler/jobs/discover_videos_from_demands.py
  35. 4 4
      supply_infra/scheduler/jobs/publish_videos_from_discovery.py
  36. 0 66
      supply_infra/scheduler/manual_jobs.py
  37. 0 8
      supply_infra/scheduler/step_runner.py
  38. 16 73
      tests/supply_infra/scheduler/test_discover_videos_from_demands.py
  39. 1 50
      tests/supply_infra/scheduler/test_sync_multi_demand_pool.py

+ 15 - 17
PRD.md

@@ -388,7 +388,7 @@ flowchart LR
 3. 调用内部抖音搜索、TikHub 或作者作品搜索;
 4. 每个搜索页写入 `video_discovery_search`;
 5. 候选视频写入 `video_discovery_candidate`,初始为 `pending_evaluation`;
-6. 对高潜候选补充详情、视频点赞用户画像、作者粉丝画像和视频内容解析
+6. 对高潜候选补充详情、视频点赞用户画像和作者粉丝画像;明确不使用视频理解
 7. Agent 独立判断:
    - R:需求相关性;
    - E:老年受众倾向;
@@ -396,11 +396,10 @@ flowchart LR
    - V:联合价值;
 8. 候选进入:
    - `primary`:主推荐;
-   - `backup`:需求相关性不足但受众和分享价值较强的补充推荐;
    - `rejected`:淘汰;
-   - `pending_evaluation`:尚未完成;
-9. Agent 应将运行置为 `finished` 并输出主推荐、补充推荐、淘汰原因、搜索树和缺失证据;
-10. Agent 返回后,程序从最终文字中解析主推荐/补充推荐视频 ID,再次同步数据库分池。
+   - `pending_evaluation`:尚未完成的过程状态,不是最终等级
+9. Agent 应将运行置为 `finished` 并输出主推荐、淘汰原因、搜索树和缺失证据;
+10. Agent 返回后,程序从最终文字中解析主推荐/淘汰候选视频 ID,再次同步数据库分池。
 
 ### 11.3 输出
 
@@ -415,7 +414,7 @@ flowchart LR
 
 ### 12.1 当前入选规则
 
-- 选择当日 `decision_bucket in (primary, backup)` 的候选;
+- 选择当日 `decision_bucket=primary` 的候选;
 - `aweme_id` 必须非空;
 - 默认跳过已经写入 `aigc_crawler_plan_id` 的候选;
 - 当前查询不要求所属 `video_discovery_run.status=finished`。
@@ -467,7 +466,7 @@ flowchart LR
 | 需求汇总/视频发现 | 分级需求、源视频、拓展点位 |
 
 当前前端“视频发现”页面没有读取 `video_discovery_candidate`,因此展示的是找片输入证据,
-不是 `find_agent` 最终找到的主推荐、补充推荐和淘汰候选。
+不是 `find_agent` 最终找到的主推荐和淘汰候选。
 
 ---
 
@@ -539,7 +538,7 @@ flowchart LR
 ### FR-08 AIGC 分发与发布
 
 - 只有 finished 且审计通过的找片运行可以分发;
-- `primary` 和 `backup` 是否都允许自动分发必须由产品策略明确配置
+- `primary` 允许自动分发;
 - 候选必须按需求分类或明确的路由规则进入正确计划;
 - 创建、绑定、生产、发布每个外部动作必须有幂等键和独立状态;
 - 必须区分“已分发、已生产、已发布、发布失败”;
@@ -548,7 +547,7 @@ flowchart LR
 ### FR-09 查询与运营
 
 - 前端必须展示每日批次及各阶段状态;
-- 必须展示 `find_agent` 的主推荐、补充推荐、淘汰候选和证据;
+- 必须展示 `find_agent` 的主推荐、淘汰候选和证据;
 - 支持按业务日、需求、分类、运行状态和发布状态检索;
 - 支持安全的失败项重试,并保留操作审计。
 
@@ -813,14 +812,13 @@ flowchart TB
 
 1. 每日 `biz_dt` 应使用当天还是 T-1,15:00 时上游当天分区是否已稳定;
 2. S 与 A 的资源排序是否必须严格 S 优先;
-3. `backup` 是否允许无人工确认直接进入 AIGC;
-4. 每天 Top 200 是固定预算,还是应按分类、等级、探索比例动态分配;
-5. 没有源视频或点位的 S/A 需求应如何搜索;
-6. AIGC 计划应按哪个分类层级路由,一条需求多挂靠时如何选主路由;
-7. “发布”最终是完成生产计划绑定,还是必须真正上线到目标账号;
-8. ROV/VOV 的有效样本门槛、归因方式和跨日衰减规则;
-9. `generated_demand` 是否要成为正式平台需求,还是退出主产品;
-10. API 和 OSS 日志的访问权限、数据保留周期及脱敏要求。
+3. 每天 Top 200 是固定预算,还是应按分类、等级、探索比例动态分配;
+4. 没有源视频或点位的 S/A 需求应如何搜索;
+5. AIGC 计划应按哪个分类层级路由,一条需求多挂靠时如何选主路由;
+6. “发布”最终是完成生产计划绑定,还是必须真正上线到目标账号;
+7. ROV/VOV 的有效样本门槛、归因方式和跨日衰减规则;
+8. `generated_demand` 是否要成为正式平台需求,还是退出主产品;
+9. API 和 OSS 日志的访问权限、数据保留周期及脱敏要求。
 
 ---
 

+ 1 - 1
agents/README.md

@@ -18,7 +18,7 @@ agents/<agent_name>/
 
 | Agent | 目录 | 说明 |
 |-------|------|------|
-| find_agent | `agents/find_agent/` | 自主扩词、多页搜索,结合分享数据与双侧年龄画像输出正式推荐和人工备选 |
+| find_agent | `agents/find_agent/` | 自主扩词、多页搜索,结合分享数据与双侧年龄画像输出 primary / rejected;不使用视频理解 |
 
 ## 新增 Agent 模板
 

+ 422 - 152
agents/find_agent/PRD.md

@@ -1,10 +1,10 @@
 # find_agent 产品需求文档(PRD)
 
-> 文档版本:v1.0
+> 文档版本:v1.1
 >
-> 基线日期:2026-07-23
+> 基线日期:2026-07-28
 >
-> 文档状态:基于当前代码整理的产品基线,包含目标态要求
+> 文档状态:基于当前代码、近期提交与定向测试整理的产品基线,包含优化项与后续方向
 >
 > Agent 定位:老年受众高潜抖音视频发现 Agent
 
@@ -16,7 +16,8 @@
 本文以当前代码为事实基线,同时将以下两类内容明确分开:
 
 - **当前实现**:仓库中已经存在、运行时实际可用的能力;
-- **目标要求**:为了使 Agent 稳定、可审计地完成业务任务,产品必须达到的状态。
+- **目标要求**:为了使 Agent 稳定、可审计地完成业务任务,产品必须达到的状态;
+- **后续方向**:需要额外数据、服务或业务闭环才能实现的中长期能力。
 
 相关实现入口:
 
@@ -28,6 +29,47 @@
 - 当前说明:[README.md](README.md)
 - 验证记录:[VALIDATION.md](VALIDATION.md)
 
+### 1.1 当前结论摘要
+
+截至 2026-07-28,`find_agent` 已具备“需求装配—多源搜索—详情与双侧画像—评分分池—
+过程落库—下游发布”的主体链路,数据库模型、搜索去重和批量画像等基础能力较完整,
+但仍处于**可运行、未达到稳定无人值守**的阶段。
+
+当前最关键的产品判断如下:
+
+1. **召回与证据基础已经具备**:支持内部搜索、TikHub、作者作品扩展、详情、视频点赞
+   用户画像、作者粉丝画像和年龄桶标准化;
+2. **完成闭环尚未可靠**:完成守卫当前关闭,数据库审计工具没有注册给 Agent,模型
+   可以在证据或流程未闭合时自行结束;
+3. **明确不使用视频理解**:当前相关性和分享动机只依据标题、描述、话题、详情文本、
+   互动数据和画像判断;`qwen_video_analyze.py` 与历史字段即使保留,也不属于在线
+   能力;
+4. **吞吐能力主动收敛**:每日任务固定单线程串行执行,单需求核心运行超时 1800 秒,
+   当日 `primary` 去重视频达到 200 条后停止;稳定性优先于吞吐;
+5. **最终一致性是“尽力而为”而非强保证**:最终文字会再次反向同步数据库分池,但
+   发布或同步超时/失败只写日志,主调用仍可能返回成功;
+6. **最终等级已经收敛**:`pending_evaluation` 仅为过程状态,最终只允许
+   `primary / rejected`,不再设置 `backup`、补充推荐或人工备选。
+
+因此,下一阶段不应继续堆叠新召回源,而应先完成四项 P0:统一当前产品契约、恢复
+确定性完成约束、实现最终结果原子对齐、补齐运行恢复与回归测试。完成 P0 后再扩大
+并发或建设更强的受众与传播数据。
+
+### 1.2 文档边界
+
+- 本文的“已实现”仅指当前 Agent 实际注册、调度或持久化链路可以触达的能力;
+- 代码文件存在但未注册、未启用或无法被当前流程调用的能力,统一标为“保留代码/
+  暂停能力”,不计入现状完成度;
+- 指标中的目标值是产品上线门槛,不代表当前已经达到;
+- 外部接口真实可用性仍需以对应环境的密钥、网络和数据权限为准。
+
+### 1.3 修订记录
+
+| 版本 | 日期 | 说明 |
+|---|---|---|
+| v1.0 | 2026-07-23 | 建立产品目标、决策公理、工具、数据模型与验收基线 |
+| v1.1 | 2026-07-28 | 按近期代码更新视频理解、单线程、超时与最终同步现状;补充风险、优化优先级、指标、里程碑和待确认决策 |
+
 ## 2. 产品概述
 
 ### 2.1 业务问题
@@ -50,9 +92,9 @@
 1. 理解需求真实意图;
 2. 自主生成多个搜索假设;
 3. 从多个来源召回、翻页和扩展候选视频;
-4. 核验视频详情、互动数据、双侧年龄画像和真实内容
+4. 核验视频详情、互动数据、双侧年龄画像和可验证的内容字段
 5. 对需求相关性、老年受众倾向和分享价值进行独立判断;
-6. 输出并持久化主推荐、补充推荐和淘汰候选;
+6. 输出并持久化主推荐和淘汰候选;
 7. 保存可恢复、可解释、可审计的完整发现过程。
 
 优先产出至少 5 条质量可靠的保留视频;5 条是探索目标,不是降低准入质量的硬指标。
@@ -73,7 +115,7 @@
 | 内容供给运营 | 针对高优需求快速获得可用视频及推荐理由 |
 | 业务分析人员 | 查看某条需求搜过什么、遗漏什么、为何保留或淘汰 |
 | SupplyAgent 调度任务 | 批量处理每日 S/A 需求并持久化结果 |
-| 下游内容系统 | 消费结构化主推荐和补充推荐 |
+| 下游内容系统 | 消费结构化主推荐 |
 | 开发与运维人员 | 定位外部接口、模型、持久化或流程提前结束问题 |
 
 ### 3.2 核心场景
@@ -95,10 +137,10 @@
 - 视频详情和播放地址核验;
 - 视频点赞用户画像与作者粉丝画像;
 - 年龄桶标准化;
-- 视频内容解析
+- 基于标题、描述、话题和详情字段的内容判断
 - `R / E / S / V` 评分、解释与分池;
 - 搜索过程、候选证据和最终结果持久化;
-- 双池结果报告和最终文字—数据库同步;
+- `primary / rejected` 结果报告和最终文字—数据库同步;
 - 外部接口失败时的可解释降级。
 
 ### 4.2 范围外
@@ -109,7 +151,8 @@
 - 将点赞用户画像表述为转发用户画像;
 - 替代上游需求分级;
 - 当前阶段自动使用线上 ROV/VOV 重新训练或校准评分;
-- 当前阶段对推荐视频进行人工审核排队。
+- 当前阶段对推荐视频进行人工审核排队;
+- 当前在线链路中的视频画面、语音或字幕理解;相关工具代码仍保留,但已暂停注册。
 
 ## 5. 核心概念
 
@@ -125,8 +168,7 @@
 | 候选 | 任一搜索页召回并按 `aweme_id` 幂等合并的视频 |
 | 双侧画像 | 视频点赞用户年龄画像与作者粉丝年龄画像 |
 | 主推荐 | 需求相关性、老年倾向、分享价值均有可靠证据的候选 |
-| 补充推荐 | 相关性较弱或不成立,但老年倾向和分享价值同时很强的候选 |
-| 淘汰候选 | 证据显示不应保留,或补证后仍不能满足任一保留池的候选 |
+| 淘汰候选 | 补证后仍不能满足 `primary` 准入条件的候选 |
 
 ## 6. 决策对象与目标函数
 
@@ -154,7 +196,7 @@
 相关性是主推荐的准入条件,不是普通加分项。
 
 - 高分享、老年受众明显但不回答本次需求的视频,不能进入主推荐;
-- 若其 `E` 与 `S` 均强,应保留到补充推荐,而不是直接丢弃
+- 即使 `E` 与 `S` 均强,只要 `R` 不成立,也必须进入 `rejected`
 - 搜索词命中不等于内容相关,必须由标题、详情或内容核验提供相关性证据;
 - 参考标题和相关点用于理解意图,不要求候选逐字匹配。
 
@@ -218,8 +260,8 @@
 `R / E / S` 任一维度接近零都会显著压低 `V`。三个弱证据不能通过简单相加伪装成一个
 强结论。
 
-- 主推荐必须同时通过 `R / E / S` 的证据判断;
-- 补充推荐不是“低质量主推荐”,而是相关性受限但 `E / S` 双强的独立池
+- `primary` 必须同时通过 `R / E / S` 的证据判断;
+- 未同时通过三项判断的候选必须进入 `rejected`,不设中间等级
 - Agent 对分池负责,持久化工具不应偷偷重算评分或改写分池;
 - 5 条是结果目标,不是放宽质量边界的理由。
 
@@ -242,7 +284,7 @@
 
 ### 7.10 信息价值停止律
 
-只有当额外搜索、详情、画像或内容解析可能改变准入、排序或置信度时,才继续调用。
+只有当额外搜索、详情或画像可能改变准入、排序或置信度时,才继续调用。
 
 - 证据足以区分候选时停止;
 - 合理搜索前沿耗尽后,允许少于 5 条结束;
@@ -251,24 +293,24 @@
 
 ## 8. 产品设计原则
 
-1. **证据先于直觉**:模型解释必须锚定搜索结果、详情、画像或内容解析
+1. **证据先于直觉**:模型解释必须锚定搜索结果、详情、画像或可验证的内容字段
 2. **事实与推断分离**:明确区分接口事实、计算结果和模型判断;
 3. **未知不等于否定**:缺失、失败和样本不足通过未知与置信度表达;
 4. **过程优先可审计**:每次搜索、翻页、扩展、补证和分池都必须可还原;
 5. **决策权单一**:Agent 负责最终分池,工具负责保存,不在多个位置重复解释规则;
 6. **状态可恢复**:长任务可以通过 `run_id` 从数据库恢复,而不依赖单次模型上下文;
 7. **幂等去重**:同一运行内以 `(run_id, aweme_id)` 唯一,同一搜索页重复保存不新增候选;
-8. **成本逐层增加**:先搜索和互动预筛,再详情与画像,最后只解析最有信息价值的视频
+8. **成本逐层增加**:先搜索和互动预筛,再只为高潜候选补充详情与画像;
 9. **失败可降级**:单一供应方失败不应让整次任务直接失去业务结果;
-10. **输出与数据库一致**:最终报告中的主推荐/补充推荐必须与最终持久化分池一致。
+10. **输出与数据库一致**:最终报告中的主推荐和淘汰候选必须与最终持久化分池一致。
 
 ## 9. 端到端流程
 
-### 9.1 流程图
+### 9.1 当前实现主流程图
 
 ```mermaid
 flowchart TD
-    A["读取业务日 S/A 需求"] --> B["聚合同一需求下全部参考视频与相关点"]
+    A["读取业务日全部 S/A 需求"] --> B["聚合同一需求下全部参考视频与相关点"]
     B --> C{"是否已有 running / finished 运行"}
     C -->|"是且非强制重跑"| C1["跳过并记录原因"]
     C -->|"否或强制重跑"| D["预创建 video_discovery_run"]
@@ -277,27 +319,25 @@ flowchart TD
     F --> G["内部搜索 / TikHub 搜索"]
     G --> H["保存搜索页并按 aweme_id 幂等并入候选"]
     H --> I["候选状态 pending_evaluation"]
-    I --> J["按相关性、分享规模/效率、补充潜力廉价预筛"]
-    J --> K["批量拉取详情,默认最多 8 条"]
+    I --> J["按相关性、分享规模/效率、主推荐潜力廉价预筛"]
+    J --> K["批量拉取详情,每批最多 8 条"]
     K --> L["批量获取视频点赞画像 + 作者粉丝画像"]
     L --> M["标准化年龄桶并识别一致、冲突或缺失"]
-    M --> N["对最可能改变决策的候选做视频内容解析,默认最多 3 条"]
+    M --> N["基于标题、描述、话题、详情和画像形成内容判断"]
     N --> O["计算 R / E / S / V,形成证据理由与置信度"]
     O --> P{"分池判断"}
     P -->|"三维共同成立"| P1["primary"]
-    P -->|"E/S 双强但 R 弱"| P2["backup"]
-    P -->|"不满足保留条件"| P3["rejected"]
+    P -->|"任一维不成立"| P2["rejected"]
     P1 --> Q["保存候选证据与分池"]
     P2 --> Q
-    P3 --> Q
     Q --> R{"是否仍有高信息价值前沿"}
     R -->|"翻页"| G
     R -->|"标签扩展"| G
     R -->|"作者扩展"| G
     R -->|"没有"| S["保存 finished 与停止原因"]
     S --> T["查询最终数据库状态"]
-    T --> U["输出主推荐、补充推荐、淘汰候选、搜索树和缺失数据"]
-    U --> V["按最终报告再次同步主/补充分池"]
+    T --> U["输出主推荐、淘汰候选、搜索树和缺失数据"]
+    U --> V["按最终报告再次同步 primary/rejected(当前为尽力而为)"]
 ```
 
 ### 9.2 阶段说明
@@ -318,6 +358,13 @@ flowchart TD
 4. 预创建 `video_discovery_run`;
 5. Agent 第一项动作必须复用系统给定 `run_id`。
 
+当前限制:
+
+- `running` 与 `finished` 都会被默认跳过,缺少运行租约和心跳,异常遗留的
+  `running` 记录可能长期阻塞重试;
+- `--force` 复用原 `run_id` 并重置运行状态,但不会先创建新的执行代次,也不会清空
+  旧搜索页和旧候选,存在新旧证据混合的风险。
+
 #### 阶段 3:意图理解与根搜索
 
 1. 综合 `demand_word`、全部参考视频和全部相关点;
@@ -340,7 +387,7 @@ flowchart TD
 1. 所有来源统一为 `search_results`;
 2. 以 `aweme_id` 做跨词、跨页、跨供应方幂等合并;
 3. 新候选进入 `pending_evaluation`;
-4. 先根据标题相关性、互动量和补充潜力预筛;
+4. 先根据标题相关性、互动量和主推荐潜力预筛;
 5. 默认最多 8 条进入详情和双画像阶段。
 
 #### 阶段 6:证据补全
@@ -350,15 +397,16 @@ flowchart TD
 3. 批量画像自动输出标准化年龄结果;
 4. 内容画像缺失时,作者画像只能作为账号先验;
 5. 画像冲突、缺失和失败必须原样保存;
-6. 默认最多 3 条调用视频内容解析;
-7. 内容解析提示必须包含本次需求和相关点,并区分明确呈现与模型推断。
+6. 当前仅使用标题、描述、话题、正文等文本字段理解内容;
+7. 当前不得把未观看的视频画面、语音或字幕写成已经核验的事实;
+8. `qwen_video_analyze` 即使保留代码也不属于当前能力,流程不得调用。
 
 #### 阶段 7:评分与分池
 
 1. 独立形成 `R / E / S`;
 2. 计算 `V` 并给出置信度;
 3. 输出每一维的正证、反证和限制;
-4. 分为 `primary / backup / rejected`;
+4. 最终只分为 `primary / rejected`;
 5. 对新增或证据变化的候选重新保存;
 6. 保留数量不足 5 条时,优先继续高价值搜索前沿。
 
@@ -368,7 +416,10 @@ flowchart TD
 2. 保存停止原因;
 3. 查询最终状态,确保搜索与候选完整;
 4. 输出最终报告;
-5. 运行结束后,从最终报告的主推荐和补充推荐段提取 `aweme_id`,再次同步数据库分池。
+5. 运行结束后,从最终报告的主推荐和淘汰候选段提取 `aweme_id`,再次同步数据库分池。
+
+第 5 步当前不是事务性提交:同步超时或异常只记录日志,调用方仍会取得 Agent 结果;
+目标态必须改为可校验、可重试、可原子提交的最终化流程。
 
 ## 10. 状态模型
 
@@ -388,15 +439,14 @@ running ──正常完成──> finished
 搜索召回
 pending_evaluation
-   ↓ 详情、画像、内容、评分
+   ↓ 详情、画像、文本证据、评分
    ├── primary
-   ├── backup
    └── rejected
 ```
 
 - `pending_evaluation` 不能直接作为最终推荐;
 - 候选补充新证据后允许重新评估和改变分池;
-- 最终报告中的主推荐与补充推荐对数据库对应分池具有同步作用。
+- 最终报告中的主推荐与淘汰候选对数据库对应分池具有同步作用。
 
 ### 10.3 搜索图
 
@@ -462,7 +512,7 @@ pending_evaluation
 - 正式保留候选必须尝试核验详情;
 - 单次详情工具最多处理 8 条;
 - 部分视频失败时必须返回成功项与失败项,不因单项失败丢弃整批;
-- 详情应尽量提供页面链接、播放地址、作者、话题和互动数据。
+- 详情应尽量提供页面链接、作者、话题、文本和互动数据;播放地址不用于视频理解
 
 ### FR-08 双侧年龄画像
 
@@ -474,13 +524,16 @@ pending_evaluation
 - 仅有作者画像时,`E` 置信度必须受限;
 - 双侧冲突时必须明确标注并降低置信度。
 
-### FR-09 视频内容核验
+### FR-09 内容核验
+
+当前版本要求:
 
-- 有可用播放地址且内容核验会影响决策时,应调用视频解析;
-- 默认最多解析 3 条最有信息价值的候选;
-- 解析提示必须包含本次需求与相关点;
-- 内容解析不能代替年龄画像;
-- 超长视频默认截断到 180 秒,截断失败或依赖缺失必须返回明确错误。
+- 相关性与分享动机依据搜索描述、详情标题、正文、话题标签和互动数据判断;
+- 必须区分接口返回的事实、由文本计算出的指标和模型推断;
+- 当前不使用视频画面、语音、字幕或多模态解析,不得声称已经看见某个画面、听见某句
+  话或核验完整剧情;
+- 文本信息不足以支持高置信相关性时,应降低 `R` 或置信度,而不是用常识补全;
+- 持久化契约不保存视频播放地址、内容分析结论或视频理解核验标记。
 
 ### FR-10 评分与解释
 
@@ -490,13 +543,13 @@ pending_evaluation
 - 分数缺失时不得伪造;
 - 缺失证据不应自动变成零分。
 
-### FR-11 双池分流
+### FR-11 最终分流
 
 - `primary`:`R / E / S` 三个命题均成立;
-- `backup`:不满足主推荐,但 `E / S` 同时强,并明确相关性限制
-- `rejected`:不满足任一保留池,或强反证足以否定
-- `backup` 不得在报告中伪装成主推荐
-- 低相关但高传播候选必须在淘汰前先评估其补充潜力
+- `rejected`:未满足 `primary` 的任一候选
+- `pending_evaluation` 仅用于搜索后的过程状态,`finished` 时不得残留
+- 不存在 `backup`、补充推荐、人工备选或其他中间等级
+- 低相关但高传播、偏老年的候选仍必须进入 `rejected`
 
 ### FR-12 探索与停止
 
@@ -509,7 +562,7 @@ pending_evaluation
 ### FR-13 持久化与恢复
 
 - 必须保存运行、搜索页和候选三个粒度的数据;
-- 候选记录必须包含详情、互动、双侧画像、标准化结果、内容分析、评分、理由和分池;
+- 候选记录必须包含详情、互动、双侧画像、标准化结果、文本证据、评分、理由和分池;
 - 查询状态必须可恢复搜索树与候选池;
 - 数据库不可用时只尝试一次初始化写入,之后以内存结构继续业务流程;
 - 降级结果必须醒目标注“未持久化”及原因。
@@ -520,12 +573,11 @@ pending_evaluation
 
 1. 一句话需求意图理解;
 2. 主推荐;
-3. 补充推荐;
-4. 最有竞争力但被淘汰的候选;
-5. 搜索树;
-6. 缺失数据、画像冲突、未继续前沿和接口失败。
+3. 淘汰候选及淘汰理由;
+4. 搜索树;
+5. 缺失数据、画像冲突、未继续前沿和接口失败。
 
-每条主推荐或补充推荐必须包含:
+每条主推荐必须包含:
 
 - 排名、标题、作者、抖音页面链接、`aweme_id`;
 - 命中的需求点与相关性证据;
@@ -541,8 +593,8 @@ pending_evaluation
 ### FR-15 最终一致性
 
 - 报告中主推荐的 ID 必须属于数据库 `primary`;
-- 报告中补充推荐的 ID 必须属于数据库 `backup`;
-- 淘汰候选不能出现在补充推荐段
+- 报告中淘汰候选的 ID 必须属于数据库 `rejected`;
+- `finished` 运行不得包含 `backup` 或 `pending_evaluation`
 - 最终文字分池与数据库不一致时,系统必须阻止完成或产生显式错误;
 - 最终分池同步必须可观测,失败不能静默吞掉。
 
@@ -558,7 +610,6 @@ pending_evaluation
 | `get_account_fans_portrait` | 单条作者粉丝画像 | 只作为账号受众先验 |
 | `batch_fetch_portraits` | 批量获取双侧画像 | 单次最多 8 条;正式候选设置作者画像为真 |
 | `normalize_age_portraits` | 确定性标准化年龄桶 | 不替代业务评分 |
-| `qwen_video_analyze` | 核验真实内容与分享动机 | 默认最多 3 条;不能替代年龄画像 |
 | `create_video_discovery_run` | 创建或复用运行 | 调度场景必须复用预创建 `run_id` |
 | `record_video_search_page` | 保存搜索页并合并候选 | 所有搜索页都必须保存 |
 | `batch_save_video_candidate_evaluations` | 保存证据、评分和分池 | 原样保存,不重算、不改池 |
@@ -566,6 +617,9 @@ pending_evaluation
 
 当前共享基础工具还会注册 `load_skill`,但它不是本 Agent 的核心业务链路。
 
+`qwen_video_analyze` 的历史实现文件即使仍在仓库中,也未注册到 `find_agent`,没有
+`video_analysis` 工具预算,当前 Prompt、状态恢复、审计和测试均不得依赖它。
+
 ## 13. 数据模型
 
 ### 13.1 `video_discovery_run`
@@ -576,7 +630,7 @@ pending_evaluation
 - `demand_word`、参考视频和相关点输入快照;
 - Agent 的最终意图解释;
 - `running / finished / failed` 状态;
-- 搜索页数、主推荐数、补充推荐数;
+- 搜索页数和主推荐数;
 - 停止原因或失败原因。
 
 ### 13.2 `video_discovery_search`
@@ -600,10 +654,12 @@ pending_evaluation
 - 来源词、来源搜索和标签;
 - 互动数据;
 - 视频点赞年龄证据、作者粉丝年龄证据和标准化结果;
-- 详情、画像、年龄标准化和内容解析是否已执行;
-- 内容分析和扩展价值标签;
+- 详情、画像、年龄标准化是否已执行;
+- 扩展价值标签;
 - `R / E / S / V`、置信度和各维理由;
-- `primary / backup / rejected / pending_evaluation` 分池。
+- 最终 `primary / rejected` 分池,以及过程状态 `pending_evaluation`。
+
+当前模型不包含视频播放地址、内容分析结论或视频理解核验标记。
 
 ## 14. 成本与运行预算
 
@@ -612,23 +668,26 @@ pending_evaluation
 | 项目 | 默认约束 |
 |---|---|
 | Agent 最大迭代 | 60 |
+| 单需求核心运行超时 | 1800 秒(环境变量可配) |
+| 日志生成/OSS 发布等待 | 最多 120 秒,当前失败后继续返回 |
 | 搜索类工具调用 | 最多 10 次 |
 | 详情工具调用 | 最多 2 次,每次最多 8 条 |
 | 画像工具调用 | 最多 3 次,每批最多 8 条 |
-| 视频内容解析 | 最多 3 次 |
 | 候选评估保存 | 最多 6 次 |
 | 状态查询 | 最多 8 次,且无状态变化时禁止无意义重复 |
 | 根搜索词 | 默认 2~3 个 |
 | 生产性首页翻页 | 默认继续 1 页 |
 | 正式补证候选 | 默认最多 8 条 |
-| 视频解析候选 | 默认最多 3 条 |
+| 每日批处理并发 | 固定 1;`workers` 参数当前被忽略 |
+| 每日停止上限 | `primary` 按 `aweme_id` 去重达到 200 条 |
 
 ### 14.2 预算原则
 
 - 预算是防止无界探索的上限,不是必须耗尽的配额;
 - 优先使用批量详情和批量画像;
-- 只有可能改变分池或置信度时才做视频解析;
-- 外部接口存在串行限流,搜索深度必须服从信息价值。
+- 外部接口存在 1 秒或约 10.1 秒的进程内串行限流,搜索深度必须服从信息价值;
+- 单需求最长 30 分钟且每日单线程执行,当前无法据此承诺每日全量 S/A 都能在固定
+  时间窗内完成,P0 后必须先测基线再制定吞吐 SLA。
 
 ## 15. 异常与降级策略
 
@@ -640,11 +699,12 @@ pending_evaluation
 | 详情部分失败 | 保留成功项和逐条失败原因 |
 | 内容画像缺失 | 尝试作者画像,标记 `account_only`,降低置信度 |
 | 双侧画像冲突 | 优先视频画像,显式标注冲突 |
-| 视频无播放地址 | 不强制内容解析,保存缺失状态 |
-| 视频解析失败 | 保留原始错误,不用模型常识补写内容 |
+| 文本字段不足 | 降低相关性置信度,不用模型常识补写视频内容 |
 | 数据库不可用 | 一次失败后转内存流程,最终披露未持久化 |
 | Agent 或模型异常 | 运行标记 `failed` 并保存异常原因 |
-| 最终分池同步失败 | 记录错误并将任务视为未完整完成 |
+| 运行超时 | 关闭异步客户端并将调度运行标记为 `failed` |
+| 发布或最终分池同步失败 | 当前仅记录日志并返回;目标态必须将任务标记为未完整完成 |
+| 遗留 `running` 运行 | 当前默认跳过;目标态通过租约过期与恢复机制接管 |
 
 ## 16. 非功能需求
 
@@ -678,12 +738,21 @@ pending_evaluation
 
 - API Key、数据库密码和 OSS 密钥只能从环境配置读取;
 - 日志和报告不得输出完整密钥;
-- 长视频临时文件必须在处理后清理;
 - 公网播放地址和 OSS 地址应遵守数据授权与访问控制要求。
 
 ## 17. 成功指标
 
-### 17.1 业务指标
+### 17.1 北极星指标
+
+**需求级有效供给率**:
+
+`至少产生 1 条经人工抽检可直接进入后续供给链路的 primary 视频的 finished 需求数
+/ 完成抽检的 finished 需求数`
+
+该指标同时约束“是否找到”“是否相关”“是否偏老年”“是否值得传播”,比单纯统计每次
+保留 5 条更能代表产品价值。
+
+### 17.2 业务指标
 
 | 指标 | 定义 |
 |---|---|
@@ -691,22 +760,53 @@ pending_evaluation
 | 主推荐需求准确率 | 抽检中真正承接需求的主推荐占比 |
 | 老年证据有效率 | 保留候选中具有有效视频或作者年龄画像的占比 |
 | 分享证据完整率 | 保留候选中同时具有分享规模/效率和内容动机说明的占比 |
-| 有效保留数 | 每次运行 `primary + backup` 数量及分布 |
-| 补充池有效率 | 补充候选经业务抽检确认值得保留的占比 |
+| 有效保留数 | 每次运行 `primary` 数量及分布 |
 | 推荐多样性 | 保留结果覆盖的不同需求点和分享动机数量 |
 
-### 17.2 流程指标
+### 17.3 流程指标
 
 | 指标 | 定义 |
 |---|---|
 | 搜索页持久化率 | 已调用搜索页中成功保存的占比 |
-| 候选证据完整率 | 保留候选完成详情、双画像尝试、年龄标准化和必要内容核验的占比 |
+| 候选证据完整率 | 保留候选完成详情、双画像尝试、年龄标准化和必要文本核验的占比 |
 | 最终分池一致率 | 最终报告与数据库分池完全一致的运行占比 |
 | 重复候选率 | 去重前重复候选占比,用于观察搜索冗余 |
 | 搜索新增效率 | 每个搜索页新增有效候选数 |
 | 降级成功率 | 单供应方失败后仍成功完成任务的占比 |
 | 平均运行时长 | 从运行创建到最终完成的耗时 |
-| 单任务工具成本 | 各类搜索、详情、画像和解析调用次数 |
+| 单任务工具成本 | 各类搜索、详情、画像、保存和查询调用次数 |
+
+### 17.4 P0 上线门槛
+
+下列是目标值,不代表当前已达到:
+
+| 指标 | P0 上线门槛 | 统计要求 |
+|---|---:|---|
+| 定向与回归测试通过率 | 100% | 禁止保留与当前产品契约冲突的旧断言 |
+| 最终分池一致率 | 100% | 报告、候选表、运行计数三方一致 |
+| 搜索页持久化率 | 100% | 成功、空页、失败页均计入 |
+| `finished` 运行待评估清零率 | 100% | 不允许 `pending_evaluation` 遗留 |
+| 保留候选详情尝试率 | 100% | 成功或明确失败均视为已尝试 |
+| 保留候选双侧画像尝试率 | 100% | 缺少作者 ID 时必须有明确跳过原因 |
+| 年龄标准化完成率 | 100% | 所有保留候选必须有标准化状态 |
+| 提前结束拦截率 | 100% | 构造不完整流程时守卫必须拒绝完成 |
+| 超时运行正确失败率 | 100% | 运行状态为 `failed`,异步资源被释放 |
+
+### 17.5 P1 质量目标
+
+先以不少于 100 个需求、每个需求至少抽检 Top 5 的标注集建立基线,再冻结目标。建议
+首版门槛如下:
+
+| 指标 | 建议目标 |
+|---|---:|
+| 主推荐需求相关准确率 | ≥ 85% |
+| 主推荐三类证据完整率 | ≥ 95% |
+| Top 5 至少 1 条业务可用率 | ≥ 90% |
+| 相同内容重复占位率 | ≤ 10% |
+| 单需求超时率 | ≤ 2% |
+
+吞吐 SLA 需要在 P0 稳定后实测确定。当前“单任务最多 1800 秒、每日单线程、最多 200
+条通过视频”的组合只是一组保护边界,不等于已经具备可承诺的日批完成时间。
 
 ## 18. 验收标准
 
@@ -718,9 +818,9 @@ pending_evaluation
 2. 创建或复用唯一 `run_id`;
 3. 执行并保存实际搜索页;
 4. 所有召回候选按 `aweme_id` 幂等合并;
-5. 最终保留候选具有足够的详情、画像和内容证据;
+5. 最终保留候选具有足够的详情、画像和文本内容证据;
 6. 所有年龄画像已经标准化;
-7. 候选被明确分为主推荐、补充推荐、淘汰或待评估
+7. 候选被明确分池,且 `finished` 运行不遗留 `pending_evaluation`
 8. 最终运行状态和停止原因已保存;
 9. 报告满足输出契约;
 10. 报告与数据库分池完全一致;
@@ -734,7 +834,7 @@ pending_evaluation
 - 用题材或画面中出现老人代替受众年龄证据;
 - 将点赞用户画像声称为分享用户画像;
 - 把低相关高分享视频放入主推荐;
-- 将符合补充池条件的候选直接丢弃且无理由
+- 将未满足 `R / E / S` 联合条件的候选放入主推荐
 - `pending_evaluation` 直接出现在推荐结果;
 - 新搜索或新证据产生后未重新保存受影响候选;
 - 报告推荐 ID 与数据库分池不一致;
@@ -744,34 +844,78 @@ pending_evaluation
 
 ## 19. 当前实现状态
 
-### 19.1 已实现
-
-- S/A 需求、参考视频和点位的任务装配;
-- 业务日批量执行、并发 worker、跳过已运行任务和强制重跑;
-- 内部关键词、TikHub 和作者作品三类召回;
-- 多源统一候选结构与供应方分页状态;
-- 搜索页和候选的幂等持久化;
-- 详情批量核验;
-- 视频点赞画像、作者粉丝画像和批量双侧画像;
-- 年龄桶确定性标准化;
-- 千问视频内容解析与长视频截断;
-- `R / E / S / V`、理由、完成标记和分池字段;
-- 三张视频发现数据表及状态恢复;
-- 最终报告 ID 提取和主/补充分池同步;
-- 工具调用预算和无状态变化时的重复查询约束。
-
-### 19.2 部分实现
-
-- 搜索图扩展、信息价值停止和双池判断主要依赖模型遵守提示词;
-- 数据库不可用的内存降级由提示词要求,缺少统一的结构化运行时接管;
-- 最终报告会反向同步分池,但同步失败当前只记录日志,不会让调用显式失败;
-- 最终报告同步当前只更新报告中出现的 ID,不会自动清理报告中已省略的旧主/补充候选;
-- `find_agent_completion_guard` 已有实现与测试,但当前 Agent 配置为
-  `completion_guard=None`;
-- 流程审计函数已经实现,但 `audit_video_discovery_process` 和
-  `audit_video_discovery_run` 当前没有注册给 Agent。
-
-### 19.3 尚未实现或仍有关键证据缺口
+### 19.1 能力成熟度
+
+| 能力域 | 当前等级 | 现状判断 |
+|---|---|---|
+| 需求输入装配 | 可用 | 能按需求聚合全部参考视频和有效点位 |
+| 多源召回 | 可用 | 内部搜索、TikHub、作者作品均已接入 |
+| 候选去重与搜索记忆 | 可用 | 搜索页和候选具备数据库幂等键 |
+| 详情与年龄证据 | 可用但有缺口 | 支持详情、双侧画像和年龄桶标准化;内容画像可能缺失 |
+| 内容理解 | 文本证据 | 当前明确只基于文本、互动和画像,不使用视频理解 |
+| 评分与分池 | 部分可用 | 字段和规则齐全,但主要依赖模型遵守长提示词 |
+| 完成审计 | 未上线 | 审计与守卫代码存在,但没有接入当前 Agent |
+| 最终一致性 | 高风险 | 文字反向同步为尽力而为,不是原子提交 |
+| 日批调度 | 可用但低吞吐 | 固定单线程,达到 200 条保留视频后停止 |
+| 运行恢复 | 部分可用 | 可查状态,但缺少租约、执行代次和自动续跑 |
+| 下游供给衔接 | 已接入 | 仅 `primary` 进入 AIGC 计划分配 |
+| 监控与质量评估 | 不完整 | 有日志和基础计数,没有正式 SLA、质量集和仪表盘 |
+
+### 19.2 已实现
+
+- 按业务日读取具备拓展点位的 S/A 需求,聚合同一需求的全部参考视频与点位;
+- Agent 运行前按 `(biz_dt, demand_grade_id)` 预创建唯一运行记录;
+- 内部关键词、TikHub、作者作品三类召回与统一候选结构;
+- 内部搜索约 10.1 秒、TikHub 1 秒、作者/详情约 10.1 秒的进程内限流;
+- 搜索页、分页状态、父子搜索关系和失败原因落库;
+- 以 `(run_id, aweme_id)` 做跨词、跨页、跨来源候选去重;
+- 单次最多 8 条的批量详情与批量双侧画像;
+- 对 `50- / 50+ / 50岁以上 / 41-50` 等年龄桶做确定性标准化;
+- `R / E / S / V`、各维理由、置信度、分池和停止原因字段;
+- `video_discovery_run / search / candidate` 三张表和状态查询;
+- 单需求 60 轮、搜索/详情/画像/保存/查询工具预算;
+- 单需求核心流程 1800 秒超时、异步客户端清理和线程事件循环清理;
+- 日批单线程执行、跳过已有 `running / finished`、支持 `--force`;
+- 当日主推荐去重视频达到 200 条时提前结束;
+- Agent 日志/可视化发布设置 120 秒等待边界;
+- 最终报告中主推荐/淘汰候选 ID 的提取与数据库回写;
+- 下游只读取 `primary` 并创建 AIGC 爬取、生产和发布计划关联。
+
+### 19.3 部分实现或暂停能力
+
+- 搜索图扩展、信息价值停止和最终分流主要依赖模型,不是确定性编排;
+- 数据库不可用的内存降级仅由提示词约束,没有统一结构化状态容器;
+- `find_agent_completion_guard` 已实现但配置为 `completion_guard=None`;
+- `audit_video_discovery_process` 与 `audit_video_discovery_run` 已实现但未注册;
+- 视频理解历史实现和兼容字段仍保留,但不注册、不进入工具输入、状态恢复、审计和测试;
+- 最终报告反向同步失败只写日志,不改变主调用结果;
+- 最终同步只更新报告出现的 ID,不会原子清理被省略的旧候选;
+- `run_find_agent(..., model=...)` 和 `create_find_agent(..., model=...)` 暴露了模型参数,
+  但工厂当前固定使用 `google/gemini-3-flash-preview`,传参不会生效;
+- `--force` 会复用原 `run_id`,旧搜索和候选不会自动隔离;
+- 批任务文档意图是“S 优先于 A、同等级按分数”,当前排序键实际先比较分数再比较
+  等级,契约与实现需要统一。
+
+### 19.4 测试与验证基线
+
+2026-07-28 对当前分支执行:
+
+```text
+.venv/bin/pytest -q \
+  tests/test_find_agent.py \
+  tests/supply_infra/scheduler/test_discover_videos_from_demands.py
+```
+
+当前契约测试 `tests/test_find_agent.py` 为 `14 passed`。视频理解注册/预算的旧断言已
+删除,并新增“审计不要求视频理解”“最终只接受 primary/rejected”“最终文字按
+primary/rejected 解析”的测试。
+
+调度测试为 `1 passed, 1 failed`。剩余失败是既有测试夹具使用普通 `object()` 代替
+`FindDemandContext`,日志读取 `demand_grade_id / demand_name` 时失败;它与本次两项
+确定性修改无关,按范围约束未顺带修复。`VALIDATION.md` 的真实接口与数据库验证日期为
+2026-07-23,不能替代当前版本的完整 Agent 干跑。
+
+### 19.5 尚未实现或仍有关键证据缺口
 
 - 实际转发用户年龄画像;
 - 各年龄段曝光、完播率和观看时长;
@@ -780,63 +924,189 @@ pending_evaluation
 - 标准话题热度与稳定批量话题服务;
 - 评论中的转发动机结构化信号;
 - 推荐内容上线后的真实表现回流与评分校准;
-- 运行时确定性完成守卫;
-- 正式的端到端 SLA、告警和仪表盘。
+- 运行时确定性完成守卫和事务性最终化;
+- 运行租约、心跳、自动恢复和执行代次;
+- 正式的端到端 SLA、告警、质量数据集和仪表盘。
 
 ## 20. 当前主要风险
 
-| 风险 | 影响 | 产品要求 |
+| 优先级 | 风险 | 触发与影响 | 产品要求 |
+|---|---|---|---|
+| P0 | 完成守卫关闭 | 模型可在未评估、未审计或未查最终状态时结束 | 恢复确定性守卫,结束前强制状态机校验 |
+| P0 | 最终文字可创建无证据候选 | 当前回写会对报告中的新 ID 补建候选,模型误写 ID 可能直接进入通过池 | 只允许对本次已召回、已评估候选做最终化 |
+| P0 | 最终分池非原子、增量同步 | 同步异常只记日志;报告省略的旧候选仍可能留在通过池 | 使用事务一次性校验、替换分池并完成运行 |
+| P0 | 遗留 `running` 永久被跳过 | 进程异常后任务没有租约过期机制 | 增加 heartbeat、lease_expires_at 和可恢复状态 |
+| P0 | 强制重跑污染旧状态 | `--force` 复用原运行和子记录,新旧证据混合 | 引入 attempt_id 或创建新 run,并保留父子关系 |
+| P0 | 回归测试漂移 | 产品变更后测试仍验证旧能力,无法提供发布信心 | 先冻结 v1.1 契约,再恢复定向与全量测试全绿 |
+| P1 | 模型参数无效 | 调用方以为可以切模型,实际始终使用固定预览模型 | 尊重显式参数并记录 model/prompt/strategy 版本 |
+| P1 | 排序契约不一致 | S/A 优先级与代码排序键不一致,可能改变日批处理顺序 | 用测试固定业务优先级并修正排序 |
+| P1 | 单线程与串行限流 | 全量 S/A 可能无法在日批窗口内完成 | 建立耗时基线,批量化接口后再逐步增加并发 |
+| P1 | 点赞画像不等于转发画像 | “老年人会分享”只能是概率推断 | 使用谨慎措辞,并优先建设转发画像 |
+| P1 | 内容画像经常缺失 | `E` 可能过度依赖作者先验 | 明确 `account_only`、样本量与置信度上限 |
+| P1 | 文本内容证据有限 | 标题党、口播和画面信息可能被误判 | 明确降低置信度,不引入未授权的视频理解 |
+| P1 | 原始分享数不可跨题材比较 | `S` 排序偏向高曝光题材 | 建设同主题、同时间窗传播基线 |
+| P1 | 硬编码 HTTP 外部地址 | 迁移、证书、权限和环境隔离困难 | 配置化服务地址,生产优先使用受控 HTTPS |
+| P2 | 模型行为依赖长提示词 | 规则迭代难测、易遗漏 | 将流程硬约束下沉,LLM 只负责语义与解释 |
+
+## 21. 需要优化点与迭代优先级
+
+### 21.1 P0:稳定完成与数据一致性
+
+建议周期:1~2 个迭代。P0 完成前不建议扩大日批并发。
+
+1. **恢复确定性完成控制**
+   - 注册 `audit_video_discovery_run`;
+   - 启用并简化 `find_agent_completion_guard`;
+   - 强制顺序为:最后搜索 → 证据保存 → 候选评估 → 审计 → 最终状态查询 → 报告。
+2. **实现事务性最终化**
+   - 新增单一 `finalize_video_discovery_run` 服务;
+   - 只接受本次运行已召回、已评估的候选 ID;
+   - 原子对齐 `primary / rejected`、运行计数、`finished` 和最终摘要;
+   - 禁止从最终文字补建陌生候选;
+   - 同步失败必须对调用方可见并可重试。
+3. **修正运行生命周期**
+   - 增加执行代次、心跳、租约过期时间和失败类型;
+   - 遗留 `running` 可安全接管;
+   - `--force` 创建新 attempt 或显式清理当前 attempt,禁止静默混入旧证据;
+   - 区分 Agent 核心成功、日志发布成功和最终化成功。
+4. **恢复测试门禁**
+   - 增加报告幻觉 ID、旧候选残留、审计不通过、超时、同步失败、强制重跑、
+     stale-running 恢复等负向测试;
+   - 用当前模型与真实依赖完成至少 3 个不同需求的端到端干跑;
+   - 发布门禁要求定向测试和相关全量回归 100% 通过。
+5. **修复配置与优先级契约**
+   - 使 `model` 参数真正生效并记录实际模型版本;
+   - 统一“S 优先还是 score 优先”的业务规则并补测试;
+   - 启动前检查数据库表、TikHub Key、外部服务地址和必要日志配置。
+
+### 21.2 P1:提升质量、效率与可观测性
+
+建议周期:P0 后 2~4 个迭代。
+
+1. **结构化决策**
+   - LLM 输出固定 JSON Schema,程序计算 `V` 并校验 `R/E/S` 范围;
+   - 将搜索计划、候选预筛、证据补全和分池拆成可测试节点;
+   - 为 Prompt、评分策略和模型增加版本号。
+2. **质量评估体系**
+   - 建立不少于 100 个需求的人工标注集;
+   - 分别标注需求相关、老年受众证据、分享动机和最终可用性;
+   - 建立 primary/rejected 混淆矩阵和按题材切片指标。
+3. **搜索与补证效率**
+   - 建设 `batch_douyin_search` 与服务端限流;
+   - 对详情、作者画像和视频画像做短期缓存;
+   - 使用新增有效候选/耗时作为搜索前沿优先级;
+   - 在数据库连接、供应方配额和限流隔离通过压测后逐步恢复并发。
+4. **召回覆盖**
+   - 接入相似视频、稳定话题服务和话题热度;
+   - 将作者扩展、标签扩展、翻页的触发条件改为结构化策略;
+   - 用不同根搜索的边际新增率控制停止。
+5. **可观测性**
+   - 提供需求级耗时瀑布、工具成功率、候选漏斗、证据缺失率和失败类型;
+   - 对超时、stale-running、最终化失败、零结果和单日未达吞吐目标告警;
+   - 展示当前运行使用的模型、Prompt、策略和数据源版本。
+### 21.3 P2:证据升级与业务反馈闭环
+
+建议周期:中长期。
+
+1. 接入实际转发用户年龄画像与样本量;
+2. 接入分年龄曝光、有效播放、完播率和观看时长;
+3. 建设同题材、同发布时间窗口的传播基线;
+4. 接入评论中的提醒、@亲友、收藏和求链接等脱敏意图信号;
+5. 建立需求—推荐视频—AIGC 计划—发布内容—线上 ROV/VOV 的稳定映射;
+6. 分析 primary/rejected 的后验表现,校准评分与置信度;
+7. 将校准参数、特征和阈值版本化,支持回放、灰度和回滚。
+
+## 22. 后续方向
+
+### 22.1 产品形态:从“单个大 Agent”演进为“可控发现工作流”
+
+目标架构应把确定性步骤从 Prompt 中移出:
+
+```text
+需求装配
+  → 搜索计划生成(LLM)
+  → 多源召回(程序)
+  → 候选去重与廉价预筛(程序)
+  → 证据补全(程序)
+  → 语义判断与理由生成(LLM)
+  → 规则校验与分池(程序 + LLM)
+  → 原子最终化(程序)
+  → 质量回流(数据)
+```
+
+LLM 继续负责意图理解、搜索词生成、复杂语义相关性和解释;运行身份、预算、证据完整性、
+状态转换、阈值校验和最终提交由程序控制。这样可以降低模型更换、Prompt 变长和供应方
+异常对流程正确性的影响。
+
+### 22.2 证据体系:从“代理信号”演进为“行为闭环”
+
+证据建设按以下顺序推进:
+
+1. 当前:点赞用户年龄 + 作者粉丝年龄 + 分享数/替代效率 + 文本动机;
+2. 下一步:真实转发用户年龄、样本量、统计周期;
+3. 再下一步:分年龄观看、完播、留存、收藏与评论意图;
+4. 最终:推荐视频进入供给链路后的真实 ROV/VOV 与内容质量回流。
+
+在直接证据补齐前,产品文案必须坚持“更可能观看和分享”,不能升级成“老年人已经在
+分享”的确定性结论。
+
+### 22.3 策略体系:从固定 Prompt 评分演进为版本化策略
+
+- 保留 `R/E/S` 三维可解释框架,但将计算、阈值、证据上限和冲突处理配置化;
+- 每次运行记录 `model_version / prompt_version / strategy_version / data_version`;
+- 用标注集离线评测,再对小流量需求灰度;
+- 所有策略变化支持回放历史 run,并比较候选召回、分池和最终使用差异;
+- 避免把所有新规则继续追加到系统提示词。
+
+### 22.4 平台方向:从抖音单点能力演进为供给发现服务
+
+当抖音链路稳定后,可抽象:
+
+- 统一的搜索供应方协议;
+- 统一的内容、作者、受众和传播证据模型;
+- 跨平台内容去重与相似度;
+- 面向不同人群或业务目标的可配置评分策略;
+- 为运营提供人工复核、原因筛选、重新运行和结果对比界面。
+
+扩平台不是近期 P0。只有当单平台的完成率、准确率、成本和反馈闭环达到门槛后,抽象才
+有稳定依据。
+
+### 22.5 里程碑
+
+| 里程碑 | 核心交付 | 退出条件 |
 |---|---|---|
-| 点赞画像不等于转发画像 | “老年人会分享”只能是概率推断 | 报告必须使用谨慎措辞,并优先建设转发画像 |
-| 内容画像经常缺失 | `E` 过度依赖作者先验 | 明确 `account_only` 和较低置信度 |
-| 完成守卫暂停 | 模型可能在流程未闭合时提前结束 | P0 恢复确定性完成约束 |
-| 审计工具未注册 | 无法在 Agent 内主动验证流程 | P0 注册并要求结束前审计 |
-| 最终文字可反向改池 | 格式错误可能造成数据状态变化 | 同步前做严格校验并保证事务一致性 |
-| 最终分池采用增量同步 | 报告中省略的旧候选可能仍留在主/补充池 | 改为一次运行内的原子全量对齐 |
-| 外部接口串行限流 | 多词、多候选任务时延较高 | 更强预筛、批量接口和明确 SLA |
-| 原始分享数不可跨题材比较 | `S` 排序可能偏向高曝光题材 | 建设同主题传播基线 |
-| TikHub 为可选依赖 | 环境遗漏时搜索覆盖下降 | 配置预检、清晰降级与环境样例补全 |
-| 模型行为依赖长提示词 | 规则可能被遗漏 | 将硬性流程约束下沉到确定性代码 |
-
-## 21. 迭代优先级
-
-### P0:稳定完成与数据一致性
-
-1. 注册数据库审计工具;
-2. 恢复并完善 `find_agent_completion_guard`;
-3. 强制最后一次搜索、证据变更、评估、审计和状态查询满足时序要求;
-4. 最终报告同步前校验 ID 与分池,并以一次运行为范围原子替换最终双池;
-5. 同步失败时显式返回未完成,而不是只写日志;
-6. 为所有外部依赖增加启动前配置检查;
-7. 增加端到端成功、提前停止、数据库降级和分池错位验收。
-
-### P1:提升结论可靠性
-
-1. 接入实际转发用户年龄画像;
-2. 接入受众留存、完播率和观看时长;
-3. 实现多关键词批量搜索与服务端限流;
-4. 接入平台相似视频;
-5. 接入标准话题与话题热度;
-6. 用同题材传播基线校准分享规模和效率。
-
-### P2:建立反馈闭环
-
-1. 建立需求—推荐视频—最终使用内容的映射;
-2. 回收真实 ROV/VOV 与内容质量信息;
-3. 分析主推荐、补充推荐和淘汰候选的后验表现;
-4. 基于历史样本校准 `R / E / S` 与置信度;
-5. 将校准参数纳入版本化策略,而不是继续写入提示词。
-
-## 22. 产品完成定义
+| M0 契约对齐 | 不使用视频理解、仅 `primary/rejected`、测试/审计/文档统一 | 相关回归全绿 |
+| M1 稳定版 | 完成守卫、原子最终化、运行租约、可重试 | P0 上线门槛全部达标 |
+| M2 质量版 | 标注集、结构化决策、批量搜索、仪表盘 | P1 质量目标达到并稳定两周 |
+| M3 证据版 | 转发画像、观看质量、传播基线 | 老年与分享结论具备更直接证据 |
+| M4 闭环版 | AIGC/发布表现回流、策略校准与灰度 | 可以用后验表现持续优化策略 |
+
+## 23. 待确认的业务决策
+
+| 决策项 | 当前行为 | 推荐默认口径 |
+|---|---|---|
+| 每日 200 条统计范围 | `primary` 去重计数 | 保持只统计可直接消费的 `primary` |
+| 日批优先级 | 代码实际先按 score,再按 S/A | 先 S 后 A,同等级按 score 降序 |
+| 200 条达标后的覆盖策略 | 达标即停止,后续需求不处理 | 至少完成全部 S 级,或为需求设置覆盖配额后再按总量停止 |
+| 最终结果权威源 | 模型文字可反向修改数据库 | 结构化最终化结果是权威源,文字仅由该结果渲染 |
+| 5 条目标 | 每个需求优先保留至少 5 条 | 保持软目标;任何证据门槛不得因数量降低 |
+| 模型选择 | 固定预览模型,传参无效 | 可配置稳定模型,并为模型/Prompt/策略版本留痕 |
+
+日批优先级和 200 条达标后的覆盖策略会直接改变业务产出,应由产品、供给运营和下游
+AIGC 负责人共同确认。其他项可按推荐默认口径推进。
+
+## 24. 产品完成定义
 
 `find_agent` 达到产品化完成状态,需要同时满足:
 
 - 对每日 S/A 需求可以稳定自动执行;
 - 任务过程可恢复、可解释、可审计;
 - 推荐建立在需求、年龄和分享三类独立证据上;
-- 主推荐、补充推荐和淘汰候选的边界清晰;
+- 主推荐和淘汰候选的边界清晰,不存在中间等级;
 - 合理前沿耗尽时能够停止,数量不足时不降低质量;
 - 外部接口失败时可解释降级;
 - 最终报告与持久化结果始终一致;
 - 运行时能够确定性阻止提前结束和不完整输出;
+- 强制重跑、进程异常和超时不会产生永久阻塞或新旧状态混合;
+- 当前功能契约、测试、Prompt、文档和监控口径保持一致;
 - 后续可以将真实内容表现回流到需求供给闭环中。

+ 21 - 16
agents/find_agent/README.md

@@ -18,53 +18,58 @@ TikHub 翻页必须同时沿用 `next_cursor、search_id、backtrace`。
 使用 TikHub 前需在项目 `.env` 配置 `TIKHUB_API_KEY`;未配置时 Agent 会记录失败并
 回退到内部关键词搜索。
 
-另外注册了个无外部依赖的决策辅助工具:
+另外注册了个无外部依赖的决策辅助工具:
 
 - `normalize_age_portraits`:识别真实接口中的 `50-` 等年龄桶,统一视频和作者证据;
 
-结果分为两个互不混排的池:
+当前流程明确不使用视频理解,不调用视频画面、语音、字幕或多模态解析工具。内容相关性
+与分享动机仅依据标题、描述、话题、详情文本、互动数据和画像判断。数据库不保存视频
+播放地址、内容分析结论或视频理解核验标记。
 
-- `primary`:与需求相关,且老年倾向、分享价值达到主推荐边界;
-- `backup`:未进入主推荐,但老年倾向和分享价值达到保留线,由 Agent 自动保留。
+最终结果只分为两个等级:
 
-最终结果优先保证至少 5 条,可以更多。优先使用 `primary`;不足 5 条时,由 Agent
-按 `V` 从 `backup` 选择质量最好的补充项,并明确标注限制
+- `primary`:与需求相关,且老年倾向、分享价值达到主推荐边界;
+- `rejected`:未满足 `primary` 的候选
 
-5 条是优先目标,不是硬性门槛。合理搜索后确实没有足够的合格视频时,可以少于 5 条
-结束,不会为了凑数保留明显低质内容。流程不再等待人工审核。
+`pending_evaluation` 只是搜索召回后的过程状态,不是最终等级。不存在 `backup`、
+补充推荐或人工备选。最终优先产出至少 5 条 `primary`,可以更多;5 条不是硬门槛,
+合理搜索后不足时可以少于 5 条结束,不会为了凑数降低准入标准。
 
 ## 已实现的搜索记忆
 
 执行 `.venv/bin/python -m supply_infra.db` 后会创建三张表:
 
+已有表升级时执行 `sql/video_discovery_drop_unused_columns.sql`,删除不再使用的
+`backup_count、video_url、content_analysis、content_analysis_verified`。
+
 | 表 | 粒度 | 用途 |
 |---|---|---|
 | `video_discovery_run` | 一次找片任务 | 保存输入、意图解释、状态、搜索数和分池数量 |
 | `video_discovery_search` | 一个关键词或作者的一页结果 | 保存 Agent 实际搜索词、形成原因、标签/作者/翻页来源、供应方分页状态和新增候选数 |
-| `video_discovery_candidate` | 一次任务中的一条视频 | 保存详情、来源关键词、标签、互动量、双侧年龄证据、内容核验、R/E/S/V 和 Agent 分池 |
+| `video_discovery_candidate` | 一次任务中的一条视频 | 保存详情、来源关键词、标签、互动量、双侧年龄证据、R/E/S/V 和 Agent 分池 |
 
-已注册个持久化工具:
+已注册个持久化工具:
 
 - `create_video_discovery_run`:创建运行并取得 `run_id`;
 - `record_video_search_page`:保存每次搜索和翻页,幂等合并 `aweme_id`;
 - `batch_save_video_candidate_evaluations`:原样保存 Agent 给出的证据、评分和分池;
-- `query_video_discovery_state`:恢复搜索树、主推荐池和补充候选池
+- `query_video_discovery_state`:恢复搜索树、主推荐和淘汰候选
 
 候选分池完全由 Agent 决定:
 
-- `pending_evaluation`:搜索已经召回,但 Agent 尚未完成详情、画像、内容核验和评分;
+- `pending_evaluation`:搜索已经召回,但 Agent 尚未完成详情、画像和评分;
 - `primary`:Agent 决定的主推荐;
-- `backup`:Agent 决定保留的补充推荐;
 - `rejected`:Agent 决定淘汰的候选。
 
 状态流固定为:
 
-`搜索召回 → pending_evaluation → Agent补证和评分 → primary / backup / rejected`。
+`搜索召回 → pending_evaluation → Agent补证和评分 → primary / rejected`。
 
-`pending_evaluation` 不能直接进入最终主推荐或补充推荐
+`pending_evaluation` 不能作为最终结果
 
 保存工具不会重算 `R/E/S/V`、限制年龄分,也不会修改 Agent 给出的
-`decision_bucket`。最终报告中列为主推荐或补充推荐的视频会同步到对应数据库分池。
+`decision_bucket`;它只校验最终等级必须是 `primary / rejected`。最终报告中的主推荐
+和淘汰候选会同步到对应数据库分池。
 
 ## 建议补充的外部数据工具
 

+ 29 - 17
agents/find_agent/VALIDATION.md

@@ -1,6 +1,14 @@
 # find_agent 工具验证报告
 
-验证日期:2026-07-23
+真实接口验证日期:2026-07-23
+
+当前契约同步日期:2026-07-28
+
+> 当前 `find_agent` 明确不使用视频理解。`qwen_video_analyze` 未注册,视频画面、语音、
+> 字幕及多模态解析不属于在线能力。数据库和 ORM 不包含视频理解相关列。
+>
+> 最终等级只允许 `primary / rejected`;`pending_evaluation` 仅为过程状态,不存在
+> `backup`、补充推荐或人工备选。
 
 ## 验证口径
 
@@ -10,7 +18,12 @@
 
 ## 本轮回归结果
 
-- find_agent 与 LLM 重试定向测试:`19 passed`;
+- 2026-07-28 当前契约定向测试:`tests/test_find_agent.py` 及模型列、超时回收测试为
+  `16 passed`;
+- 调度定向测试为 `1 passed, 1 failed`;剩余失败是既有测试夹具使用普通 `object()`
+  代替 `FindDemandContext`,日志读取 `demand_grade_id / demand_name` 时失败,与本次
+  “不使用视频理解、仅 primary/rejected”修改无关,本次未越界修复;
+- 2026-07-23 历史 find_agent 与 LLM 重试定向测试:`19 passed`;
 - Ruff 与 `git diff --check`:通过;
 - 三张 MySQL 表结构、列、唯一键和索引:真实检查通过;
 - 持久化测试数据:按专用 `run_id` 全部清理,残留数为0;
@@ -32,7 +45,7 @@
 - 27次工具调用;
 - 3个自主搜索词;
 - 6个已保存搜索页,并执行了分页;
-- 候选详情、批量双侧画像和6条视频内容解析
+- 候选详情和批量双侧画像
 - 候选评分持久化、流程审计和状态恢复;
 - 工具参数错误后的自主修正:首次评分漏传 `run_id`、首次审计多传 `run_id`,
   Agent 均在下一次调用中修正。
@@ -45,7 +58,7 @@
 - Agent 未调用 `normalize_age_portraits`;
 - 搜索轨迹只有 `demand / pagination`,没有形成标签扩展分支;
 - 审计未通过时模型仍停止,并输出“接下来重新获取详情和画像”的过程性半截文本,
-  没有给出最终主推荐或补充推荐表。
+  没有给出最终主推荐和淘汰候选表。
 
 两次运行的数据库测试记录均已按实际 `run_id` 清理,残留数为0。
 
@@ -62,12 +75,11 @@
 | `get_account_fans_portrait` | 真实查询上述作者 | 真实通过;返回年龄桶且被标准化为强老年信号 |
 | `batch_fetch_portraits` | 对上述候选真实请求双侧画像 | 真实通过;内容侧缺失时作者侧仍成功返回 |
 | `normalize_age_portraits` | 使用本轮真实双侧返回测试 | 通过;输出 `account_only`、作者侧 `strong`、E上限0.65 |
-| `audit_video_discovery_process` | 完整流/提前停止/备选误删场景 | 通过;能阻止少根词、未翻页、未扩标签和E/S双高误删 |
-| `qwen_video_analyze` | 使用本轮详情播放URL、需求和相关点 | 真实通过;返回530字分析,耗时约21秒 |
+| `audit_video_discovery_process` | 完整流/提前停止/错误淘汰场景 | 契约已同步为仅审计 `primary / rejected`,不再要求视频理解 |
 | `create_video_discovery_run` | 真实 MySQL 端到端测试 | 真实通过;运行记录成功创建 |
 | `record_video_search_page` | 真实 MySQL 保存与重复页测试 | 真实通过;3条候选入库,重复保存新增数为0 |
-| `batch_save_video_candidate_evaluations` | 真实主推荐/补充候选/淘汰分池测试 | 真实通过;三类候选各1条,运行计数正确 |
-| `query_video_discovery_state` | 真实查询运行、搜索树和三类候选 | 真实通过;返回1个搜索页和3条候选 |
+| `batch_save_video_candidate_evaluations` | 候选分池契约 | 当前只接受 `primary / rejected`;`backup` 会返回输入错误 |
+| `query_video_discovery_state` | 查询运行、搜索树和候选 | 当前返回主推荐、淘汰候选及过程中的 `pending_evaluation` |
 
 ## 真实样例观察
 
@@ -76,30 +88,30 @@
 - 作者年龄画像实际使用 `50-` 表示50岁以上,占比 `19.63%`、TGI `84.84`;
   因此不能只识别“50岁以上”文字。
 - 内容画像可能没有数据,双侧画像工具的作者兜底是必要能力。
-- 千问视频分析能从真实视频中提取全国实施、缴费上限、税收优惠及提醒亲友等转发动机。
 - 本轮内部搜索第一页声明 `has_more=true`,第二页返回0条并关闭分页;Agent 应保存空页
   和停止信号,不能把“无新增”误写成调用失败。
 - TikHub 第二页与第一页出现1条重复,说明跨页去重不能依赖供应方;
   当前 `(run_id, aweme_id)` 唯一键和幂等合并逻辑能够处理。
-- MySQL 端到端测试覆盖创建运行、保存搜索页、重复页幂等、三类分池和状态查询;
+- MySQL 历史端到端测试覆盖创建运行、保存搜索页、重复页幂等和状态查询;
   测试记录已按 `run_id` 清理,残留数为0。
 
 ## 当前阻塞
 
 1. TikHub Key、三张 MySQL 表和全部持久化工具均已完成真实验证,没有相关阻塞。
 2. 完整 Agent 干跑已经执行,但最终审计未通过,当前不能宣称 Agent 可稳定完成任务。
-3. 持久化候选没有保存 `detail_verified / content_portrait_attempted /
-   account_portrait_attempted / content_analysis_verified` 等审计状态,导致状态恢复后的
-   候选不能直接满足审计输入契约。
+3. 持久化候选需要保存 `detail_verified / content_portrait_attempted /
+   account_portrait_attempted / age_portraits_normalized` 等审计状态;视频理解字段不
+   属于当前审计契约。
 4. AgentLoop 没有 find_agent 完成守卫;模型可以在 `can_finish=false` 时直接返回文本。
 
 ## 下一步修复
 
-1. 为候选表增加详情、双画像尝试、视频解析和年龄标准化状态,并
+1. 确保候选表的详情、双画像尝试和年龄标准化状态
    `query_video_discovery_state` 原样恢复;搜索状态同时补回 `parent_search_id`。
 2. 给 find_agent 增加确定性完成守卫:最后一次审计不是 `can_finish=true` 时,禁止
    AgentLoop 接受最终文本;搜索或候选变更后必须重新审计。
 3. 将年龄标准化合并到批画像或候选保存流程,避免模型跳过强制证据处理。
-4. 调整搜索/详情/解析预算。当前详情按每条约10秒串行,且一次解析6条视频,真实任务
-   延迟偏高;应先用分享量、相关性和画像可得性做更强预筛。
-5. 完成上述修改后,使用同一输入重新干跑,直到最终审计通过并输出完整双池报告。
+4. 调整搜索和详情预算。当前详情按每条约10秒串行,真实任务延迟偏高;应先用分享量、
+   相关性和画像可得性做更强预筛。
+5. 完成上述修改后,使用同一输入重新干跑,直到最终审计通过并输出完整
+   `primary / rejected` 报告。

+ 14 - 17
agents/find_agent/agent.py

@@ -63,27 +63,27 @@ def _validate_report_buckets(
     primary_section = _report_section(
         content,
         "主推荐",
-        ("补充推荐",),
+        ("淘汰候选",),
     )
-    backup_section = _report_section(
+    rejected_section = _report_section(
         content,
-        "补充推荐",
-        ("最有竞争力", "搜索树", "缺失数据", "总结"),
+        "淘汰候选",
+        ("搜索树", "缺失数据", "总结"),
     )
-    if primary_section is None or backup_section is None:
-        return "最终报告必须分别包含“主推荐”和“补充推荐”段"
+    if primary_section is None or rejected_section is None:
+        return "最终报告必须分别包含“主推荐”和“淘汰候选”段"
 
     primary_ids = set(_VIDEO_ID_PATTERN.findall(primary_section))
-    backup_ids = set(_VIDEO_ID_PATTERN.findall(backup_section))
+    rejected_ids = set(_VIDEO_ID_PATTERN.findall(rejected_section))
     wrong_primary = sorted(
         video_id
         for video_id in primary_ids
         if bucket_by_id.get(video_id) != "primary"
     )
-    wrong_backup = sorted(
+    wrong_rejected = sorted(
         video_id
-        for video_id in backup_ids
-        if bucket_by_id.get(video_id) != "backup"
+        for video_id in rejected_ids
+        if bucket_by_id.get(video_id) != "rejected"
     )
     if wrong_primary:
         return (
@@ -91,19 +91,16 @@ def _validate_report_buckets(
             + ", ".join(wrong_primary[:5])
             + "。必须按数据库 decision_bucket 输出"
         )
-    if wrong_backup:
+    if wrong_rejected:
         return (
-            "补充推荐段包含非 backup 候选: "
-            + ", ".join(wrong_backup[:5])
-            + "。这些条目应移到淘汰候选,不得冒充 Agent 保留结果"
+            "淘汰候选段包含非 rejected 候选: "
+            + ", ".join(wrong_rejected[:5])
+            + "。必须按数据库 decision_bucket 输出"
         )
 
     primary_count = int(run_state.get("primary_count") or 0)
-    backup_count = int(run_state.get("backup_count") or 0)
     if primary_count > 0 and not primary_ids:
         return "数据库存在 primary 候选,但主推荐段没有输出 aweme_id"
-    if primary_count == 0 and backup_count > 0 and not backup_ids:
-        return "数据库只有 backup 保留候选,补充推荐段至少应输出一条"
     return None
 
 

+ 17 - 17
agents/find_agent/output_sync.py

@@ -13,10 +13,8 @@ from supply_infra.db.session import get_session
 
 _VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
 _PRIMARY_LABELS = ("主推荐", "正式推荐")
-_BACKUP_LABELS = ("补充推荐", "人工备选")
+_REJECTED_LABELS = ("淘汰候选", "淘汰")
 _END_LABELS = (
-    "淘汰",
-    "最有竞争力",
     "搜索轨迹",
     "搜索树",
     "缺失数据",
@@ -35,29 +33,31 @@ def _normalized_heading(line: str) -> str:
 def extract_model_recommendation_ids(
     content: str,
 ) -> tuple[list[str], list[str]]:
-    """Extract video ids from the model's primary and backup sections."""
+    """Extract video ids from the model's primary and rejected sections."""
     primary: list[str] = []
-    backup: list[str] = []
+    rejected: list[str] = []
     section: str | None = None
 
     for line in (content or "").splitlines():
         heading = _normalized_heading(line)
         if any(heading.startswith(label) for label in _PRIMARY_LABELS):
             section = "primary"
-        elif any(heading.startswith(label) for label in _BACKUP_LABELS):
-            section = "backup"
+        elif any(heading.startswith(label) for label in _REJECTED_LABELS):
+            section = "rejected"
         elif any(heading.startswith(label) for label in _END_LABELS):
             section = None
 
         if section is None:
             continue
-        target = primary if section == "primary" else backup
+        target = primary if section == "primary" else rejected
         for aweme_id in _VIDEO_ID_PATTERN.findall(line):
             if aweme_id not in target:
                 target.append(aweme_id)
 
     primary_set = set(primary)
-    return primary, [aweme_id for aweme_id in backup if aweme_id not in primary_set]
+    return primary, [
+        aweme_id for aweme_id in rejected if aweme_id not in primary_set
+    ]
 
 
 def _latest_run_id(result: AgentResult) -> str | None:
@@ -81,15 +81,15 @@ def persist_model_recommendations(result: AgentResult) -> dict[str, Any]:
     Make the model's final recommendation sections authoritative in the database.
 
     The final response itself is never validated or rewritten. Video ids listed
-    under primary/backup are persisted to those buckets exactly as stated.
+    under primary/rejected are persisted to those buckets exactly as stated.
     """
     run_id = _latest_run_id(result)
-    primary_ids, backup_ids = extract_model_recommendation_ids(result.content)
-    if not run_id or not (primary_ids or backup_ids):
+    primary_ids, rejected_ids = extract_model_recommendation_ids(result.content)
+    if not run_id or not (primary_ids or rejected_ids):
         return {
             "run_id": run_id,
             "primary_ids": primary_ids,
-            "backup_ids": backup_ids,
+            "rejected_ids": rejected_ids,
             "saved_count": 0,
         }
 
@@ -98,8 +98,8 @@ def persist_model_recommendations(result: AgentResult) -> dict[str, Any]:
         for aweme_id in primary_ids
     ]
     rows.extend(
-        {"aweme_id": aweme_id, "decision_bucket": "backup"}
-        for aweme_id in backup_ids
+        {"aweme_id": aweme_id, "decision_bucket": "rejected"}
+        for aweme_id in rejected_ids
     )
     with get_session() as session:
         repo = VideoDiscoveryRepository(session)
@@ -107,13 +107,13 @@ def persist_model_recommendations(result: AgentResult) -> dict[str, Any]:
             return {
                 "run_id": run_id,
                 "primary_ids": primary_ids,
-                "backup_ids": backup_ids,
+                "rejected_ids": rejected_ids,
                 "saved_count": 0,
             }
         saved_count, _ = repo.save_candidate_evaluations(run_id, rows)
     return {
         "run_id": run_id,
         "primary_ids": primary_ids,
-        "backup_ids": backup_ids,
+        "rejected_ids": rejected_ids,
         "saved_count": saved_count,
     }

+ 41 - 35
agents/find_agent/prompt/system_prompt.md

@@ -10,6 +10,11 @@
 
 “偏老年”描述的是视频的实际或潜在受众,不是视频画面中出现老人,也不是标题中含有“老人”“养老”等词。不得把题材印象、人物年龄、作者年龄或刻板印象当作受众年龄证据。
 
+当前流程**明确不使用视频理解**。你没有也不得调用任何视频画面、语音、字幕或多模态
+解析能力。相关性和分享动机只能依据搜索结果、标题、描述、话题标签、详情文本、互动
+数据和画像判断。当前持久化契约不保存视频播放地址、内容分析结论或视频理解核验标记,
+不得自行构造或引用此类数据作为证据。
+
 # 基本定义
 
 对候选视频 `v`,定义三个彼此独立的命题:
@@ -22,17 +27,20 @@
 
 `G(v) = R(v) ∩ E(v) ∩ S(v)`
 
-主推荐要求三个命题共同成立。补充候选允许 `R(v)` 不成立,但必须同时具备很强的
-`E(v)` 与 `S(v)`。是否保留完全由 Agent 依据证据和分池规则决定,不再等待人工审核。
-主推荐不足目标数量时,可以使用补充候选,但必须显式标注相关性限制。
+最终分池只允许:
+
+- `primary`:`R / E / S` 三个命题共同成立;
+- `rejected`:未满足 `primary` 的任一候选。
+
+`pending_evaluation` 只是搜索召回后的过程状态,不是最终等级。不存在 `backup`、
+补充推荐或人工备选等级。
 
 # 决策公理与定理
 
 ## 1. 需求闸门公理
 
 相关性是**主推荐池**的准入条件,不是加分项。一个高分享、老年粉丝很多但没有
-回答本次需求的视频,主推荐价值为零,但在老年倾向与分享价值双高时应由 Agent
-保留到独立的补充池,不能丢弃。
+回答本次需求的视频不能保留,最终必须进入 `rejected`。
 
 `seed_video_title` 和 `relevant_points` 用来消除需求词的歧义、提炼事件/人物/场景/用途及同义表达;它们不是必须逐字匹配的搜索条件。搜索词只是召回假设,不能成为候选合格的证据。
 
@@ -52,7 +60,7 @@
 
 - 根节点来自需求语义、参考视频标题和相关点揭示的不同内容假设;
 - 优质候选的 `topic_list`、话题标签、标题实体和详情字段中出现的新角度,可以成为
-  子节点;只有与本次目标内容相关或可能产生高价值补充候选的标签才允许扩展;
+  子节点;只有与本次目标内容相关、可能产生高价值主推荐的标签才允许扩展;
 - `has_more=true` 与 `next_cursor` 表示同一关键词仍有搜索前沿,翻页不是重复搜索;
 - 每个新词和每一页都必须记录来源,使最终能够解释“为什么搜这个词”。
 - `demand / seed / point / mixed` 表示独立根搜索,保存时不得设置
@@ -77,8 +85,9 @@
 
 1. 候选视频自身的点赞用户年龄画像;
 2. 同一候选作者的粉丝年龄画像;
-3. 视频内容表现出的易理解、怀旧、实用、家庭沟通或公共话题等适配特征;
-4. 标题、题材或作者形象带来的直觉。
+3. 标题、描述、话题和详情文本表现出的易理解、怀旧、实用、家庭沟通或公共话题等
+   适配特征;
+4. 题材或作者形象带来的直觉。
 
 第 4 层不得单独形成老年倾向结论。视频画像代表“这条内容吸引了谁”,作者画像代表“这个账号通常触达谁”;前者是直接内容证据,后者是账号先验。两者一致时增强置信度;冲突时优先视频画像并显式降置信度,不得静默平均。
 
@@ -107,18 +116,15 @@
 这意味着任一维度接近零,整体价值都会被明显压低。评分用于保持排序一致,不得制造虚假精确性;最终报告应展示整数分、原始数据和理由。
 
 `R/E/S/V` 是帮助你保持判断一致的参考量,不是程序校验线。候选最终进入
-`primary / backup / rejected` 完全由你根据全部证据判断。保存工具不会重算分数、
+`primary / rejected` 完全由你根据全部证据判断。保存工具不会重算分数、
 设置画像上限或改写你的 `decision_bucket`。
 
-补充候选不是低质量主推荐。它表达的是“与本次需求无关或关系较弱,但已意外发现
-老年倾向和分享性都很强”。必须保留标题、链接、画像、分享数据和不相关原因。
-优先目标是保留至少 5 条质量可靠的视频,可以超过 5 条。先按 `V` 使用 `primary`;
-不足 5 条时,再按 `V` 使用 `backup`。5 条是搜索和筛选的优先目标,不是硬性准入线:
+优先目标是保留至少 5 条质量可靠的 `primary`,可以超过 5 条。5 条是搜索和筛选的
+优先目标,不是硬性准入线:
 在合理搜索、翻页和扩展后确实没有更多好视频时,允许少于 5 条,禁止为凑数保留明显
-低质或缺乏基本证据的候选。补充项必须标记“补充推荐”和限制,不能伪装成主推荐。
+低质、低相关或缺乏基本证据的候选。
 
-在初筛阶段,低相关候选若原始分享规模或分享效率处于当前结果前列,不得因相关性低而
-立即丢弃;它应进入“备选探测队列”取得年龄画像,再由你结合全部证据决定保留或淘汰。
+低相关候选即使原始分享规模、分享效率或老年倾向很强,也不能进入 `primary`。
 
 ## 8. 反证优先公理
 
@@ -141,7 +147,7 @@
   `cursor / search_id / backtrace`。使用它翻页时,三项状态必须原样传回;不得把
   TikHub 和内部搜索的游标混用。若未配置 `TIKHUB_API_KEY` 或接口失败,保存失败原因
   后改用内部搜索,不要用相同参数反复重试。
-- `douyin_user_videos`:当候选作者的粉丝画像偏老、或其视频具有较高主推荐/补充
+- `douyin_user_videos`:当候选作者的粉丝画像偏老、或其视频具有较高主推荐
   潜力时,按最热或最新扩展作者作品。作者作品属于 `author` 搜索分支,仍需逐条判断
   相关性、老年倾向和分享价值,不能因作者优秀就直接推荐。
 - `douyin_detail`:用于核验候选的最新互动数据、作者、页面链接和标题/描述等文本证据。
@@ -160,28 +166,29 @@
   任何搜索页都不能只存在于上下文中。新召回候选初始状态是
   `pending_evaluation`,表示等待 Agent 补证和评分,不能直接输出。
 - `batch_save_video_candidate_evaluations`:原样保存你给出的详情、证据、评分和
-  `decision_bucket`。工具不会校验或修改你的判断。每个候选至少传入 `aweme_id` 和
-  你决定的 `decision_bucket`;其他证据、理由和分数尽量完整传入。
-- `query_video_discovery_state`:恢复长搜索的已探索关键词、翻页状态和候选分池,也用于
+  `decision_bucket`。工具只接受 `primary / rejected`,不会重算分数或替你改池。
+  每个候选至少传入 `aweme_id` 和你决定的 `decision_bucket`;其他证据、理由和分数
+  尽量完整传入。
+- `query_video_discovery_state`:恢复长搜索的已探索关键词、翻页状态、主推荐和淘汰
+  候选,也用于
   查看已经保存的模型决定。
 
-优先让廉价证据淘汰没有主推荐价值、也没有补充潜力的候选。详情与双画像
-用于仍可能进入主推荐或补充候选的候选。所有工具失败都保留原始错误语义,不得编造
-缺失字段。
+优先让廉价证据淘汰没有主推荐价值的候选。详情与双画像用于仍可能进入主推荐的候选。
+所有工具失败都保留原始错误语义,不得编造缺失字段。
 
 若 `create_video_discovery_run` 明确返回数据库表未初始化或数据库不可用,只尝试一次:
-之后在当前上下文中维护同样的 searches/candidates 结构,继续完成搜索和双池判断
+之后在当前上下文中维护同样的 searches/candidates 结构,继续完成搜索和最终分池
 不得因持久化失败放弃找片,也不得反复调用失败的存储工具。最终必须醒目标注“结果未
 持久化”及失败原因。其他数据库错误不应被假定为可忽略。
 
 # 成本与迭代预算
 
 - 根搜索默认形成2~3个语义不同的词;每个生产性首页最多继续1页,除非第二页仍显著
-  提升主推荐或补充候选质量;
-- 搜索结果先按需求相关性、分享规模/效率和备选潜力做廉价预筛,默认最多选择8条进入
+  提升主推荐质量;
+- 搜索结果先按需求相关性、分享规模/效率和主推荐潜力做廉价预筛,默认最多选择8条进入
   `douyin_detail` 和双画像阶段;
 - 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
-  不得依赖视频画面解析;
+  不得依赖或声称使用了视频画面、语音、字幕解析;
 - 执行新搜索、翻页、详情或画像后,应重新保存受影响候选;
 - 不得用“接下来我会继续”作为最终回答。当前不依赖运行时完成守卫,Agent 必须自行
   决定何时结束。
@@ -191,7 +198,7 @@
 
 # 完成条件
 
-一次任务优先在数据库中正确保留至少 5 条符合 `primary` 或 `backup` 规则的视频,
+一次任务优先在数据库中正确保留至少 5 条符合 `primary` 规则的视频,
 可以保留更多。若经过合理搜索仍没有 5 条合格视频,则保留全部真正合格的候选后结束,
 不能降低基本质量要求硬凑数量;确实没有合格候选时可以返回空结果。
 
@@ -201,7 +208,7 @@
   结构中完整保留搜索页并在最终报告披露未持久化;
 - 推荐按联合价值排序,优先保证 5 条,可以超过 5 条;确实没有足够好视频时允许更少;
 - 数据库可用时,把你决定的候选分池和运行完成状态持久化;
-- 最终报告中列为主推荐或补充推荐的视频,会按你的最终文字同步为对应数据库状态。
+- 最终报告中的主推荐和淘汰候选,会按你的最终文字同步为对应数据库状态。
 
 以下探索项在不足 5 条时应优先执行;但它们只作为 warning,不把 5 条变成硬门槛:
 
@@ -211,7 +218,7 @@
 
 # 最终输出契约
 
-先用一句话复述你对需求意图的理解,然后分别输出“主推荐”和“补充推荐”。每条必须包含:
+先用一句话复述你对需求意图的理解,然后输出“主推荐”和“淘汰候选”。主推荐每条必须包含:
 
 - 排名、标题、作者、抖音页面链接、`aweme_id`;
 - 命中的需求点及相关性证据;
@@ -225,11 +232,10 @@
 输出顺序:
 
 1. **主推荐**:列出你最终决定推荐的视频;
-2. **补充推荐**:主推荐不足 5 条时,可以列出你认为值得保留的补充项;有更多可靠
-   候选时可以超过 5 条。明确标注每条限制;没有合格项时写“无”;
-3. 最有竞争力但被淘汰的候选及淘汰理由;
-4. 搜索树:实际搜索词、形成来源、已翻页数、标签扩展关系和新增候选数;
-5. 缺失数据、画像冲突、未继续的搜索前沿及接口失败。
+2. **淘汰候选**:列出已评估候选及淘汰理由;低相关但高分享或偏老年也必须在此列,
+   不得另设中间等级;
+3. 搜索树:实际搜索词、形成来源、已翻页数、标签扩展关系和新增候选数;
+4. 缺失数据、画像冲突、未继续的搜索前沿及接口失败。
 
 决定均由 Agent 作出。程序不会根据阈值重新解释或修改你的最终推荐。
 

+ 15 - 25
agents/find_agent/tools/decision_support.py

@@ -238,14 +238,14 @@ def audit_video_discovery_process(
     intended_status: str = "finished",
 ) -> str:
     """
-    在结束找片前审计搜索树、翻页、标签扩展、证据完备性和双池分流。
+    在结束找片前审计搜索树、翻页、标签扩展、证据完备性和最终分流。
 
     Args:
         searches: 已执行搜索页。建议包含 search_id、keyword、source_type、
             parent_search_id、cursor、page_no、has_more、new_candidate_count。
         candidates: 已评估候选。建议包含 aweme_id、decision_bucket、R/E/S 分数、
             detail_verified、content_portrait_attempted、account_portrait_attempted、
-            content_analysis_verified、has_video_url、expansion_worthy_tags。
+            age_portraits_normalized、expansion_worthy_tags。
         intended_status: 准备设置的运行状态,通常为 finished。
 
     Returns:
@@ -311,7 +311,6 @@ def audit_video_discovery_process(
 
     bucket_counts = {
         "primary": 0,
-        "backup": 0,
         "rejected": 0,
         "pending_evaluation": 0,
     }
@@ -322,12 +321,15 @@ def audit_video_discovery_process(
         )
         if bucket == "unreviewed":
             bucket = "pending_evaluation"
-        bucket_counts[bucket] = bucket_counts.get(bucket, 0) + 1
+        if bucket not in bucket_counts:
+            critical.append(f"{aweme_id} 使用了不支持的分池: {bucket}")
+            continue
+        bucket_counts[bucket] += 1
         relevance = _float_score(candidate, "relevance_score")
         elder = _float_score(candidate, "elder_score")
         share = _float_score(candidate, "share_score")
 
-        if bucket in {"primary", "backup"}:
+        if bucket == "primary":
             if not candidate.get("detail_verified"):
                 critical.append(f"{aweme_id} 未核验详情")
             if not candidate.get("content_portrait_attempted"):
@@ -336,10 +338,6 @@ def audit_video_discovery_process(
                 critical.append(f"{aweme_id} 未尝试作者画像")
             if not candidate.get("age_portraits_normalized"):
                 critical.append(f"{aweme_id} 未标准化年龄画像")
-            if candidate.get("has_video_url") and not candidate.get(
-                "content_analysis_verified"
-            ):
-                critical.append(f"{aweme_id} 有播放地址但未核验视频内容")
 
         if bucket == "primary" and not (
             relevance is not None
@@ -358,24 +356,16 @@ def audit_video_discovery_process(
             and share is not None
             and share >= 0.50
         )
-        meets_backup = (
-            not meets_primary
-            and elder is not None
-            and elder >= 0.50
-            and share is not None
-            and share >= 0.50
-        )
-        if bucket == "backup" and not meets_backup:
-            critical.append(f"{aweme_id} 不满足 backup 阈值")
-        if bucket == "rejected" and (meets_primary or meets_backup):
-            critical.append(f"{aweme_id} 达到保留线却被错误淘汰")
+        if bucket == "rejected" and meets_primary:
+            critical.append(f"{aweme_id} 达到 primary 线却被错误淘汰")
         if bucket == "pending_evaluation":
-            warnings.append(f"{aweme_id} 等待 Agent 补证和评估")
+            message = f"{aweme_id} 等待 Agent 补证和评估"
+            if intended_status == "finished":
+                critical.append(message)
+            else:
+                warnings.append(message)
 
-    retained_count = (
-        bucket_counts.get("primary", 0)
-        + bucket_counts.get("backup", 0)
-    )
+    retained_count = bucket_counts.get("primary", 0)
     if retained_count < 5:
         warnings.append(
             f"当前保留 {retained_count} 条,低于优先目标 5 条;"

+ 4 - 6
agents/find_agent/tools/qwen_video_analyze.py

@@ -1,8 +1,8 @@
 """
-千问视频解析工具
+历史千问视频解析实现。
 
-通过阿里云百炼平台(DashScope 兼容模式)调用 qwen3.7-plus 模型,解析视频内容
-超长视频会先截断到指定时长,上传 OSS 后再解析
+当前 find_agent 明确不使用视频理解,本模块未注册到 Agent,也不属于在线能力
+保留文件仅用于历史兼容,不得因文件或数据库字段存在而推断能力已启用
 """
 from __future__ import annotations
 
@@ -25,7 +25,6 @@ from dotenv import load_dotenv
 from openai import APIStatusError, APITimeoutError, OpenAI
 
 from supply_agent.paths import find_project_root
-from supply_agent.tools import tool
 from supply_infra.config import get_infra_settings
 from supply_infra.oss.client import OssClient
 
@@ -486,7 +485,6 @@ def verify_truncation_runtime(*, check_oss: bool = False) -> dict[str, Any]:
     }
 
 
-@tool
 async def qwen_video_analyze(
     video_url: str,
     prompt: str = DEFAULT_PROMPT,
@@ -497,7 +495,7 @@ async def qwen_video_analyze(
     download_timeout: Optional[float] = None,
 ) -> str:
     """
-    千问视频内容解析
+    历史视频解析函数,不注册为 find_agent 工具。
 
     通过阿里云百炼平台调用 qwen3.7-plus 模型,分析视频 URL 并返回文字描述。
     需要设置环境变量 DASHSCOPE_API_KEY。超长视频会先截断再解析。

+ 13 - 26
agents/find_agent/tools/video_discovery_store.py

@@ -22,6 +22,7 @@ from supply_infra.db.session import get_session
 logger = logging.getLogger(__name__)
 
 _RUN_STATUSES = {"running", "finished", "failed"}
+_FINAL_DECISION_BUCKETS = {"primary", "rejected"}
 _SOURCE_TYPES = {
     "demand",
     "seed",
@@ -181,9 +182,6 @@ def _serialize_candidate(item: Any) -> dict[str, Any]:
         "content_portrait_attempted": bool(item.content_portrait_attempted),
         "account_portrait_attempted": bool(item.account_portrait_attempted),
         "age_portraits_normalized": bool(item.age_portraits_normalized),
-        "has_video_url": bool(item.video_url),
-        "content_analysis": item.content_analysis,
-        "content_analysis_verified": bool(item.content_analysis_verified),
         "expansion_worthy_tags": _load_json(
             item.expansion_worthy_tags_json, []
         ),
@@ -416,22 +414,23 @@ def _normalize_evaluation(item: dict[str, Any]) -> dict[str, Any]:
     content_age = item.get("content_age_evidence")
     account_age = item.get("account_age_evidence")
     age_normalization = item.get("age_normalization")
-    content_analysis = _clean_text(item.get("content_analysis"))
     detail_verified = bool(item.get("detail_verified"))
     content_portrait_attempted = bool(item.get("content_portrait_attempted"))
     account_portrait_attempted = bool(item.get("account_portrait_attempted"))
-    content_analysis_verified = bool(item.get("content_analysis_verified"))
     age_portraits_normalized = bool(item.get("age_portraits_normalized"))
     decision_bucket = (
         _clean_text(item.get("decision_bucket"), max_length=24)
-        or "pending_evaluation"
+        or ""
     )
+    if decision_bucket not in _FINAL_DECISION_BUCKETS:
+        raise ValueError(
+            "decision_bucket 必须是 primary 或 rejected"
+        )
 
     mapping = {
         "aweme_id": aweme_id,
         "title": _clean_text(item.get("title"), max_length=512),
         "content_link": _clean_text(item.get("content_link"), max_length=1024),
-        "video_url": _clean_text(item.get("video_url")),
         "author_name": _clean_text(item.get("author_name"), max_length=256),
         "author_sec_uid": _clean_text(item.get("author_sec_uid"), max_length=256),
         "source_keywords_json": item.get("source_keywords") or [],
@@ -457,8 +456,6 @@ def _normalize_evaluation(item: dict[str, Any]) -> dict[str, Any]:
         "content_portrait_attempted": int(content_portrait_attempted),
         "account_portrait_attempted": int(account_portrait_attempted),
         "age_portraits_normalized": int(age_portraits_normalized),
-        "content_analysis": content_analysis,
-        "content_analysis_verified": int(content_analysis_verified),
         "expansion_worthy_tags_json": item.get("expansion_worthy_tags") or [],
         "relevance_score": _optional_decimal(item.get("relevance_score"), 6),
         "elder_score": _optional_decimal(item.get("elder_score"), 6),
@@ -486,7 +483,7 @@ def batch_save_video_candidate_evaluations(
     原样保存 Agent 给出的候选详情、证据、评分和分池。
 
     本工具不重算 R/E/S/V,不执行画像证据上限,也不根据阈值修改
-    decision_bucket。Agent 给出的 primary / backup / rejected 会原样落库
+    decision_bucket。最终分池只接受 primary / rejected
 
     Args:
         run_id: 发现运行 id。
@@ -543,10 +540,9 @@ def batch_save_video_candidate_evaluations(
                 "status": run.status,
                 "search_count": run.search_count,
                 "primary_count": run.primary_count,
-                "backup_count": run.backup_count,
                 "output": (
                     f"保存 {saved} 条;主推荐 {run.primary_count} 条;"
-                    f"补充候选 {run.backup_count} 条;状态 {run.status}"
+                    f"状态 {run.status}"
                 ),
             }
         return _json(payload)
@@ -560,11 +556,11 @@ def batch_save_video_candidate_evaluations(
 @tool
 def query_video_discovery_state(
     run_id: str,
-    include_rejected: bool = False,
+    include_rejected: bool = True,
     limit: int = 100,
 ) -> str:
     """
-    查询一次运行已经保存的搜索轨迹、主推荐与 Agent 补充候选。
+    查询一次运行已经保存的搜索轨迹、主推荐与淘汰候选。
 
     用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
     """
@@ -579,14 +575,9 @@ def query_video_discovery_state(
                 return _json({"error": f"run_id 不存在: {run_text}"})
             searches = repo.list_searches(run_text)
             buckets = (
-                None
+                ("primary", "rejected", "pending_evaluation", "unreviewed")
                 if include_rejected
-                else (
-                    "primary",
-                    "backup",
-                    "pending_evaluation",
-                    "unreviewed",
-                )
+                else ("primary", "pending_evaluation", "unreviewed")
             )
             candidates = repo.list_candidates(
                 run_text, buckets=buckets, limit=max(1, min(int(limit), 500))
@@ -600,14 +591,12 @@ def query_video_discovery_state(
                     "status": run.status,
                     "search_count": run.search_count,
                     "primary_count": run.primary_count,
-                    "backup_count": run.backup_count,
                     "stop_reason": run.stop_reason,
                 },
                 "searches": [_serialize_search(item) for item in searches],
                 "candidates": [_serialize_candidate(item) for item in candidates],
                 "output": (
-                    f"搜索页 {run.search_count};主推荐 {run.primary_count};"
-                    f"补充候选 {run.backup_count}"
+                    f"搜索页 {run.search_count};主推荐 {run.primary_count}"
                 ),
             }
         return _json(payload)
@@ -661,7 +650,6 @@ def audit_video_discovery_run(
                 "status": run.status,
                 "search_count": run.search_count,
                 "primary_count": run.primary_count,
-                "backup_count": run.backup_count,
             }
 
         result = _load_json(
@@ -680,7 +668,6 @@ def audit_video_discovery_run(
                 "persisted_status": persisted_run["status"],
                 "persisted_search_count": persisted_run["search_count"],
                 "persisted_primary_count": persisted_run["primary_count"],
-                "persisted_backup_count": persisted_run["backup_count"],
             }
         )
         if (

+ 75 - 0
alembic/versions/20260728_01_drop_unused_scheduler_artifacts.py

@@ -0,0 +1,75 @@
+"""drop unused scheduler leftovers
+
+Revision ID: 20260728_01
+Revises: 20260727_03
+Create Date: 2026-07-28
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import mysql
+
+revision: str = "20260728_01"
+down_revision: str | None = "20260727_03"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.execute("DROP TABLE IF EXISTS scheduler_job_execution")
+    op.drop_column("pipeline_run", "triggered_by")
+    op.drop_column("pipeline_run", "parent_run_id")
+    op.drop_column("pipeline_run", "scheduled_for")
+    op.drop_column("pipeline_step_run", "metrics_json")
+
+
+def downgrade() -> None:
+    op.add_column(
+        "pipeline_step_run",
+        sa.Column("metrics_json", sa.JSON(), nullable=True),
+    )
+    op.add_column(
+        "pipeline_run",
+        sa.Column("scheduled_for", sa.DateTime(), nullable=True),
+    )
+    op.add_column(
+        "pipeline_run",
+        sa.Column("parent_run_id", sa.String(length=36), nullable=True),
+    )
+    op.add_column(
+        "pipeline_run",
+        sa.Column("triggered_by", sa.String(length=128), nullable=True),
+    )
+    op.create_table(
+        "scheduler_job_execution",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("run_id", sa.String(length=64), nullable=False),
+        sa.Column("job_name", sa.String(length=128), nullable=False),
+        sa.Column("job_id", sa.String(length=128), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("event_time", sa.DateTime(), nullable=False),
+        sa.Column("biz_dt", sa.String(length=8), nullable=True),
+        sa.Column("started_at", sa.DateTime(), nullable=True),
+        sa.Column("finished_at", sa.DateTime(), nullable=True),
+        sa.Column("duration_seconds", sa.Float(), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("detail", mysql.LONGTEXT(), nullable=True),
+        sa.Column(
+            "create_time",
+            sa.DateTime(),
+            server_default=sa.text("CURRENT_TIMESTAMP"),
+            nullable=False,
+        ),
+        sa.Column(
+            "update_time",
+            sa.DateTime(),
+            server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
+            nullable=False,
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        mysql_charset="utf8mb4",
+        mysql_collate="utf8mb4_unicode_ci",
+    )

+ 1 - 30
api/app.py

@@ -120,15 +120,6 @@ class TriggerSchedulerJobBody(BaseModel):
         pattern=r"^\d{8}$",
         description="业务日 YYYYMMDD;省略则取当天",
     )
-    partition_date: str | None = Field(
-        default=None,
-        pattern=r"^\d{8}$",
-        description="ODPS 分区日 YYYYMMDD;全局树默认取 biz_dt 前一日",
-    )
-    wait: bool = Field(
-        default=False,
-        description="是否同步等待执行完成;默认 false 表示后台执行",
-    )
 
 
 class AgentDocumentInjectionBody(BaseModel):
@@ -176,34 +167,14 @@ def trigger_scheduler_job(
         pattern=r"^\d{8}$",
         description="业务日 YYYYMMDD(与 body 二选一,body 优先)",
     ),
-    partition_date: str | None = Query(
-        default=None,
-        pattern=r"^\d{8}$",
-        description="ODPS 分区日 YYYYMMDD(与 body 二选一,body 优先)",
-    ),
-    wait: bool = Query(
-        default=False,
-        description="是否同步等待执行完成",
-    ),
 ) -> dict:
     """Manually trigger a scheduler job."""
     resolved_biz_dt = body.biz_dt if body and body.biz_dt is not None else biz_dt
-    resolved_partition_date = (
-        body.partition_date if body and body.partition_date is not None else partition_date
-    )
-    resolved_wait = body.wait if body is not None else wait
 
     try:
-        return run_scheduler_job(
-            job_id,
-            biz_dt=resolved_biz_dt,
-            partition_date=resolved_partition_date,
-            wait=resolved_wait,
-        )
+        return run_scheduler_job(job_id, biz_dt=resolved_biz_dt)
     except KeyError:
         raise HTTPException(status_code=404, detail=f"scheduler job not found: {job_id}") from None
-    except ValueError as exc:
-        raise HTTPException(status_code=409, detail=str(exc)) from None
 
 
 @app.get("/api/scheduler/runs/{run_id}")

+ 0 - 6
api/services/scheduler.py

@@ -18,7 +18,6 @@ def list_triggerable_jobs() -> list[dict[str, Any]]:
             "name": SUPPLY_PIPELINE_JOB_NAME,
             "description": "提交严格门禁的 12 步持久化供给流水线",
             "accepts_biz_dt": True,
-            "accepts_partition_date": False,
             "deprecated": False,
             "steps": [step.key for step in PIPELINE_STEPS],
         }
@@ -29,14 +28,9 @@ def run_scheduler_job(
     job_id: str,
     *,
     biz_dt: str | None = None,
-    partition_date: str | None = None,
-    wait: bool = False,
 ) -> dict[str, Any]:
-    del partition_date
     if job_id != SUPPLY_PIPELINE_JOB_ID:
         raise KeyError(job_id)
-    if wait:
-        raise ValueError("wait=true is disabled for the durable API; poll by run_id")
     return submit_pipeline_run(
         biz_dt=biz_dt,
         trigger_type="api",

+ 1 - 4
sql/video_discovery_add_audit_evidence.sql

@@ -17,9 +17,6 @@ ALTER TABLE `video_discovery_candidate`
   ADD COLUMN `age_portraits_normalized` TINYINT NOT NULL DEFAULT 0
     COMMENT '是否已执行年龄画像标准化'
     AFTER `account_portrait_attempted`,
-  ADD COLUMN `content_analysis_verified` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已核验真实视频内容'
-    AFTER `content_analysis`,
   ADD COLUMN `expansion_worthy_tags_json` TEXT NULL
     COMMENT '值得继续搜索的标签 JSON'
-    AFTER `content_analysis_verified`;
+    AFTER `age_portraits_normalized`;

+ 10 - 0
sql/video_discovery_drop_unused_columns.sql

@@ -0,0 +1,10 @@
+-- 删除 find_agent 已停止使用的 MySQL 列。
+-- 适用于当前 video_discovery 表结构;DDL 会自动提交,执行前请按需备份历史数据。
+
+ALTER TABLE `video_discovery_run`
+  DROP COLUMN `backup_count`;
+
+ALTER TABLE `video_discovery_candidate`
+  DROP COLUMN `video_url`,
+  DROP COLUMN `content_analysis`,
+  DROP COLUMN `content_analysis_verified`;

+ 1 - 6
sql/video_discovery_tables.sql

@@ -18,7 +18,6 @@ CREATE TABLE IF NOT EXISTS `video_discovery_run` (
     COMMENT 'running / finished / failed',
   `search_count` INT NOT NULL DEFAULT 0 COMMENT '已保存搜索页数',
   `primary_count` INT NOT NULL DEFAULT 0 COMMENT '主推荐数',
-  `backup_count` INT NOT NULL DEFAULT 0 COMMENT 'Agent 自动保留的补充候选数',
   `stop_reason` TEXT NULL COMMENT '停止搜索的证据或失败原因',
   `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -76,7 +75,6 @@ CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `aweme_id` VARCHAR(64) NOT NULL COMMENT '抖音视频 id',
   `title` VARCHAR(512) NULL COMMENT '视频标题',
   `content_link` VARCHAR(1024) NULL COMMENT '抖音页面链接',
-  `video_url` TEXT NULL COMMENT '解析时使用的播放链接',
   `author_name` VARCHAR(256) NULL COMMENT '作者名',
   `author_sec_uid` VARCHAR(256) NULL COMMENT '作者 sec_uid',
   `source_keywords_json` TEXT NULL COMMENT '命中过该视频的搜索词 JSON',
@@ -99,9 +97,6 @@ CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
     COMMENT '是否已尝试作者粉丝画像',
   `age_portraits_normalized` TINYINT NOT NULL DEFAULT 0
     COMMENT '是否已执行年龄画像标准化',
-  `content_analysis` TEXT NULL COMMENT '视频内容核验结果',
-  `content_analysis_verified` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已核验真实视频内容',
   `expansion_worthy_tags_json` TEXT NULL COMMENT '值得继续搜索的标签 JSON',
   `relevance_score` DECIMAL(8,6) NULL COMMENT 'R,范围 0~1',
   `elder_score` DECIMAL(8,6) NULL COMMENT 'E,范围 0~1',
@@ -113,7 +108,7 @@ CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `share_reason` TEXT NULL COMMENT '分享价值依据',
   `decision_reason` TEXT NULL COMMENT '最终分池依据',
   `decision_bucket` VARCHAR(24) NOT NULL DEFAULT 'pending_evaluation'
-    COMMENT 'primary / backup / rejected / pending_evaluation',
+    COMMENT '最终为 primary / rejected;pending_evaluation 仅为过程状态',
   `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

+ 5 - 5
supply_infra/aigc/plan_map.py

@@ -8,14 +8,14 @@ T = TypeVar("T")
 # 品类 → (生成计划ID, 发布计划ID);按 pair 去重后做均匀分发。
 AIGC_PLAN_ID_MAP: dict[str, dict[str, str]] = {
     "健康知识": {"生成ID": "20260408092313211598604", "发布ID": "20260408115944193153417"},
-    "历史名人": {"生成ID": "20260408083251311809309", "发布ID": "20260408115139124511126"},
-    "知识科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
+    # "历史名人": {"生成ID": "20260408083251311809309", "发布ID": "20260408115139124511126"},
+    # "知识科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
     "搞笑段子": {"生成ID": "20260408091536533918237", "发布ID": "20260408115842127748387"},
-    "社会风气": {"生成ID": "20260408084318884115213", "发布ID": "20260408115315950129776"},
+    # "社会风气": {"生成ID": "20260408084318884115213", "发布ID": "20260408115315950129776"},
     "人生忠告": {"生成ID": "20260408085205791658566", "发布ID": "20260408115405410408001"},
     "国际时政": {"生成ID": "20260408090208237400605", "发布ID": "20260408115616925523989"},
-    "生活技巧科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
-    "贪污腐败": {"生成ID": "20260408090309503416878", "发布ID": "20260408115653908856043"},
+    # "生活技巧科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
+    # "贪污腐败": {"生成ID": "20260408090309503416878", "发布ID": "20260408115653908856043"},
     "民生政策": {"生成ID": "20260408090721867506475", "发布ID": "20260408115727030928177"},
     "对口型表演": {"生成ID": "20260408092122328523262", "发布ID": "20260408115914659162376"},
     "中国战争史": {"生成ID": "20260408090950446586451", "发布ID": "20260408115804931772327"},

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

@@ -27,7 +27,6 @@ from supply_infra.db.models.pipeline_lock import PipelineLock
 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.db.models.scheduler_job_execution import SchedulerJobExecution
 from supply_infra.db.models.video_discovery import (
     VideoDiscoveryCandidate,
     VideoDiscoveryRun,
@@ -58,7 +57,6 @@ __all__ = [
     "PipelineOutbox",
     "PipelineRun",
     "PipelineStepRun",
-    "SchedulerJobExecution",
     "VideoDiscoveryCandidate",
     "VideoDiscoveryRun",
     "VideoDiscoverySearch",

+ 0 - 3
supply_infra/db/models/pipeline_run.py

@@ -30,14 +30,11 @@ class PipelineRun(Base):
     biz_dt: Mapped[str] = mapped_column(String(8), nullable=False)
     trigger_type: Mapped[str] = mapped_column(String(24), nullable=False)
     trigger_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
-    triggered_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
     trigger_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
-    parent_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
     run_mode: Mapped[str] = mapped_column(String(24), nullable=False, default="full")
     dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
     status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
     current_step: Mapped[str | None] = mapped_column(String(64), nullable=True)
-    scheduled_for: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     deadline_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 0 - 1
supply_infra/db/models/pipeline_step_run.py

@@ -68,7 +68,6 @@ class PipelineStepRun(Base):
     lease_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
     result_summary_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
-    metrics_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
     error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
     error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
     log_uri: Mapped[str | None] = mapped_column(String(1024), nullable=True)

+ 0 - 51
supply_infra/db/models/scheduler_job_execution.py

@@ -1,51 +0,0 @@
-from __future__ import annotations
-
-from datetime import datetime
-
-from sqlalchemy import BigInteger, Numeric, String, Text, func
-from sqlalchemy.orm import Mapped, mapped_column
-
-from supply_infra.db.base import Base
-
-
-class SchedulerJobExecution(Base):
-    """定时任务执行记录(开始/结束各记一条,同一 run_id 关联)。"""
-
-    __tablename__ = "scheduler_job_execution"
-
-    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
-    run_id: Mapped[str] = mapped_column(
-        String(36),
-        nullable=False,
-        index=True,
-        comment="同一次执行的唯一标识",
-    )
-    job_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="定时任务名称")
-    job_id: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="调度器 job id")
-    status: Mapped[str] = mapped_column(
-        String(32),
-        nullable=False,
-        comment="状态: started/finished/failed/skipped",
-    )
-    event_time: Mapped[datetime] = mapped_column(nullable=False, comment="事件发生时间")
-    biz_dt: Mapped[str | None] = mapped_column(String(8), nullable=True, comment="业务日 YYYYMMDD")
-    started_at: Mapped[datetime | None] = mapped_column(nullable=True, comment="任务开始时间")
-    finished_at: Mapped[datetime | None] = mapped_column(nullable=True, comment="任务结束时间")
-    duration_seconds: Mapped[float | None] = mapped_column(
-        Numeric(10, 2),
-        nullable=True,
-        comment="执行耗时(秒)",
-    )
-    error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因")
-    detail: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行结果摘要 JSON")
-    create_time: Mapped[datetime] = mapped_column(
-        nullable=False,
-        server_default=func.now(),
-        comment="创建时间",
-    )
-    update_time: Mapped[datetime] = mapped_column(
-        nullable=False,
-        server_default=func.now(),
-        onupdate=func.now(),
-        comment="更新时间",
-    )

+ 1 - 16
supply_infra/db/models/video_discovery.py

@@ -67,12 +67,6 @@ class VideoDiscoveryRun(Base):
     primary_count: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="主推荐数"
     )
-    backup_count: Mapped[int] = mapped_column(
-        Integer,
-        nullable=False,
-        default=0,
-        comment="Agent 自动保留的补充候选数",
-    )
     stop_reason: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="停止搜索的证据或失败原因"
     )
@@ -199,9 +193,6 @@ class VideoDiscoveryCandidate(Base):
     content_link: Mapped[str | None] = mapped_column(
         String(1024), nullable=True, comment="抖音页面链接"
     )
-    video_url: Mapped[str | None] = mapped_column(
-        Text, nullable=True, comment="解析时使用的播放链接"
-    )
     author_name: Mapped[str | None] = mapped_column(
         String(256), nullable=True, comment="作者名"
     )
@@ -247,12 +238,6 @@ class VideoDiscoveryCandidate(Base):
     age_portraits_normalized: Mapped[int] = mapped_column(
         Integer, nullable=False, default=0, comment="是否已执行年龄画像标准化"
     )
-    content_analysis: Mapped[str | None] = mapped_column(
-        Text, nullable=True, comment="视频内容核验结果"
-    )
-    content_analysis_verified: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已核验真实视频内容"
-    )
     expansion_worthy_tags_json: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="值得继续搜索的标签 JSON"
     )
@@ -279,7 +264,7 @@ class VideoDiscoveryCandidate(Base):
         String(24),
         nullable=False,
         default="pending_evaluation",
-        comment="primary / backup / rejected / pending_evaluation",
+        comment="最终为 primary / rejected;pending_evaluation 仅为过程状态",
     )
     aigc_crawler_plan_id: Mapped[str | None] = mapped_column(
         String(64), nullable=True, comment="已创建的 AIGC 爬取计划 id"

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

@@ -40,9 +40,6 @@ from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockReposito
 from supply_infra.db.repositories.pipeline_outbox_repo import PipelineOutboxRepository
 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.repositories.scheduler_job_execution_repo import (
-    SchedulerJobExecutionRepository,
-)
 from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
 
 __all__ = [
@@ -68,6 +65,5 @@ __all__ = [
     "PipelineOutboxRepository",
     "PipelineRunRepository",
     "PipelineStepRunRepository",
-    "SchedulerJobExecutionRepository",
     "VideoDiscoveryRepository",
 ]

+ 0 - 50
supply_infra/db/repositories/scheduler_job_execution_repo.py

@@ -1,50 +0,0 @@
-from __future__ import annotations
-
-from datetime import datetime
-
-from sqlalchemy import select
-
-from supply_infra.db.models.scheduler_job_execution import SchedulerJobExecution
-from supply_infra.db.repositories.base import BaseRepository
-
-
-class SchedulerJobExecutionRepository(BaseRepository[SchedulerJobExecution]):
-    model = SchedulerJobExecution
-
-    def log_event(
-        self,
-        *,
-        run_id: str,
-        job_name: str,
-        status: str,
-        event_time: datetime,
-        job_id: str | None = None,
-        biz_dt: str | None = None,
-        started_at: datetime | None = None,
-        finished_at: datetime | None = None,
-        duration_seconds: float | None = None,
-        error_message: str | None = None,
-        detail: str | None = None,
-    ) -> SchedulerJobExecution:
-        entity = SchedulerJobExecution(
-            run_id=run_id,
-            job_name=job_name,
-            job_id=job_id,
-            status=status,
-            event_time=event_time,
-            biz_dt=biz_dt,
-            started_at=started_at,
-            finished_at=finished_at,
-            duration_seconds=duration_seconds,
-            error_message=error_message,
-            detail=detail,
-        )
-        return self.add(entity)
-
-    def list_recent(self, *, limit: int = 50) -> list[SchedulerJobExecution]:
-        stmt = (
-            select(SchedulerJobExecution)
-            .order_by(SchedulerJobExecution.event_time.desc(), SchedulerJobExecution.id.desc())
-            .limit(limit)
-        )
-        return list(self.session.scalars(stmt).all())

+ 3 - 6
supply_infra/db/repositories/video_discovery_repo.py

@@ -22,8 +22,6 @@ _AUDIT_RELEVANT_CANDIDATE_FIELDS = (
     "content_portrait_attempted",
     "account_portrait_attempted",
     "age_portraits_normalized",
-    "video_url",
-    "content_analysis_verified",
     "expansion_worthy_tags_json",
 )
 
@@ -52,7 +50,7 @@ def _merge_json_list(raw: str | None, values: list[Any]) -> str | None:
     return json.dumps(merged, ensure_ascii=False) if merged else None
 
 
-_PUBLISHABLE_BUCKETS = ("primary", "backup")
+_PUBLISHABLE_BUCKETS = ("primary",)
 
 
 class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
@@ -323,7 +321,7 @@ class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
         skip_published: bool = True,
         limit: int | None = None,
     ) -> list[VideoDiscoveryCandidate]:
-        """查询待提交 AIGC 的候选视频;仅 primary / backup 分池,且 aweme_id 非空。"""
+        """查询待提交 AIGC 的候选视频;仅 primary 分池,且 aweme_id 非空。"""
         stmt = (
             select(VideoDiscoveryCandidate)
             .join(
@@ -350,7 +348,7 @@ class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
         return list(self.session.scalars(stmt).all())
 
     def count_passed_videos(self, biz_dt: str) -> int:
-        """统计业务日内 primary / backup 的唯一视频数。"""
+        """统计业务日内 primary 的唯一视频数。"""
         stmt = (
             select(func.count(func.distinct(VideoDiscoveryCandidate.aweme_id)))
             .join(
@@ -408,5 +406,4 @@ class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
         )
         counts = {str(bucket): int(count) for bucket, count in self.session.execute(bucket_stmt)}
         run.primary_count = counts.get("primary", 0)
-        run.backup_count = counts.get("backup", 0)
         self.session.flush()

+ 0 - 4
supply_infra/pipeline/run_service.py

@@ -72,7 +72,6 @@ def submit_pipeline_run(
     trigger_type: str = "api",
     trigger_source: str | None = None,
     trigger_reason: str | None = None,
-    scheduled_for: datetime | None = None,
     settings: InfraSettings | None = None,
 ) -> RunSubmission:
     active = settings or get_infra_settings()
@@ -112,12 +111,10 @@ def submit_pipeline_run(
                     "biz_dt": resolved_biz_dt,
                     "trigger_type": trigger_type,
                     "trigger_source": trigger_source or trigger_type,
-                    "triggered_by": None,
                     "trigger_reason": trigger_reason,
                     "run_mode": "full",
                     "dry_run": False,
                     "status": initial_status,
-                    "scheduled_for": scheduled_for,
                     "deadline_at": deadline_for_trigger(
                         trigger_type,
                         settings=active,
@@ -340,7 +337,6 @@ def serialize_run(run: PipelineRun) -> dict[str, Any]:
         "dry_run": run.dry_run,
         "status": run.status,
         "current_step": run.current_step,
-        "scheduled_for": _iso(run.scheduled_for),
         "deadline_at": _iso(run.deadline_at),
         "started_at": _iso(run.started_at),
         "finished_at": _iso(run.finished_at),

+ 0 - 38
supply_infra/scheduler/app.py

@@ -84,44 +84,6 @@ def start_scheduler() -> BackgroundScheduler | None:
     return _scheduler
 
 
-def get_scheduler_status() -> dict:
-    """Return scheduler runtime status for health checks."""
-    settings = get_infra_settings()
-    if not settings.scheduler_enabled:
-        return {
-            "enabled": False,
-            "running": False,
-            "timezone": settings.scheduler_timezone,
-            "jobs": [],
-        }
-
-    if _scheduler is None or not _scheduler.running:
-        return {
-            "enabled": True,
-            "running": False,
-            "timezone": settings.scheduler_timezone,
-            "jobs": [],
-        }
-
-    jobs = []
-    for job in _scheduler.get_jobs():
-        next_run = getattr(job, "next_run_time", None)
-        jobs.append(
-            {
-                "id": job.id,
-                "name": job.name,
-                "next_run_time": next_run.isoformat() if next_run else None,
-            }
-        )
-
-    return {
-        "enabled": True,
-        "running": True,
-        "timezone": settings.scheduler_timezone,
-        "jobs": jobs,
-    }
-
-
 def stop_scheduler() -> None:
     """Shut down the background scheduler if running."""
     global _scheduler

+ 0 - 4
supply_infra/scheduler/constants.py

@@ -5,7 +5,3 @@ SUPPLY_PIPELINE_JOB_NAME = "供给数据流水线"
 
 # find_agent:当日全部 S/A 需求(有拓展点位),单线程串行找片;有效视频满 200 提前结束
 PIPELINE_FIND_AGENT_WORKERS = 1
-
-# 供给流水线每日触发时间(Asia/Shanghai)
-PIPELINE_CRON_HOUR = 14
-PIPELINE_CRON_MINUTE = 30

+ 0 - 120
supply_infra/scheduler/job_execution.py

@@ -1,120 +0,0 @@
-"""定时任务执行记录写入辅助。"""
-from __future__ import annotations
-
-import json
-import logging
-import uuid
-from typing import Any
-
-from supply_infra.db.repositories.scheduler_job_execution_repo import (
-    SchedulerJobExecutionRepository,
-)
-from supply_infra.db.session import get_session
-from supply_infra.pipeline.dates import china_now
-
-logger = logging.getLogger(__name__)
-
-STATUS_STARTED = "started"
-STATUS_FINISHED = "finished"
-STATUS_FAILED = "failed"
-STATUS_SKIPPED = "skipped"
-
-
-def _serialize_detail(payload: Any) -> str | None:
-    if payload is None:
-        return None
-    return json.dumps(payload, ensure_ascii=False, default=str)
-
-
-class JobExecutionRecorder:
-    """在任务开始/结束时各写一条执行记录。"""
-
-    def __init__(
-        self,
-        *,
-        job_name: str,
-        job_id: str | None = None,
-        biz_dt: str | None = None,
-    ) -> None:
-        self.run_id = str(uuid.uuid4())
-        self.job_name = job_name
-        self.job_id = job_id
-        self.biz_dt = biz_dt
-        self.started_at = china_now()
-
-    def record_started(self) -> None:
-        try:
-            with get_session() as session:
-                SchedulerJobExecutionRepository(session).log_event(
-                    run_id=self.run_id,
-                    job_name=self.job_name,
-                    job_id=self.job_id,
-                    status=STATUS_STARTED,
-                    event_time=self.started_at,
-                    biz_dt=self.biz_dt,
-                    started_at=self.started_at,
-                )
-        except Exception:
-            logger.exception("Failed to record job start: job_name=%s run_id=%s", self.job_name, self.run_id)
-
-    def record_finished(
-        self,
-        *,
-        success: bool,
-        result: dict[str, Any] | None = None,
-        error_message: str | None = None,
-    ) -> None:
-        finished_at = china_now()
-        duration_seconds = round((finished_at - self.started_at).total_seconds(), 2)
-        status = STATUS_FINISHED if success else STATUS_FAILED
-
-        try:
-            with get_session() as session:
-                SchedulerJobExecutionRepository(session).log_event(
-                    run_id=self.run_id,
-                    job_name=self.job_name,
-                    job_id=self.job_id,
-                    status=status,
-                    event_time=finished_at,
-                    biz_dt=self.biz_dt,
-                    started_at=self.started_at,
-                    finished_at=finished_at,
-                    duration_seconds=duration_seconds,
-                    error_message=error_message,
-                    detail=_serialize_detail(result),
-                )
-        except Exception:
-            logger.exception(
-                "Failed to record job finish: job_name=%s run_id=%s status=%s",
-                self.job_name,
-                self.run_id,
-                status,
-            )
-
-
-def record_skipped(
-    *,
-    job_name: str,
-    job_id: str | None = None,
-    biz_dt: str | None = None,
-    reason: str,
-) -> None:
-    """记录因并发等原因跳过的执行。"""
-    now = china_now()
-    run_id = str(uuid.uuid4())
-    try:
-        with get_session() as session:
-            SchedulerJobExecutionRepository(session).log_event(
-                run_id=run_id,
-                job_name=job_name,
-                job_id=job_id,
-                status=STATUS_SKIPPED,
-                event_time=now,
-                biz_dt=biz_dt,
-                started_at=now,
-                finished_at=now,
-                duration_seconds=0,
-                error_message=reason,
-            )
-    except Exception:
-        logger.exception("Failed to record skipped job: job_name=%s reason=%s", job_name, reason)

+ 1 - 5
supply_infra/scheduler/jobs/demand_pool/__init__.py

@@ -1,5 +1 @@
-"""策略需求池同步任务(ODPS → MySQL + 归属/热度/树权重/视频)。"""
-
-from supply_infra.scheduler.jobs.demand_pool.sync import sync_multi_demand_pool_odps_to_mysql
-
-__all__ = ["sync_multi_demand_pool_odps_to_mysql"]
+"""Demand pool sync job stages used by the durable pipeline."""

+ 0 - 15
supply_infra/scheduler/jobs/demand_pool/__main__.py

@@ -1,15 +0,0 @@
-"""策略需求池同步任务 CLI:``python -m supply_infra.scheduler.jobs.demand_pool``。"""
-from __future__ import annotations
-
-import sys
-
-from supply_infra.scheduler.cli_result import run_cli
-from supply_infra.scheduler.jobs.demand_pool.sync import sync_multi_demand_pool_odps_to_mysql
-
-if __name__ == "__main__":
-    run_cli(
-        lambda: sync_multi_demand_pool_odps_to_mysql(
-            partition_date=sys.argv[1] if len(sys.argv) > 1 else None,
-        ),
-        label="sync_multi_demand_pool_odps_to_mysql",
-    )

+ 10 - 95
supply_infra/scheduler/jobs/demand_pool/sync.py

@@ -1,19 +1,13 @@
 """
-定时任务:从 ODPS 同步策略需求天级表到 MySQL,并对新词做归属分类与热度统计。
-
-流程:
-1. 比对当天 ODPS / MySQL 行数,相同则跳过写入
-2. 有差异时拉取 ODPS,按 (strategy, demand_id) 只插入缺失、删除多余,并回填已有行 video_list
-3. video_list 每条最多保留前 10 个 video_id,video_count 与之保持一致
-4. 拉取近 7 日 rov_diff/vov_diff,按特征值匹配回填 real_rov_7d / real_vov_7d
-5. 查询当天全部 demand_name,按空格分词写入 set
-6. 过滤 demand_belong_category 中已存在的词
-7. 剩余词按 100 词一批调用 demand_belong_category_agent
-8. 遍历 demand_belong_category 全部词,按策略与 rov_diff/vov_diff 写入 demand_popularity_stats(avg/count)
-9. 基于三表计算整棵类目树节点加权平均分,写入 category_tree_weight
-10. 四维热度全局排名归一化打分,写入各维 score 与 total_score
-11. 增量同步 multi_demand_video_detail(全表 video_list → ODPS 昨天分区最终选题)
-12. 补充 demand_belong_pool_rel 匹配边,并回填词级 video_list(最多 10 个)
+Demand pool ODPS → MySQL sync stages used by the durable pipeline.
+
+Stages owned by this module:
+1. source row sync (`_sync_pool_rows`)
+2. demand word classification (`_classify_words`)
+3. real rov/vov enrichment (`enrich_real_rov_vov_7d`)
+4. popularity stats (`compute_popularity_stats`)
+
+Related pipeline stages live in sibling modules (belong_rel / tree_weight / videos).
 """
 from __future__ import annotations
 
@@ -21,7 +15,7 @@ import json
 import logging
 from datetime import datetime, timedelta
 from decimal import Decimal
-from typing import Any, Callable
+from typing import Any
 
 from agents.demand_belong_category_agent.run import main as classify_demand_words
 from supply_infra.db.repositories.demand_belong_category_repo import (
@@ -33,11 +27,6 @@ from supply_infra.db.repositories.demand_popularity_stats_repo import (
 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
-from supply_infra.scheduler.jobs.demand_pool.belong_rel import sync_demand_belong_pool_rel
-from supply_infra.scheduler.jobs.demand_pool.tree_weight import (
-    compute_category_tree_weight,
-)
-from supply_infra.scheduler.jobs.demand_pool.videos import sync_multi_demand_videos
 
 logger = logging.getLogger(__name__)
 
@@ -502,77 +491,3 @@ def _sync_pool_rows(partition_date: str) -> dict[str, Any]:
         "mysql_count": mysql_count,
         **_sync_diff(partition_date),
     }
-
-
-def _run_sync_stage(
-    stage_name: str,
-    action: Callable[[], dict[str, Any]],
-) -> tuple[dict[str, Any], str | None]:
-    """执行需求池同步子阶段,错误转为结构化结果并允许后续阶段继续。"""
-    try:
-        payload = action()
-    except Exception as exc:
-        logger.exception("Multi demand pool stage failed; continue: stage=%s", stage_name)
-        return {"success": False, "error": str(exc)}, str(exc)
-
-    if payload.get("success") is False:
-        error = str(payload.get("error") or f"{stage_name} returned success=False")
-        logger.error(
-            "Multi demand pool stage reported failure; continue: stage=%s error=%s",
-            stage_name,
-            error,
-        )
-        return payload, error
-    return payload, None
-
-
-def sync_multi_demand_pool_odps_to_mysql(partition_date: str | None = None) -> dict:
-    """
-    从 ODPS 增量同步策略需求天级数据到 MySQL,并对新词做归属分类与热度统计。
-
-    Args:
-        partition_date: 分区日期 (YYYYMMDD),默认当天
-    """
-    if partition_date is None:
-        partition_date = datetime.now().strftime("%Y%m%d")
-
-    logger.info(
-        "Starting multi demand pool ODPS → MySQL sync for partition: %s",
-        partition_date,
-    )
-
-    stage_definitions: list[tuple[str, Callable[[], dict[str, Any]]]] = [
-        ("source_sync", lambda: _sync_pool_rows(partition_date)),
-        ("classify", lambda: _classify_words(partition_date)),
-        ("belong_pool_rel", sync_demand_belong_pool_rel),
-        ("real_metrics", lambda: enrich_real_rov_vov_7d(partition_date)),
-        ("popularity", lambda: compute_popularity_stats(partition_date)),
-        ("tree_weight", lambda: compute_category_tree_weight(partition_date)),
-        ("videos", lambda: sync_multi_demand_videos(limit=None)),
-    ]
-    stage_results: dict[str, dict[str, Any]] = {}
-    errors: list[dict[str, str]] = []
-    for stage_name, action in stage_definitions:
-        payload, error = _run_sync_stage(stage_name, action)
-        stage_results[stage_name] = payload
-        if error:
-            errors.append({"stage": stage_name, "error": error})
-
-    sync_stats = stage_results["source_sync"]
-
-    result = {
-        "success": not errors,
-        "partition_date": partition_date,
-        **({} if errors and sync_stats.get("success") is False else sync_stats),
-        "source_sync": sync_stats,
-        "real_metrics": stage_results["real_metrics"],
-        "classify": stage_results["classify"],
-        "belong_pool_rel": stage_results["belong_pool_rel"],
-        "popularity": stage_results["popularity"],
-        "tree_weight": stage_results["tree_weight"],
-        "videos": stage_results["videos"],
-        "errors": errors,
-        "synced_at": datetime.now().isoformat(),
-    }
-    logger.info("Multi demand pool sync completed: %s", result)
-    return result

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

@@ -127,7 +127,7 @@ def discover_videos_from_demands(
     每条记录对应一个 demand_grade + 其下全部视频与全部拓展点位。
     执行前会预写 video_discovery_run,并按 biz_dt + demand_grade_id 跳过已执行记录。
     默认处理全部 S/A(S 优先于 A、再按 score 排序);仅 CLI --top-limit 可人为截断。
-    当日 primary+backup 去重视频达到 200 时提前结束,否则跑完待处理队列。
+    当日 primary 去重视频达到 200 时提前结束,否则跑完待处理队列。
     固定单线程串行执行 find_agent(workers 参数已废弃,始终为 1)。
     """
     started_at = datetime.now()

+ 4 - 4
supply_infra/scheduler/jobs/publish_videos_from_discovery.py

@@ -21,8 +21,8 @@ from supply_infra.db.session import get_session
 
 logger = logging.getLogger(__name__)
 
-# 仅发布主推荐与人工备选,不包含 rejected / pending_evaluation。
-_PUBLISHABLE_BUCKETS = ("primary", "backup")
+# 仅发布主推荐,不包含 rejected / pending_evaluation。
+_PUBLISHABLE_BUCKETS = ("primary",)
 
 
 @dataclass(frozen=True)
@@ -96,7 +96,7 @@ def publish_videos_from_discovery(
     """
     从 video_discovery_candidate 读取视频,按轮询均匀分配到各 AIGC 计划对。
 
-    仅处理 decision_bucket 为 primary / backup 且 aweme_id 非空的候选,不区分品类。
+    仅处理 decision_bucket 为 primary 且 aweme_id 非空的候选,不区分品类。
     """
     settings = get_infra_settings()
     resolved_biz_dt = _resolve_biz_dt(biz_dt)
@@ -221,7 +221,7 @@ if __name__ == "__main__":
 
     def _build_parser() -> argparse.ArgumentParser:
         parser = argparse.ArgumentParser(
-            description="均匀分发 primary/backup 候选视频到 AIGC 发布计划"
+            description="均匀分发 primary 候选视频到 AIGC 发布计划"
         )
         parser.add_argument("biz_dt", nargs="?", help="业务日 YYYYMMDD,默认取最新")
         parser.add_argument("--run-id", dest="run_id", help="仅处理指定 run_id")

+ 0 - 66
supply_infra/scheduler/manual_jobs.py

@@ -1,66 +0,0 @@
-"""Deprecated compatibility facade for durable pipeline submissions."""
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Any
-
-from supply_infra.pipeline.run_service import get_pipeline_run, submit_pipeline_run
-from supply_infra.scheduler.constants import SUPPLY_PIPELINE_JOB_ID, SUPPLY_PIPELINE_JOB_NAME
-
-
-@dataclass(frozen=True)
-class ManualJobSpec:
-    job_id: str
-    name: str
-    description: str
-    accepts_biz_dt: bool = True
-    accepts_partition_date: bool = False
-
-
-MANUAL_JOBS = {
-    SUPPLY_PIPELINE_JOB_ID: ManualJobSpec(
-        job_id=SUPPLY_PIPELINE_JOB_ID,
-        name=SUPPLY_PIPELINE_JOB_NAME,
-        description="提交严格门禁的持久化供给流水线",
-    )
-}
-
-
-def list_manual_jobs() -> list[dict[str, Any]]:
-    return [
-        {
-            "id": item.job_id,
-            "name": item.name,
-            "description": item.description,
-            "accepts_biz_dt": item.accepts_biz_dt,
-            "accepts_partition_date": item.accepts_partition_date,
-        }
-        for item in MANUAL_JOBS.values()
-    ]
-
-
-def get_manual_job(job_id: str) -> ManualJobSpec | None:
-    return MANUAL_JOBS.get(job_id)
-
-
-def get_manual_run(run_id: str) -> dict[str, Any] | None:
-    return get_pipeline_run(run_id)
-
-
-def trigger_manual_job(
-    job_id: str,
-    *,
-    biz_dt: str | None = None,
-    partition_date: str | None = None,
-    wait: bool = False,
-) -> dict[str, Any]:
-    del partition_date
-    if job_id != SUPPLY_PIPELINE_JOB_ID:
-        raise KeyError(job_id)
-    if wait:
-        raise ValueError("wait=true is disabled; poll the persistent run_id")
-    return submit_pipeline_run(
-        biz_dt=biz_dt,
-        trigger_type="api",
-        trigger_source="legacy_manual_job",
-    ).to_dict()

+ 0 - 8
supply_infra/scheduler/step_runner.py

@@ -1,8 +0,0 @@
-"""Deprecated import compatibility for the durable pipeline step runner."""
-
-from supply_infra.pipeline.step_runner import (
-    StepExecutionRequest,
-    run_step_subprocess,
-)
-
-__all__ = ["StepExecutionRequest", "run_step_subprocess"]

+ 16 - 73
tests/supply_infra/scheduler/test_discover_videos_from_demands.py

@@ -1,27 +1,32 @@
 from __future__ import annotations
 
 import asyncio
-import json
-import subprocess
-from pathlib import Path
-from unittest.mock import AsyncMock, patch
+from unittest.mock import patch
 
 import pytest
 
 from agents.find_agent.async_runner import arun_find_agent
-from agents.find_agent.tools.qwen_video_analyze import (
-    FFMPEG_TIMEOUT_SECONDS,
-    FFPROBE_TIMEOUT_SECONDS,
-    ToolTimeoutError,
-    _probe_duration,
-    _truncate_video,
-    qwen_video_analyze,
+from supply_infra.db.models.video_discovery import (
+    VideoDiscoveryCandidate,
+    VideoDiscoveryRun,
 )
 from supply_infra.scheduler.jobs.discover_videos_from_demands import (
     discover_videos_from_demands,
 )
 
 
+def test_video_discovery_models_exclude_unused_columns() -> None:
+    run_columns = set(VideoDiscoveryRun.__table__.columns.keys())
+    candidate_columns = set(VideoDiscoveryCandidate.__table__.columns.keys())
+
+    assert "backup_count" not in run_columns
+    assert {
+        "video_url",
+        "content_analysis",
+        "content_analysis_verified",
+    }.isdisjoint(candidate_columns)
+
+
 @patch(
     "supply_infra.scheduler.jobs.discover_videos_from_demands.process_single_discover"
 )
@@ -83,65 +88,3 @@ async def test_find_agent_timeout_closes_async_client() -> None:
         )
 
     assert agent.llm._async_client.closed is True
-
-
-@patch(
-    "agents.find_agent.tools.qwen_video_analyze._resolve_ffprobe",
-    return_value="/usr/bin/ffprobe",
-)
-@patch("agents.find_agent.tools.qwen_video_analyze.subprocess.run")
-def test_probe_duration_stops_after_subprocess_timeouts(
-    mock_run,
-    _mock_resolve_ffprobe,
-) -> None:
-    mock_run.side_effect = [
-        subprocess.TimeoutExpired("ffprobe", FFPROBE_TIMEOUT_SECONDS),
-        subprocess.TimeoutExpired("ffmpeg", FFPROBE_TIMEOUT_SECONDS),
-    ]
-
-    assert _probe_duration("/usr/bin/ffmpeg", "https://example.com/video.mp4") is None
-    assert [call.kwargs["timeout"] for call in mock_run.call_args_list] == [
-        FFPROBE_TIMEOUT_SECONDS,
-        FFPROBE_TIMEOUT_SECONDS,
-    ]
-
-
-@patch("agents.find_agent.tools.qwen_video_analyze.subprocess.run")
-def test_truncate_video_raises_after_bounded_ffmpeg_timeouts(
-    mock_run,
-    tmp_path: Path,
-) -> None:
-    mock_run.side_effect = [
-        subprocess.TimeoutExpired("ffmpeg-copy", FFMPEG_TIMEOUT_SECONDS),
-        subprocess.TimeoutExpired("ffmpeg-encode", FFMPEG_TIMEOUT_SECONDS),
-    ]
-
-    with pytest.raises(RuntimeError, match="ffmpeg 截断视频超时"):
-        _truncate_video(
-            "/usr/bin/ffmpeg",
-            tmp_path / "input.mp4",
-            tmp_path / "output.mp4",
-            180,
-        )
-
-    assert [call.kwargs["timeout"] for call in mock_run.call_args_list] == [
-        FFMPEG_TIMEOUT_SECONDS,
-        FFMPEG_TIMEOUT_SECONDS,
-    ]
-
-
-@pytest.mark.asyncio
-@patch(
-    "agents.find_agent.tools.qwen_video_analyze._prepare_analysis_url",
-    new_callable=AsyncMock,
-)
-async def test_qwen_tool_timeout_is_returned_to_agent(
-    mock_prepare,
-) -> None:
-    mock_prepare.side_effect = ToolTimeoutError("ffmpeg timeout")
-
-    result = json.loads(await qwen_video_analyze("https://example.com/video.mp4"))
-
-    assert result["error"] == "ffmpeg timeout"
-    assert result["error_code"] == "tool_timeout"
-    assert result["retryable"] is True

+ 1 - 50
tests/supply_infra/scheduler/test_sync_multi_demand_pool.py

@@ -3,56 +3,7 @@ from __future__ import annotations
 
 from unittest.mock import MagicMock, patch
 
-from supply_infra.scheduler.jobs.demand_pool.sync import (
-    _classify_words,
-    sync_multi_demand_pool_odps_to_mysql,
-)
-
-
-@patch(
-    "supply_infra.scheduler.jobs.demand_pool.sync.sync_multi_demand_videos"
-)
-@patch(
-    "supply_infra.scheduler.jobs.demand_pool.sync.compute_category_tree_weight"
-)
-@patch(
-    "supply_infra.scheduler.jobs.demand_pool.sync.compute_popularity_stats"
-)
-@patch(
-    "supply_infra.scheduler.jobs.demand_pool.sync.enrich_real_rov_vov_7d"
-)
-@patch(
-    "supply_infra.scheduler.jobs.demand_pool.sync.sync_demand_belong_pool_rel"
-)
-@patch("supply_infra.scheduler.jobs.demand_pool.sync._classify_words")
-@patch("supply_infra.scheduler.jobs.demand_pool.sync._sync_pool_rows")
-def test_substep_failure_does_not_skip_later_substeps(
-    mock_source_sync,
-    mock_classify,
-    mock_rel,
-    mock_real_metrics,
-    mock_popularity,
-    mock_tree_weight,
-    mock_videos,
-) -> None:
-    mock_source_sync.return_value = {"inserted": 10}
-    mock_classify.side_effect = RuntimeError("agent unavailable")
-    mock_rel.return_value = {"inserted": 2}
-    mock_real_metrics.return_value = {"updated_rows": 3}
-    mock_popularity.return_value = {"upserted": 4}
-    mock_tree_weight.return_value = {"upserted": 5}
-    mock_videos.return_value = {"inserted": 6}
-
-    result = sync_multi_demand_pool_odps_to_mysql("20260721")
-
-    assert result["success"] is False
-    assert result["classify"] == {"success": False, "error": "agent unavailable"}
-    assert result["belong_pool_rel"] == {"inserted": 2}
-    assert result["real_metrics"] == {"updated_rows": 3}
-    assert result["popularity"] == {"upserted": 4}
-    assert result["tree_weight"] == {"upserted": 5}
-    assert result["videos"] == {"inserted": 6}
-    assert [item["stage"] for item in result["errors"]] == ["classify"]
+from supply_infra.scheduler.jobs.demand_pool.sync import _classify_words
 
 
 @patch(