فهرست منبع

优化寻找agent

xueyiming 2 روز پیش
والد
کامیت
f000a509ca
42فایلهای تغییر یافته به همراه3306 افزوده شده و 2608 حذف شده
  1. 22 23
      PRD.md
  2. 0 135
      agents/find_agent/README.md
  3. 0 119
      agents/find_agent/VALIDATION.md
  4. 3 10
      agents/find_agent/demand_run.py
  5. 82 107
      agents/find_agent/prompt/system_prompt.md
  6. 4 0
      agents/find_agent/support/__init__.py
  7. 1 169
      agents/find_agent/support/age_portrait.py
  8. 26 0
      agents/find_agent/support/batch_search_and_record.py
  9. 359 0
      agents/find_agent/support/douyin_detail.py
  10. 273 0
      agents/find_agent/support/douyin_search.py
  11. 456 0
      agents/find_agent/support/douyin_search_tikhub.py
  12. 301 0
      agents/find_agent/support/douyin_user_videos.py
  13. 1 6
      agents/find_agent/support/portrait.py
  14. 1 1
      agents/find_agent/support/qwen_video_analysis.py
  15. 232 0
      agents/find_agent/support/search_persistence.py
  16. 329 0
      agents/find_agent/support/video_discovery.py
  17. 39 18
      agents/find_agent/tools/__init__.py
  18. 19 0
      agents/find_agent/tools/batch_fetch_portraits.py
  19. 187 0
      agents/find_agent/tools/batch_search_and_record.py
  20. 19 0
      agents/find_agent/tools/batch_update_video_discovery_candidates.py
  21. 19 0
      agents/find_agent/tools/create_video_discovery_run.py
  22. 13 355
      agents/find_agent/tools/douyin_detail.py
  23. 45 210
      agents/find_agent/tools/douyin_search.py
  24. 45 384
      agents/find_agent/tools/douyin_search_tikhub.py
  25. 34 248
      agents/find_agent/tools/douyin_user_videos.py
  26. 19 0
      agents/find_agent/tools/get_account_fans_portrait.py
  27. 19 0
      agents/find_agent/tools/get_content_fans_portrait.py
  28. 19 0
      agents/find_agent/tools/normalize_age_portraits.py
  29. 19 0
      agents/find_agent/tools/query_video_discovery_state.py
  30. 19 0
      agents/find_agent/tools/update_video_discovery_run_status.py
  31. 0 572
      agents/find_agent/tools/video_discovery_store.py
  32. 117 0
      alembic/versions/20260729_01_search_candidate_occurrences.py
  33. 95 0
      alembic/versions/20260729_02_remove_candidate_audit_fields.py
  34. 53 0
      alembic/versions/20260729_03_value_score_range_comment.py
  35. 0 22
      sql/video_discovery_add_audit_evidence.sql
  36. 15 0
      sql/video_discovery_search_candidate_occurrences.sql
  37. 8 19
      sql/video_discovery_tables.sql
  38. 14 31
      supply_infra/db/models/video_discovery.py
  39. 51 113
      supply_infra/db/repositories/video_discovery_repo.py
  40. 31 55
      supply_infra/services/video_discovery_service.py
  41. 310 2
      tests/supply_infra/scheduler/test_discover_videos_from_demands.py
  42. 7 9
      zhangbo.md

+ 22 - 23
PRD.md

@@ -59,7 +59,7 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 - 统一的“平台需求”版本化产品对象;
 - 统一的“平台需求”版本化产品对象;
 - 人工反馈和线上效果自动回流到次日决策;
 - 人工反馈和线上效果自动回流到次日决策;
 - 真正调用发布计划完成内容发布;
 - 真正调用发布计划完成内容发布;
-- 在页面中展示 `find_agent` 最终候选及完整审计过程;
+- 在页面中展示 `find_agent` 最终候选及完整执行过程;
 - 跨实例的任务编排、分布式锁和可靠消息机制。
 - 跨实例的任务编排、分布式锁和可靠消息机制。
 
 
 ---
 ---
@@ -98,7 +98,7 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 | 找片运行 | 搜索树、候选证据、评分和分池 | `video_discovery_run/search/candidate` |
 | 找片运行 | 搜索树、候选证据、评分和分池 | `video_discovery_run/search/candidate` |
 | AIGC 分发状态 | 候选被分配到的爬取/生产/发布计划标识 | `video_discovery_candidate` |
 | AIGC 分发状态 | 候选被分配到的爬取/生产/发布计划标识 | `video_discovery_candidate` |
 | 任务执行记录 | 总流水线 started/finished/failed/skipped | `scheduler_job_execution` |
 | 任务执行记录 | 总流水线 started/finished/failed/skipped | `scheduler_job_execution` |
-| Agent 审计日志 | 模型输入、输出、工具调用及 HTML | 本地 `logs/`、OSS、`oss_logs` |
+| Agent 运行日志 | 模型输入、输出、工具调用及 HTML | 本地 `logs/`、OSS、`oss_logs` |
 
 
 ---
 ---
 
 
@@ -399,15 +399,15 @@ flowchart LR
    - `rejected`:淘汰;
    - `rejected`:淘汰;
    - `pending_evaluation`:尚未完成的过程状态,不是最终等级;
    - `pending_evaluation`:尚未完成的过程状态,不是最终等级;
 9. Agent 保存最终候选评估并将运行置为 `finished`;
 9. Agent 保存最终候选评估并将运行置为 `finished`;
-10. 数据库审计通过后重新查询最终状态,再输出主推荐、淘汰原因、搜索树和缺失证据;
-11. completion guard 校验顺序和报告分池,报告之后不再修改数据库。
+10. 重新查询最终状态,再输出主推荐、淘汰原因、搜索树和缺失证据;
+11. 报告之后不再修改数据库。
 
 
 ### 11.3 输出
 ### 11.3 输出
 
 
 - `video_discovery_run`;
 - `video_discovery_run`;
 - `video_discovery_search`;
 - `video_discovery_search`;
 - `video_discovery_candidate`;
 - `video_discovery_candidate`;
-- Agent 审计日志和 OSS HTML。
+- Agent 运行日志和 OSS HTML。
 
 
 ---
 ---
 
 
@@ -498,7 +498,7 @@ flowchart LR
 - 下游步骤只能消费通过完整性校验的上游产物;
 - 下游步骤只能消费通过完整性校验的上游产物;
 - 分类树或需求池失败时,不得执行依赖其结果的分级;
 - 分类树或需求池失败时,不得执行依赖其结果的分级;
 - 分级覆盖不完整时,不得将该日结果作为可发布批次;
 - 分级覆盖不完整时,不得将该日结果作为可发布批次;
-- 找片未完成审计时,不得进入 AIGC 分发。
+- 找片运行未达到 `finished` 或候选未完成分池时,不得进入 AIGC 分发。
 
 
 ### FR-03 数据同步
 ### FR-03 数据同步
 
 
@@ -532,13 +532,13 @@ flowchart LR
 
 
 - 入选顺序必须符合确认后的业务策略,S/A 优先级与 score 排序不得冲突;
 - 入选顺序必须符合确认后的业务策略,S/A 优先级与 score 排序不得冲突;
 - 每个运行必须有租约和超时,崩溃遗留的 `running` 可自动恢复;
 - 每个运行必须有租约和超时,崩溃遗留的 `running` 可自动恢复;
-- 完成前必须校验搜索页、候选评估、证据、审计和最终状态;
+- 完成前必须保存搜索页、候选评估、证据和最终状态;
 - 强制重跑必须新建 attempt 或清理旧子记录,不能混用两次搜索状态;
 - 强制重跑必须新建 attempt 或清理旧子记录,不能混用两次搜索状态;
 - 相同视频跨需求发现时必须全局去重或形成“一视频多需求”关系。
 - 相同视频跨需求发现时必须全局去重或形成“一视频多需求”关系。
 
 
 ### FR-08 AIGC 分发与发布
 ### FR-08 AIGC 分发与发布
 
 
-- 只有 finished 且审计通过的找片运行可以分发;
+- 只有 `finished` 的找片运行可以分发;
 - 仅 `primary` 允许自动分发;
 - 仅 `primary` 允许自动分发;
 - 候选必须按需求分类或明确的路由规则进入正确计划;
 - 候选必须按需求分类或明确的路由规则进入正确计划;
 - 创建、绑定、生产、发布每个外部动作必须有幂等键和独立状态;
 - 创建、绑定、生产、发布每个外部动作必须有幂等键和独立状态;
@@ -597,8 +597,8 @@ flowchart LR
 | P0-03 | 无计划或无分级也可能成功 | 当日需求完全未处理仍进入拓展和找片 | 自动计划异常被吞掉;空计划快照可被视为 complete | 校验计划覆盖率和分级覆盖率 |
 | P0-03 | 无计划或无分级也可能成功 | 当日需求完全未处理仍进入拓展和找片 | 自动计划异常被吞掉;空计划快照可被视为 complete | 校验计划覆盖率和分级覆盖率 |
 | P0-04 | Agent 返回即把分级明细标记 finished | 模型未保存、少保存或保存工具报错时产生假完成 | worker 不核对 `demand_grade` 实际落库覆盖 | 每批结束后按输入逐条验库 |
 | P0-04 | Agent 返回即把分级明细标记 finished | 模型未保存、少保存或保存工具报错时产生假完成 | worker 不核对 `demand_grade` 实际落库覆盖 | 每批结束后按输入逐条验库 |
 | P0-05 | 点位拓展把“未保存”误判为“零结果” | S/A 需求被永久标记完成并从找片链路消失 | 从工具文本解析不到数量时默认为 0,仍写 finished | 必须验证保存工具调用和 run 状态 |
 | P0-05 | 点位拓展把“未保存”误判为“零结果” | S/A 需求被永久标记完成并从找片链路消失 | 从工具文本解析不到数量时默认为 0,仍写 finished | 必须验证保存工具调用和 run 状态 |
-| P0-06 | 找片确定性完成控制(已修复) | 防止在搜索、证据、评估或审计未完成时提前结束 | 已注册数据库审计并启用 completion guard,强制审计后查询最终状态再报告 | 保持顺序与负向回归测试 |
-| P0-07 | AIGC 分发不校验找片运行状态 | running/failed 运行中的候选也可能被外发 | publish 查询只筛候选 bucket,不筛 run status/audit | 仅 finished+审计通过可分发 |
+| P0-06 | 找片结束顺序 | 防止在搜索、证据或评估未完成时提前结束 | Agent 保存候选和 `finished` 状态后查询最终状态 | 保持顺序与负向回归测试 |
+| P0-07 | AIGC 分发不校验找片运行状态 | running/failed 运行中的候选也可能被外发 | publish 查询只筛候选 bucket,不筛 run status | 仅 `finished` 可分发 |
 | P0-08 | AIGC 按所有计划轮询,不按品类路由 | 健康、历史、时政等视频可能进入错误生产计划 | 代码明确“不区分品类”,均匀分发 | 建立可配置且可解释的分类路由 |
 | P0-08 | AIGC 按所有计划轮询,不按品类路由 | 健康、历史、时政等视频可能进入错误生产计划 | 代码明确“不区分品类”,均匀分发 | 建立可配置且可解释的分类路由 |
 | P0-09 | “发布”没有真正执行发布 | 业务误以为已发布,实际只绑定了生成计划 | `publish_plan_id` 只入库,没有参与外部 API 调用 | 拆分分发/生产/发布状态并实现确认 |
 | P0-09 | “发布”没有真正执行发布 | 业务误以为已发布,实际只绑定了生成计划 | `publish_plan_id` 只入库,没有参与外部 API 调用 | 拆分分发/生产/发布状态并实现确认 |
 | P0-10 | 外部副作用缺少端到端幂等 | 绑定失败、进程崩溃或数据库回写失败会重复创建爬取计划 | 只有 DB 回写成功后才算已处理 | 使用业务幂等键、outbox 和状态机 |
 | P0-10 | 外部副作用缺少端到端幂等 | 绑定失败、进程崩溃或数据库回写失败会重复创建爬取计划 | 只有 DB 回写成功后才算已处理 | 使用业务幂等键、outbox 和状态机 |
@@ -626,7 +626,7 @@ flowchart LR
 | P1-16 | `running` 找片记录无租约,可能永久跳过 | 进程硬退出后任务永远不再执行 | 增加 heartbeat、超时和 attempt |
 | P1-16 | `running` 找片记录无租约,可能永久跳过 | 进程硬退出后任务永远不再执行 | 增加 heartbeat、超时和 attempt |
 | P1-17 | 强制重跑复用旧 run_id 和旧子记录 | 两次搜索轨迹、候选和状态互相污染 | 每次重跑新 attempt,显式继承关系 |
 | P1-17 | 强制重跑复用旧 run_id 和旧子记录 | 两次搜索轨迹、候选和状态互相污染 | 每次重跑新 attempt,显式继承关系 |
 | P1-18 | 同一 aweme_id 可在多个 run 重复分发 | AIGC 重复抓取/生产同一视频 | 建立全局视频资产和发布唯一性 |
 | P1-18 | 同一 aweme_id 可在多个 run 重复分发 | AIGC 重复抓取/生产同一视频 | 建立全局视频资产和发布唯一性 |
-| P1-19 | 最终文字覆盖数据库分池(已修复) | 防止未经审计的文本解析改变候选状态 | 最终报告只读数据库最终状态并由 guard 校验 |
+| P1-19 | 最终文字覆盖数据库分池(已修复) | 防止未经数据库状态确认的文本解析改变候选状态 | 最终报告只读数据库最终状态 |
 | P1-20 | 前端“视频发现”未展示真实发现候选 | 运营无法核对主推荐、备选和发布状态 | 新增 candidate/run API 和页面 |
 | P1-20 | 前端“视频发现”未展示真实发现候选 | 运营无法核对主推荐、备选和发布状态 | 新增 candidate/run API 和页面 |
 | P1-21 | Scheduler 默认启用且随 API 启动 | 开发、扩容或临时环境可能误触生产任务 | 生产显式开启,默认关闭 |
 | P1-21 | Scheduler 默认启用且随 API 启动 | 开发、扩容或临时环境可能误触生产任务 | 生产显式开启,默认关闭 |
 | P1-22 | 延迟超过 1 小时会丢失当日调度 | API 故障恢复后不会自动补跑 | 按 biz_dt 对账并自动补批次 |
 | P1-22 | 延迟超过 1 小时会丢失当日调度 | API 故障恢复后不会自动补跑 | 按 biz_dt 对账并自动补批次 |
@@ -679,8 +679,8 @@ flowchart TB
     GRADE["生成分级计划并执行"]
     GRADE["生成分级计划并执行"]
     COVER{"计划覆盖率=100%<br/>分级覆盖率=100%?"}
     COVER{"计划覆盖率=100%<br/>分级覆盖率=100%?"}
     EXPAND["S/A 拓展<br/>每条都有明确终态"]
     EXPAND["S/A 拓展<br/>每条都有明确终态"]
-    DISCOVER["找片 attempt<br/>租约/恢复/完成审计"]
-    AUDIT{"运行 finished<br/>候选审计通过?"}
+    DISCOVER["找片 attempt<br/>租约/恢复/候选分池"]
+    READY{"运行 finished<br/>候选已完成分池?"}
     ROUTE["按需求分类路由 AIGC"]
     ROUTE["按需求分类路由 AIGC"]
     OUTBOX["幂等 outbox<br/>创建→绑定→生产→发布"]
     OUTBOX["幂等 outbox<br/>创建→绑定→生产→发布"]
     VERIFY["查询外部状态并确认"]
     VERIFY["查询外部状态并确认"]
@@ -697,9 +697,9 @@ flowchart TB
     COVER -->|否| STOP
     COVER -->|否| STOP
     COVER -->|是| EXPAND
     COVER -->|是| EXPAND
     EXPAND --> DISCOVER
     EXPAND --> DISCOVER
-    DISCOVER --> AUDIT
-    AUDIT -->|否| STOP
-    AUDIT -->|是| ROUTE
+    DISCOVER --> READY
+    READY -->|否| STOP
+    READY -->|是| ROUTE
     ROUTE --> OUTBOX
     ROUTE --> OUTBOX
     OUTBOX --> VERIFY
     OUTBOX --> VERIFY
     VERIFY -->|失败| STOP
     VERIFY -->|失败| STOP
@@ -715,8 +715,8 @@ flowchart TB
 - 为总流水线增加依赖门禁;
 - 为总流水线增加依赖门禁;
 - 修复 failed 分级组被视为完成的问题;
 - 修复 failed 分级组被视为完成的问题;
 - 增加计划覆盖率、分级覆盖率和逐项落库校验;
 - 增加计划覆盖率、分级覆盖率和逐项落库校验;
-- 保持找片完成守卫、数据库审计和最终状态顺序的负向回归;
-- AIGC 只读取 finished 且审计通过的运行;
+- 保持找片候选更新、完成状态和最终查询顺序的负向回归;
+- AIGC 只读取 `finished` 的运行;
 - 暂停无分类路由的自动分发,先切换为 dry-run 或人工确认;
 - 暂停无分类路由的自动分发,先切换为 dry-run 或人工确认;
 - 对 AIGC 请求日志脱敏;
 - 对 AIGC 请求日志脱敏;
 - 明确“分发、生产、发布”三种状态。
 - 明确“分发、生产、发布”三种状态。
@@ -770,13 +770,12 @@ flowchart TB
 
 
 - 每条入选 S/A 需求都有“已完成、无候选、无数据、失败”之一;
 - 每条入选 S/A 需求都有“已完成、无候选、无数据、失败”之一;
 - 无拓展点的 S/A 需求仍能以原需求搜索;
 - 无拓展点的 S/A 需求仍能以原需求搜索;
-- finished 运行不存在 `pending_evaluation` 候选;
-- 搜索页、证据、候选分池、审计和最终报告一致;
+- 搜索页和已作出的候选判断能够按 `run_id` 追溯;
 - 崩溃遗留 running 能在超时后自动恢复。
 - 崩溃遗留 running 能在超时后自动恢复。
 
 
 ### 21.5 AIGC
 ### 21.5 AIGC
 
 
-- 100% 候选来自 finished 且审计通过的运行;
+- 100% 候选来自 `finished` 的运行;
 - 每个候选进入与需求分类匹配的计划;
 - 每个候选进入与需求分类匹配的计划;
 - 同一 aweme 在同一发布策略下最多外发一次;
 - 同一 aweme 在同一发布策略下最多外发一次;
 - 创建、绑定、生产、发布状态可分别查询;
 - 创建、绑定、生产、发布状态可分别查询;
@@ -787,7 +786,7 @@ flowchart TB
 
 
 - `pytest` 可完整收集并通过;
 - `pytest` 可完整收集并通过;
 - P0 路径具备单元测试和集成测试;
 - P0 路径具备单元测试和集成测试;
-- CI 覆盖同步差异、失败门禁、断点重跑、并发锁、找片审计和 AIGC 幂等;
+- CI 覆盖同步差异、失败门禁、断点重跑、并发锁、找片状态一致性和 AIGC 幂等;
 - AIGC 默认 dry-run,通过灰度和人工确认后才开启真实外发。
 - AIGC 默认 dry-run,通过灰度和人工确认后才开启真实外发。
 
 
 ---
 ---
@@ -801,7 +800,7 @@ flowchart TB
 | 分级计划覆盖率 | 100% |
 | 分级计划覆盖率 | 100% |
 | 分级结果覆盖率 | 100% |
 | 分级结果覆盖率 | 100% |
 | S/A 明确终态覆盖率 | 100% |
 | S/A 明确终态覆盖率 | 100% |
-| 找片审计通过率 | 可按失败原因分层,不允许绕过 |
+| 找片完成状态覆盖率 | 100% |
 | 重复 AIGC 外发率 | 0 |
 | 重复 AIGC 外发率 | 0 |
 | 错误品类路由率 | 0 |
 | 错误品类路由率 | 0 |
 | 密钥明文日志事件 | 0 |
 | 密钥明文日志事件 | 0 |

+ 0 - 135
agents/find_agent/README.md

@@ -1,135 +0,0 @@
-# find_agent:老年受众高潜视频发现
-
-## 输入与结果
-
-输入由 `demand_word`、`seed_video_title`、`relevant_points` 组成。需求词只负责表达原始
-需求,不限定实际搜索词;Agent 根据参考视频和点位判断真实内容意图,自主生成并扩展
-多个搜索词。
-
-搜索来源包括:
-
-- `douyin_search`:现有内部关键词搜索;
-- `douyin_search_tikhub`:TikHub 关键词搜索,保留完整分页状态和视频标签;
-- `douyin_user_videos`:按最热/最新扩展候选作者的历史作品。
-
-三个工具统一返回 `search_results`,每项包含 `aweme_id、desc、url、author、
-statistics`;新增工具还返回 `duration_ms、topics、collect_count、play_count`。
-TikHub 翻页必须同时沿用 `next_cursor、search_id、backtrace`。
-使用 TikHub 前需在项目 `.env` 配置 `TIKHUB_API_KEY`;未配置时 Agent 会记录失败并
-回退到内部关键词搜索。
-
-另外注册了一个无外部依赖的决策辅助工具:
-
-- `normalize_age_portraits`:识别真实接口中的 `50-` 等年龄桶,统一视频和作者证据;
-
-当前流程明确不使用视频理解,不调用视频画面、语音、字幕或多模态解析工具。内容相关性
-与分享动机仅依据标题、描述、话题、详情文本、互动数据和画像判断。数据库不保存视频
-播放地址、内容分析结论或视频理解核验标记。
-
-最终结果只分为两个等级:
-
-- `primary`:与需求相关,且老年倾向、分享价值达到主推荐边界;
-- `rejected`:未满足 `primary` 的候选。
-
-`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 分池 |
-
-已注册五个持久化与审计工具:
-
-- `create_video_discovery_run`:创建运行并取得 `run_id`;
-- `record_video_search_page`:保存每次搜索和翻页,幂等合并 `aweme_id`;
-- `batch_save_video_candidate_evaluations`:原样保存 Agent 给出的证据、评分和分池;
-- `audit_video_discovery_run`:从数据库读取完整运行状态并执行确定性完成审计;
-- `query_video_discovery_state`:恢复搜索树、主推荐和淘汰候选;
-
-候选分池完全由 Agent 决定:
-
-- `pending_evaluation`:搜索已经召回,但 Agent 尚未完成详情、画像和评分;
-- `primary`:Agent 决定的主推荐;
-- `rejected`:Agent 决定淘汰的候选。
-
-状态流固定为:
-
-`搜索召回 → pending_evaluation → Agent补证和评分 → primary / rejected`。
-
-`pending_evaluation` 不能作为最终结果。
-
-保存工具不会重算 `R/E/S/V`、限制年龄分,也不会修改 Agent 给出的
-`decision_bucket`;它只校验最终等级必须是 `primary / rejected`。
-
-运行时 completion guard 强制结束前最后阶段为:
-
-`最后搜索并保存 → 证据获取与整理 → 候选评估保存 → 审计 → 最终状态查询 → 报告`
-
-审计后如果又发生搜索、取证或评估,必须重新审计并重新查询状态。最终报告只读取数据库
-中的 `primary / rejected`,报告之后不再反向修改分池。
-
-当 Agent 根据任务上下文和已尝试方案判断工具故障已导致任务无法继续时,可以停止重复
-调用,输出以 `任务未完成(工具故障)` 开头的失败摘要。完成守卫只识别该失败声明,
-不解析工具返回结构、错误字段或错误文案,也不替 Agent 判断错误是否可恢复。
-
-## 建议补充的外部数据工具
-
-以下工具依赖抖音爬虫或热点宝增加接口,当前仓库无法自行补出真实数据。建议按优先级
-评估能否实现。
-
-### P0:直接提升结论可靠性
-
-1. `get_video_share_user_portrait(content_id)`
-
-   返回实际转发用户的年龄桶、占比、TGI、样本量和统计周期。当前只有点赞用户画像,
-   这是“老年人是否真的分享”最大的证据缺口。
-
-2. `batch_douyin_search(requests)`
-
-   一次接收多个 `{keyword, cursor, sort_type, publish_time}`,逐项返回结果和下一游标,
-   服务端负责限流。当前单接口约 10 秒间隔,多词、多页探索会很慢。
-
-3. `get_video_audience_retention(content_id)`
-
-   返回各年龄段的曝光、有效播放、完播率、平均观看时长。它能区分“老年人点赞过”
-   和“老年人真正看完并喜欢”。
-
-### P1:提升扩词与搜索覆盖
-
-4. `get_similar_videos(content_id, cursor)`
-
-   返回平台相关推荐及相似原因,用优质候选直接扩展同类视频,比纯关键词更容易找到
-   标题表达不同的内容。
-
-5. `get_video_topics(content_ids)`
-
-   批量返回标准化话题标签、挑战标签、实体和标签热度。详情接口已有 `topic_list`,
-   但如果它不稳定或没有热度,这个独立接口能支持可靠的标签前沿扩展。
-
-6. 作者作品列表已由 `douyin_user_videos` 实现;如果内部接口后续能返回更完整的
-   `topic_list、play_count、publish_timestamp`,可直接增强当前工具。
-
-### P2:改善分享分的跨主题可比性
-
-7. `get_topic_engagement_baseline(topic, publish_window)`
-
-   返回同主题、相近发布时间视频的播放/点赞/分享分位数,使原始分享数能按题材和曝光
-   归一化。
-
-8. `get_video_comment_signals(content_id)`
-
-   返回脱敏后的高频评论意图、@家人朋友、收藏提醒、求链接等分享动机统计。不要返回
-   用户身份信息;该工具只作为内容动机证据,不能代替年龄画像。
-
-每个画像或基线接口都应返回 `sample_size`、`stat_period`、`data_source` 和缺失原因。
-没有样本量与统计周期的百分比,不适合用于高置信判断。

+ 0 - 119
agents/find_agent/VALIDATION.md

@@ -1,119 +0,0 @@
-# find_agent 工具验证报告
-
-真实接口验证日期:2026-07-23
-
-当前契约同步日期:2026-07-28
-
-> 当前 `find_agent` 明确不使用视频理解。`qwen_video_analyze` 未注册,视频画面、语音、
-> 字幕及多模态解析不属于在线能力。数据库和 ORM 不包含视频理解相关列。
->
-> 最终等级只允许 `primary / rejected`;`pending_evaluation` 仅为过程状态,不存在
-> `backup`、补充推荐或人工备选。
-
-## 验证口径
-
-- **真实通过**:实际调用当前配置的外部接口,并检查关键字段。
-- **契约通过**:使用模拟接口/Repository 检查参数、分页、解析和错误格式。
-- **受阻**:缺少密钥、数据库表或明确授权,不能宣称真实可用。
-
-## 本轮回归结果
-
-- 2026-07-28 移除工具错误分类后,find_agent、AgentLoop、工具框架和失败声明
-  定向测试合计 `39 passed`;Ruff 与 `git diff --check` 通过;
-- 调度测试文件全量为 `4 passed, 3 failed`;失败来自既有测试环境问题:两个 SQLite
-  用例未为 MySQL `BIGINT` 主键提供自增兼容,一个测试夹具使用普通 `object()` 代替
-  `FindDemandContext`。三项均与本次故障终止修改无关;
-- 2026-07-23 历史 find_agent 与 LLM 重试定向测试:`19 passed`;
-- Ruff 与 `git diff --check`:通过;
-- 三张 MySQL 表结构、列、唯一键和索引:真实检查通过;
-- 持久化测试数据:按专用 `run_id` 全部清理,残留数为0;
-- 项目全量 pytest:在两个非 find_agent 模块的收集阶段中止,分别是缺少
-  `agents.demand_grade_orchestrator_agent.common.plan_builder`,以及
-  `supply_infra.scheduler.jobs.grade_demand_pool` 未导出测试引用的
-  `_execute_plan_tasks_with_retries`。
-
-## 真实 Agent 干跑
-
-测试输入为“个人养老金税收优惠”,模型为 `google/gemini-2.5-flash`。
-
-第一次运行在第5轮收到 OpenRouter/Google 的
-`MALFORMED_FUNCTION_CALL`。原框架将该供应方错误解析为空答案并提前结束。现已在同步和
-异步 LLM 调用中增加最多2次有限重试,重试耗尽后显式抛错;对应3个测试均通过。
-
-修复后第二次运行完成了:
-
-- 27次工具调用;
-- 3个自主搜索词;
-- 6个已保存搜索页,并执行了分页;
-- 候选详情和批量双侧画像;
-- 候选评分持久化、流程审计和状态恢复;
-- 工具参数错误后的自主修正:首次评分漏传 `run_id`、首次审计多传 `run_id`,
-  Agent 均在下一次调用中修正。
-
-但完整 Agent 验收**未通过**:
-
-- 最后一次审计 `can_finish=false`;
-- 仍有3个生产性搜索页未完成后续翻页;
-- 5个已入池候选在审计输入中缺失视频画像/作者画像“已尝试”标记;
-- Agent 未调用 `normalize_age_portraits`;
-- 搜索轨迹只有 `demand / pagination`,没有形成标签扩展分支;
-- 审计未通过时模型仍停止,并输出“接下来重新获取详情和画像”的过程性半截文本,
-  没有给出最终主推荐和淘汰候选表。
-
-两次运行的数据库测试记录均已按实际 `run_id` 清理,残留数为0。
-
-## 工具逐项结果
-
-| 工具 | 验证方式 | 结果 |
-|---|---|---|
-| `load_skill` | 实际调用不存在技能 | 通过错误契约;当前 skills 目录无可用技能 |
-| `douyin_search` | 真实搜索“个人养老金税收优惠”第一页、第二页 | 真实通过;8/0条,cursor 0→10;第二页关闭分页 |
-| `douyin_search_tikhub` | 真实搜索“老年人高血压管理”两页 | 真实通过;7/6条,cursor 0→8→16,`search_id/backtrace` 可用于翻页 |
-| `douyin_user_videos` | 使用 TikHub 结果中的真实 `sec_uid` 查询作者最热作品 | 真实通过;返回20条,统一候选结构与下一页游标完整 |
-| `douyin_detail` | 真实查询视频 `7665332856776764843` | 真实通过;详情、标签、分享数、播放地址齐全 |
-| `get_content_fans_portrait` | 真实查询上述视频 | 接口通过;该样例没有内容画像,正确返回 `has_portrait=false` |
-| `get_account_fans_portrait` | 真实查询上述作者 | 真实通过;返回年龄桶且被标准化为强老年信号 |
-| `batch_fetch_portraits` | 对上述候选真实请求双侧画像 | 真实通过;内容侧缺失时作者侧仍成功返回 |
-| `normalize_age_portraits` | 使用本轮真实双侧返回测试 | 通过;输出 `account_only`、作者侧 `strong`、E上限0.65 |
-| `audit_video_discovery_process` | 完整流/提前停止/错误淘汰场景 | 契约已同步为仅审计 `primary / rejected`,不再要求视频理解 |
-| `audit_video_discovery_run` | 注册与完成顺序契约测试 | 已注册;候选评估后执行,且仅 `can_finish=true` 可进入最终状态查询 |
-| `create_video_discovery_run` | 真实 MySQL 端到端测试 | 真实通过;运行记录成功创建 |
-| `record_video_search_page` | 真实 MySQL 保存与重复页测试 | 真实通过;3条候选入库,重复保存新增数为0 |
-| `batch_save_video_candidate_evaluations` | 候选分池契约 | 当前只接受 `primary / rejected`;`backup` 会返回输入错误 |
-| `query_video_discovery_state` | 查询运行、搜索树和候选 | 当前返回主推荐、淘汰候选及过程中的 `pending_evaluation` |
-
-## 真实样例观察
-
-- 搜索首条视频分享数为 `304,973`,详情接口返回值一致。
-- 作者作品接口首条作品分享数为 `917,007`,说明作者分支能找到关键词搜索之外的高传播内容。
-- 作者年龄画像实际使用 `50-` 表示50岁以上,占比 `19.63%`、TGI `84.84`;
-  因此不能只识别“50岁以上”文字。
-- 内容画像可能没有数据,双侧画像工具的作者兜底是必要能力。
-- 本轮内部搜索第一页声明 `has_more=true`,第二页返回0条并关闭分页;Agent 应保存空页
-  和停止信号,不能把“无新增”误写成调用失败。
-- TikHub 第二页与第一页出现1条重复,说明跨页去重不能依赖供应方;
-  当前 `(run_id, aweme_id)` 唯一键和幂等合并逻辑能够处理。
-- MySQL 历史端到端测试覆盖创建运行、保存搜索页、重复页幂等和状态查询;
-  测试记录已按 `run_id` 清理,残留数为0。
-
-## 当前阻塞
-
-1. TikHub Key、三张 MySQL 表和全部持久化工具均已完成真实验证,没有相关阻塞。
-2. 历史完整 Agent 干跑的最终审计未通过;完成守卫现已接入,但仍需重新执行真实干跑
-   才能宣称 Agent 可稳定完成任务。
-3. 持久化候选需要保存 `detail_verified / content_portrait_attempted /
-   account_portrait_attempted / age_portraits_normalized` 等审计状态;视频理解字段不
-   属于当前审计契约。
-4. find_agent 完成守卫已启用;正常结果仍要求完整成功顺序。Agent 明确输出
-   `任务未完成(工具故障)` 时允许失败结束;程序不解析工具返回,也不判断错误是否
-   可恢复。
-
-## 下一步修复
-
-1. 确保候选表的详情、双画像尝试和年龄标准化状态由
-   `query_video_discovery_state` 原样恢复;搜索状态同时补回 `parent_search_id`。
-2. 将年龄标准化合并到批画像或候选保存流程,避免模型跳过强制证据处理。
-3. 调整搜索和详情预算。当前详情按每条约10秒串行,真实任务延迟偏高;应先用分享量、
-   相关性和画像可得性做更强预筛。
-4. 使用同一输入重新干跑,直到最终审计通过并输出完整
-   `primary / rejected` 报告。

+ 3 - 10
agents/find_agent/demand_run.py

@@ -341,22 +341,15 @@ def _flatten_relevant_points(ctx: FindDemandContext) -> list[dict[str, Any]]:
 def build_find_agent_user_input(ctx: FindDemandContext, run_id: str) -> str:
 def build_find_agent_user_input(ctx: FindDemandContext, run_id: str) -> str:
     """构建传给 find_agent 的用户消息。"""
     """构建传给 find_agent 的用户消息。"""
     videos_payload = [_serialize_video(video) for video in ctx.videos]
     videos_payload = [_serialize_video(video) for video in ctx.videos]
-    primary = ctx.primary_video
-    seed_video_title = primary.title if primary else ""
-    seed_video_id = primary.video_id if primary else ""
 
 
     return (
     return (
         f"run_id:{run_id}\n"
         f"run_id:{run_id}\n"
         f"demand_grade_id:{ctx.demand_grade_id}\n"
         f"demand_grade_id:{ctx.demand_grade_id}\n"
         f"demand_word:{ctx.demand_name}\n"
         f"demand_word:{ctx.demand_name}\n"
-        f"seed_video_id:{seed_video_id}\n"
-        f"seed_video_title:{seed_video_title}\n"
         f"reference_videos:{json.dumps(videos_payload, ensure_ascii=False)}\n"
         f"reference_videos:{json.dumps(videos_payload, ensure_ascii=False)}\n"
-        "说明:video_discovery_run 已由系统预创建,run_id 见上。\n"
-        f"第一步必须调用 create_video_discovery_run,并原样传入 run_id={run_id},"
-        "同时传入 demand_word、seed_video_title、seed_video_id、demand_grade_id;"
-        "relevant_points 请从 reference_videos 中各视频的 points 展平得到。\n"
-        "禁止省略 run_id,禁止自行生成新的 run_id;后续所有存储工具都必须使用这个 run_id。"
+        "说明:video_discovery_run 已由系统预创建,无需也不得由模型再次创建。\n"
+        "请直接依据 reference_videos 中各视频的 title 和 points 理解需求并开始搜索;"
+        f"后续所有存储和状态工具都必须使用预创建的 run_id={run_id}。"
     )
     )
 
 
 
 

+ 82 - 107
agents/find_agent/prompt/system_prompt.md

@@ -1,10 +1,16 @@
 # 角色与唯一目标
 # 角色与唯一目标
 
 
-你是短视频供给发现 Agent。用户会给出:
+你是短视频供给发现 Agent。调度用户消息会给出:
 
 
-- `demand_word`:需求词,定义这次寻找的真实意图边界;
-- `seed_video_title`:已知相关视频的标题,是理解语境的证据;
-- `relevant_points`:该视频中与需求词相关的一个或多个点,是理解用户究竟关注什么的证据。
+- `run_id`:系统预创建的视频发现运行 ID,所有存储工具必须复用它;
+- `demand_grade_id` 和 `demand_word`:需求记录 ID 与需求词,定义这次寻找的真实意图边界;
+- `reference_videos`:全部参考视频。每项包含 `video_id`、`title` 和该视频对应的
+  `points`。
+
+必须综合全部 `reference_videos[].title` 和 `reference_videos[].points` 理解需求,
+不能只读取第一条参考视频。`video_discovery_run` 已由调度程序预创建;直接复用用户消息
+中的 `run_id` 执行搜索、候选更新和状态管理,不要自行创建运行,也不要生成
+`relevant_points`。
 
 
 你的唯一目标是:从抖音搜索结果中找出一小组**与需求真正相关,并且老年受众更可能观看和转发**的视频。
 你的唯一目标是:从抖音搜索结果中找出一小组**与需求真正相关,并且老年受众更可能观看和转发**的视频。
 
 
@@ -42,7 +48,8 @@
 相关性是**主推荐池**的准入条件,不是加分项。一个高分享、老年粉丝很多但没有
 相关性是**主推荐池**的准入条件,不是加分项。一个高分享、老年粉丝很多但没有
 回答本次需求的视频不能保留,最终必须进入 `rejected`。
 回答本次需求的视频不能保留,最终必须进入 `rejected`。
 
 
-`seed_video_title` 和 `relevant_points` 用来消除需求词的歧义、提炼事件/人物/场景/用途及同义表达;它们不是必须逐字匹配的搜索条件。搜索词只是召回假设,不能成为候选合格的证据。
+`reference_videos` 中的标题和点位用来消除需求词的歧义、提炼事件/人物/场景/用途及
+同义表达;它们不是必须逐字匹配的搜索条件。搜索词只是召回假设,不能成为候选合格的证据。
 
 
 ## 2. 搜索词自主权公理
 ## 2. 搜索词自主权公理
 
 
@@ -67,9 +74,8 @@
   `parent_search_id`;`tag / author / pagination` 表示扩展分支,才设置父搜索。
   `parent_search_id`;`tag / author / pagination` 表示扩展分支,才设置父搜索。
   保存工具会按这一语义自动规范根节点和翻页节点。
   保存工具会按这一语义自动规范根节点和翻页节点。
 
 
-在保留候选不足 5 条时,优先验证不同的根搜索假设,并对产生新增候选的页面做翻页或
-标签扩展。达到 5 条后,未完成但价值较低的根搜索、翻页或标签前沿只作为 warning。
-若合理搜索前沿已经耗尽,少于 5 条也允许结束,不能为了数量扩大到明显低质内容。
+优先验证语义不同的根搜索假设,并对能够产生有效新增信息的页面做翻页或标签扩展。
+当剩余搜索前沿不再可能改变候选判断、排序或置信度时即可停止。
 
 
 ## 4. 分享—年龄不可替代定理
 ## 4. 分享—年龄不可替代定理
 
 
@@ -111,19 +117,14 @@
 
 
 对 `R、E、S` 分别作 `0~1` 的证据评分时,综合价值采用加权几何关系,而不是简单相加:
 对 `R、E、S` 分别作 `0~1` 的证据评分时,综合价值采用加权几何关系,而不是简单相加:
 
 
-`V(v) = 100 × R(v)^0.40 × E(v)^0.35 × S(v)^0.25`
+`V(v) = R(v)^0.40 × E(v)^0.35 × S(v)^0.25`
 
 
-这意味着任一维度接近零,整体价值都会被明显压低。评分用于保持排序一致,不得制造虚假精确性;**数据库保存与最终报告中的 `R/E/S/V` 均使用 `0~1` 小数,原样写入,不做百分制换算**。
+这意味着任一维度接近零,整体价值都会被明显压低。评分用于保持排序一致,不得制造虚假精确性;**数据库保存与最终报告中的 `R/E/S/V` 均使用 `0~1` 小数,不做百分制换算**。
 
 
 `R/E/S/V` 是帮助你保持判断一致的参考量,**不是程序校验线**。候选最终进入
 `R/E/S/V` 是帮助你保持判断一致的参考量,**不是程序校验线**。候选最终进入
-`primary / rejected` 完全由你根据全部证据判断。`batch_save_video_candidate_evaluations`
-会 **原样保存** 你给出的 `0~1` 分数,不会重算、换算或改写 `decision_bucket`。
-`audit_video_discovery_run` **不校验分数**,只审计证据完备性、分池合法性和搜索覆盖。
-
-优先目标是保留至少 5 条质量可靠的 `primary`,可以超过 5 条。5 条是搜索和筛选的
-优先目标,不是硬性准入线:
-在合理搜索、翻页和扩展后确实没有更多好视频时,允许少于 5 条,禁止为凑数保留明显
-低质、低相关或缺乏基本证据的候选。
+`primary / rejected` 完全由你根据全部证据判断。`batch_update_video_discovery_candidates`
+不会重算、换算、校验分数范围或改写 `decision_bucket`;存储时 `R/E/S` 最多保留
+6 位小数,`V` 最多保留 2 位小数。因此必须自行确保所有分数都在 `0~1` 内。
 
 
 低相关候选即使原始分享规模、分享效率或老年倾向很强,也不能进入 `primary`。
 低相关候选即使原始分享规模、分享效率或老年倾向很强,也不能进入 `primary`。
 
 
@@ -132,6 +133,9 @@
 一个强反证比多个弱正向线索更重要。实际内容若围绕青少年校园、年轻圈层黑话、需要特定年轻文化背景,或画像明显偏年轻,应降低老年倾向;但剪辑快、使用网络表达等单个风格特征不能直接证明老年人不喜欢。
 一个强反证比多个弱正向线索更重要。实际内容若围绕青少年校园、年轻圈层黑话、需要特定年轻文化背景,或画像明显偏年轻,应降低老年倾向;但剪辑快、使用网络表达等单个风格特征不能直接证明老年人不喜欢。
 
 
 缺失数据不是负证据,接口失败也不是零分。应标为“未知”并降低置信度,绝不能把未知写成不适合。
 缺失数据不是负证据,接口失败也不是零分。应标为“未知”并降低置信度,绝不能把未知写成不适合。
+但 `primary` 要求 `R / E / S` 共同成立;关键年龄证据缺失、导致 `E` 只能判为未知时,
+候选不得进入 `primary`。此时应以“证据不足以进入主推荐”归入 `rejected`,而不是声称
+画像证明其不适合老年受众。
 
 
 ## 9. 多样性边际定理
 ## 9. 多样性边际定理
 
 
@@ -143,14 +147,24 @@
 
 
 # 工具的证据含义
 # 工具的证据含义
 
 
-- `douyin_search`:用于召回候选并取得初始互动量。搜索结果不是最终事实,重复候选按 `aweme_id` 去重。
+- `batch_search_and_record`:默认搜索入口。一次提交多个关键词及形成原因,每个任务可
+  搜索 1~2 页;工具在每页返回后创建搜索记录并把本页结果逐条写入候选表。优先用它完成
+  2~3 个根搜索。每次搜索和每条候选都会生成新的数据库记录,返回结果中的
+  `search_id / candidate_id` 是后续更新依据;同一 `aweme_id` 在不同搜索中对应不同
+  `candidate_id`。
+- `douyin_search`:用于单次内部关键词搜索和特殊场景回退。必须传入 `run_id`、搜索
+  原因和来源;工具返回前自动保存本页搜索轨迹及候选。
 - `douyin_search_tikhub`:独立的 TikHub 搜索来源,返回标签、更多互动字段和
 - `douyin_search_tikhub`:独立的 TikHub 搜索来源,返回标签、更多互动字段和
-  `cursor / search_id / backtrace`。使用它翻页时,三项状态必须原样传回;不得把
-  TikHub 和内部搜索的游标混用。若未配置 `TIKHUB_API_KEY` 或接口失败,保存失败原因
-  后改用内部搜索,不要用相同参数反复重试。
+  完整分页状态。持久化后的返回值中,`search_id` 是本地数据库搜索记录 ID;
+  TikHub 上游分页 ID 位于 `provider_search_id`。继续翻页时必须按以下映射传参:
+  `next_cursor → cursor`、`provider_search_id → search_id`、`backtrace → backtrace`,
+  并将本地 `search_id → parent_search_id`。不得把本地 `search_id` 当成 TikHub
+  分页 ID,也不得混用 TikHub 和内部搜索的游标。工具会自动保存成功或失败搜索页。若未配置
+  `TIKHUB_API_KEY` 或接口失败,改用内部搜索,不要用相同参数反复重试。
 - `douyin_user_videos`:当候选作者的粉丝画像偏老、或其视频具有较高主推荐
 - `douyin_user_videos`:当候选作者的粉丝画像偏老、或其视频具有较高主推荐
   潜力时,按最热或最新扩展作者作品。作者作品属于 `author` 搜索分支,仍需逐条判断
   潜力时,按最热或最新扩展作者作品。作者作品属于 `author` 搜索分支,仍需逐条判断
-  相关性、老年倾向和分享价值,不能因作者优秀就直接推荐。
+  相关性、老年倾向和分享价值,不能因作者优秀就直接推荐。工具会自动保存作者搜索页
+  和候选。
 - `douyin_detail`:用于核验候选的最新互动数据、作者、页面链接和标题/描述等文本证据。
 - `douyin_detail`:用于核验候选的最新互动数据、作者、页面链接和标题/描述等文本证据。
 - `batch_fetch_portraits`:用于批量取得视频点赞画像;对正式候选应设置
 - `batch_fetch_portraits`:用于批量取得视频点赞画像;对正式候选应设置
   `fetch_account_portrait=true`,同时取得作者粉丝画像。批量结果会自动附带
   `fetch_account_portrait=true`,同时取得作者粉丝画像。批量结果会自动附带
@@ -159,112 +173,73 @@
 - `normalize_age_portraits`:把视频与作者画像中的 `50- / 50+ / 50岁以上 / 41-50`
 - `normalize_age_portraits`:把视频与作者画像中的 `50- / 50+ / 50岁以上 / 41-50`
   等年龄桶统一为直接老年比例、TGI、成熟代理比例和证据强度;取得画像后必须调用,
   等年龄桶统一为直接老年比例、TGI、成熟代理比例和证据强度;取得画像后必须调用,
   不得自行猜测 `50-` 的含义。使用 `batch_fetch_portraits` 时已经自动执行同一标准化,
   不得自行猜测 `50-` 的含义。使用 `batch_fetch_portraits` 时已经自动执行同一标准化,
-  不要重复调用。
-- `create_video_discovery_run`:在开始探索时保存输入并取得 `run_id`。若用户消息已给出
-  预创建 `run_id`,必须原样传入该 `run_id` 复用已有记录,禁止自行生成新的 `run_id`。
-- `record_video_search_page`:每次 `douyin_search` 后保存实际搜索词、形成原因、标签或
-  翻页来源、作者来源、供应方分页状态和本页结果;TikHub 搜索及作者作品页也必须保存,
-  任何搜索页都不能只存在于上下文中。新召回候选初始状态是
-  `pending_evaluation`,表示等待 Agent 补证和评分,不能直接输出。
-- `batch_save_video_candidate_evaluations`:原样保存你给出的详情、证据、**0~1 的 R/E/S/V 评分**
-  和 `decision_bucket`。工具只接受 `primary / rejected`,不会重算分数或替你改池。
-  每个候选至少传入 `aweme_id` 和你决定的 `decision_bucket`;其他证据、理由和分数
-  尽量完整传入。
-- `audit_video_discovery_run`:候选评估完成后按 `run_id` 从数据库读取完整搜索和候选
-  状态,执行确定性完成审计(证据、分池、搜索覆盖;**不校验 R/E/S/V 分数**)。只有返回
-  `can_finish=true` 才能进入最终状态查询。
-- `query_video_discovery_state`:恢复长搜索的已探索关键词、翻页状态、主推荐和淘汰
-  候选,也用于查看已经保存的模型决定。结束前的最后一次查询必须发生在审计通过后,
-  最终报告只能依据这次查询结果生成。
+  不要重复调用。标准化结果中的 `elder_score_cap` 是当前证据条件下 `E` 的上限,
+  `elder_score` 不得超过该值。
+- `batch_update_video_discovery_candidates`:严格按搜索结果返回的 `candidate_id`
+  更新详情、证据、**0~1 的 R/E/S/V 评分**和 `decision_bucket`。工具只接受
+  `primary / rejected`,不会新增候选、重算分数、修改搜索记录或修改运行状态。
+  `decision_reason` 必须用一段综合理由覆盖相关性、年龄证据、分享价值和主要限制。
+  同一 `aweme_id` 对应多个 `candidate_id` 时必须分别判断和更新。
+- `update_video_discovery_run_status`:本轮搜索和评估流程结束后,单独把运行状态更新为
+  `finished`,并保存意图摘要和停止原因。不得用它代替候选更新。
+- `query_video_discovery_state`:按需恢复长搜索中已经保存的搜索轨迹和候选状态,
+  或查看已持久化的模型决定。
 
 
 优先让廉价证据淘汰没有主推荐价值的候选。详情与双画像用于仍可能进入主推荐的候选。
 优先让廉价证据淘汰没有主推荐价值的候选。详情与双画像用于仍可能进入主推荐的候选。
 所有工具失败都保留原始错误语义,不得编造缺失字段。
 所有工具失败都保留原始错误语义,不得编造缺失字段。
 
 
-若 `create_video_discovery_run` 明确返回数据库表未初始化或数据库不可用,只尝试一次:
-保留原始错误且不得反复调用或假装完成。数据库不可用时不能输出已完成报告,应立即按
-下方“工具故障终止规则”结束任务。
-
 # 工具故障终止规则
 # 工具故障终止规则
 
 
-由你结合任务上下文、已尝试的替代方案和工具反馈判断任务是否已经无法继续。程序不解析
-工具错误字段,也不根据错误文案替你判断错误是否可恢复。参数可以修正或仍有替代工具时
-应继续;继续调用已经确认无效的工具不会产生新信息时,应停止重试。
+- 参数错误或返回 `input_error=true`:根据错误信息修正参数后最多重试 1 次;不得用完全
+  相同的参数重复调用。
+- 缺少密钥、认证失败或明确配置错误:同一工具不重试。搜索工具存在等价来源时切换来源;
+  不存在替代能力时保留缺失项并继续完成仍可完成的判断。
+- 网络超时、限流、HTTP 5xx 或临时上游错误:同一请求最多额外重试 1 次;仍失败时切换
+  可用来源或把该证据标为未知。
+- 空结果、无画像、内容不存在或单条业务失败不等于系统故障:不得反复请求同一对象;
+  应继续其他候选或其他搜索前沿。缺失证据不得记为零分或负证据。
+- 批量工具部分成功时必须保留成功结果,只针对仍可能改变决策的失败项进行单条补充,
+  不得整批无差别重试。
+- `run_id` 不存在、数据库不可用、搜索页无法持久化或候选无法更新属于不可恢复的状态
+  一致性故障。不得把运行设为 `finished`;数据库仍可写时将运行标记为 `failed`,
+  然后输出失败摘要。
+- 所有可用搜索来源都持续失败,或关键证据故障使任何候选都无法可靠判断时,停止扩展。
+  数据库仍可写时将运行标记为 `failed`;能查询时执行一次状态查询,然后输出失败摘要。
 
 
-确认无法继续后:
-
-1. 立即停止调用失败工具,不再为了满足正常成功守卫重复调用;
-2. 直接输出失败摘要,第一行必须是 `任务未完成(工具故障)`;
-3. 说明失败工具、原始错误、已完成内容和未完成内容;
-4. 明确写出“未产出有效推荐”,不得输出看似正常的主推荐或淘汰候选报告。
-
-该出口表示任务失败结束,不是成功完成;调度侧会按运行结果判定为失败。
 
 
 # 成本与迭代预算
 # 成本与迭代预算
 
 
-- 根搜索默认形成2~3个语义不同的词;每个生产性首页最多继续1页,除非第二页仍显著
-  提升主推荐质量;
+- 根搜索默认形成 2~3 个语义不同的词;每个生产性首页最多继续 1 页,除非第二页仍能
+  显著提升判断质量;
 - 搜索结果先按需求相关性、分享规模/效率和主推荐潜力做廉价预筛,默认最多选择8条进入
 - 搜索结果先按需求相关性、分享规模/效率和主推荐潜力做廉价预筛,默认最多选择8条进入
   `douyin_detail` 和双画像阶段;
   `douyin_detail` 和双画像阶段;
 - 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
 - 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
   不得依赖或声称使用了视频画面、语音、字幕解析;
   不得依赖或声称使用了视频画面、语音、字幕解析;
 - 执行新搜索、翻页、详情或画像后,应重新保存受影响候选;
 - 执行新搜索、翻页、详情或画像后,应重新保存受影响候选;
-- 不得用“接下来我会继续”作为最终回答。最终报告必须与库中 `primary / rejected`
-  一致,并由 `audit_video_discovery_run` 与结束后的状态查询支撑。
-- 你已按“工具故障终止规则”判断无法继续时,不再尝试正常完成条件,直接输出失败摘要。
-- 数据库保留数量达到 5 条后,若没有明显更高价值的搜索前沿,优先结束任务。
-- 保留数量不足 5 条时,优先继续有效的搜索、翻页或扩标签;合理前沿已经耗尽,或剩余
-  候选明显不值得保留时,可以少于 5 条结束。
-
-# 完成条件
-
-一次任务优先在数据库中正确保留至少 5 条符合 `primary` 规则的视频,
-可以保留更多。若经过合理搜索仍没有 5 条合格视频,则保留全部真正合格的候选后结束,
-不能降低基本质量要求硬凑数量;确实没有合格候选时可以返回空结果。
-
-硬性完成条件:
-
-- 已创建发现运行且每个搜索页都已持久化;
-- 推荐按联合价值排序,优先保证 5 条,可以超过 5 条;确实没有足够好视频时允许更少;
-- 已把候选证据、最终分池和运行完成状态持久化;
-- `audit_video_discovery_run` 返回 `can_finish=true`;
-- 审计通过后重新调用 `query_video_discovery_state`,最终报告与该状态中的
-  `primary / rejected` 完全一致。
-
-结束前必须按以下顺序完成最后一段流程:
-
-`最后搜索并保存搜索页 → 获取并整理证据 → 保存候选评估 → 数据库审计 → 最终状态查询 → 报告`
-
-如果审计后又发生搜索、证据获取或候选评估,原审计立即失效,必须从受影响阶段继续,
-重新审计并重新查询最终状态。最终状态查询必须晚于最后一次成功审计,报告之后不得再
-反向修改候选分池。
+- 不得用“接下来我会继续”作为最终回答;
+- 已按“工具故障终止规则”判断无法继续时,不再尝试正常流程,直接输出失败摘要。
 
 
-以下探索项在不足 5 条时应优先执行;但它们只作为 warning,不把 5 条变成硬门槛:
+# 结束流程
 
 
-- 独立根搜索词少于 2 个;
-- 生产性首页尚未翻页;
-- 值得扩展的标签尚未建立搜索分支。
+结果要求只保留一项:尽量形成 5 条 `decision_bucket=primary` 的通过视频。
 
 
-# 最终输出契约
+正常结束时按以下流程执行:
 
 
-先用一句话复述你对需求意图的理解,然后输出“主推荐”和“淘汰候选”。主推荐每条必须包含:
+1. 当继续搜索、翻页或扩展不再提供有价值的新信息时停止搜索;
+2. 对仍值得判断的候选获取并整理必要证据;
+3. 使用 `batch_update_video_discovery_candidates` 保存已经作出的候选判断;
+4. 使用 `update_video_discovery_run_status` 将运行更新为 `finished`,并记录意图摘要和
+   停止原因;
+5. 输出本次搜索与判断结果。
 
 
-- 排名、标题、作者、抖音页面链接、`aweme_id`;
-- 命中的需求点及相关性证据;
-- 原始 `share_count`,以及可计算时的分享率替代指标;
-- 视频点赞年龄画像证据;
-- 作者粉丝年龄画像证据;
-- 老年人可能愿意分享的内容动机;
-- `R / E / S / V` 的 `0~1` 分值、置信度(高/中/低);
-- 一句包含正证与主要限制的推荐理由。
+因工具故障无法继续时,按照“工具故障终止规则”更新运行状态并输出失败摘要。
 
 
-输出顺序:
+# 最终输出
 
 
-1. **主推荐**:列出你最终决定推荐的视频;
-2. **淘汰候选**:列出已评估候选及淘汰理由;低相关但高分享或偏老年也必须在此列,
-   不得另设中间等级;
-3. 搜索树:实际搜索词、形成来源、已翻页数、标签扩展关系和新增候选数;
-4. 缺失数据、画像冲突、未继续的搜索前沿及接口失败。
+简要说明对需求意图的理解,再报告本次形成的主推荐及其主要证据。按需概括主要淘汰原因、
+缺失数据、画像冲突或接口失败,不要求逐条列出 `rejected` 候选。
 
 
 决定均由 Agent 作出。程序不会根据阈值重新解释或修改你的最终推荐。
 决定均由 Agent 作出。程序不会根据阈值重新解释或修改你的最终推荐。
 
 
-禁止输出没有证据支撑的年龄结论,禁止把“内容讲老人”写成“观看者是老人”,禁止为了满足数量而推荐低相关视频。
+禁止输出没有证据支撑的年龄结论,禁止把“内容讲老人”写成“观看者是老人”,禁止推荐
+低相关视频。

+ 4 - 0
agents/find_agent/support/__init__.py

@@ -0,0 +1,4 @@
+"""find_agent 工具共享的内部实现。
+
+该包不注册 Agent 工具;依赖方向固定为 tools -> support。
+"""

+ 1 - 169
agents/find_agent/tools/decision_support.py → agents/find_agent/support/age_portrait.py

@@ -1,14 +1,10 @@
-"""find_agent 的确定性年龄画像标准化与流程审计工具。"""
+"""find_agent 的确定性年龄画像标准化实现。"""
 from __future__ import annotations
 from __future__ import annotations
 
 
 import json
 import json
 import re
 import re
 from typing import Any
 from typing import Any
 
 
-from supply_agent.tools import tool
-
-_ROOT_SOURCE_TYPES = {"demand", "seed", "point", "mixed"}
-
 
 
 def _number(value: Any) -> float | None:
 def _number(value: Any) -> float | None:
     if value is None or isinstance(value, bool):
     if value is None or isinstance(value, bool):
@@ -187,7 +183,6 @@ def normalize_age_portrait_pair(
     }
     }
 
 
 
 
-@tool
 def normalize_age_portraits(
 def normalize_age_portraits(
     content_portrait: dict[str, Any],
     content_portrait: dict[str, Any],
     account_portrait: dict[str, Any] | None = None,
     account_portrait: dict[str, Any] | None = None,
@@ -220,166 +215,3 @@ def normalize_age_portraits(
         ),
         ),
     }
     }
     return json.dumps(result, ensure_ascii=False)
     return json.dumps(result, ensure_ascii=False)
-
-
-@tool
-def audit_video_discovery_process(
-    searches: list[dict[str, Any]],
-    candidates: list[dict[str, Any]],
-    intended_status: str = "finished",
-) -> str:
-    """
-    在结束找片前审计搜索树、翻页、标签扩展、证据完备性和最终分流。
-
-    不校验 R/E/S/V 分数,也不根据分数质疑 decision_bucket;分数由 Agent 原样保存。
-
-    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、
-            age_portraits_normalized、expansion_worthy_tags。
-        intended_status: 准备设置的运行状态,通常为 finished。
-
-    Returns:
-        JSON,包含 can_finish、critical_violations、warnings 和 coverage。
-    """
-    critical: list[str] = []
-    warnings: list[str] = []
-    coverage_violations: list[str] = []
-    valid_searches = [item for item in searches if isinstance(item, dict)]
-    valid_candidates = [item for item in candidates if isinstance(item, dict)]
-
-    roots = [
-        item
-        for item in valid_searches
-        if item.get("source_type") in _ROOT_SOURCE_TYPES
-        and not item.get("parent_search_id")
-        and int(item.get("page_no") or 1) == 1
-    ]
-    root_keywords = {
-        str(item.get("keyword") or "").strip() for item in roots if item.get("keyword")
-    }
-    if len(root_keywords) < 2:
-        coverage_violations.append("独立根搜索词少于2个")
-
-    by_parent = {
-        int(item["parent_search_id"])
-        for item in valid_searches
-        if item.get("parent_search_id") is not None
-    }
-    keyword_pages = {
-        (str(item.get("keyword") or ""), int(item.get("page_no") or 1))
-        for item in valid_searches
-    }
-    for item in valid_searches:
-        page_no = int(item.get("page_no") or 1)
-        if (
-            page_no != 1
-            or not item.get("has_more")
-            or int(item.get("new_candidate_count") or 0) <= 0
-        ):
-            continue
-        search_id = item.get("search_id")
-        keyword = str(item.get("keyword") or "")
-        followed = (
-            search_id is not None and int(search_id) in by_parent
-        ) or (keyword, page_no + 1) in keyword_pages
-        if not followed:
-            coverage_violations.append(
-                f"生产性搜索页未翻页: search_id={search_id}, keyword={keyword}"
-            )
-
-    tag_searches = [
-        item for item in valid_searches if item.get("source_type") == "tag"
-    ]
-    worthy_tags = {
-        str(tag)
-        for candidate in valid_candidates
-        for tag in (candidate.get("expansion_worthy_tags") or [])
-        if str(tag).strip()
-    }
-    if worthy_tags and not tag_searches:
-        coverage_violations.append("存在值得扩展的标签,但没有 tag 搜索分支")
-
-    bucket_counts = {
-        "primary": 0,
-        "rejected": 0,
-        "pending_evaluation": 0,
-    }
-    pending_evaluation_messages: list[str] = []
-    for candidate in valid_candidates:
-        aweme_id = str(candidate.get("aweme_id") or "unknown")
-        bucket = str(
-            candidate.get("decision_bucket") or "pending_evaluation"
-        )
-        if bucket == "unreviewed":
-            bucket = "pending_evaluation"
-        if bucket not in bucket_counts:
-            critical.append(f"{aweme_id} 使用了不支持的分池: {bucket}")
-            continue
-        bucket_counts[bucket] += 1
-
-        if bucket == "primary":
-            if not candidate.get("detail_verified"):
-                critical.append(f"{aweme_id} 未核验详情")
-            if not candidate.get("content_portrait_attempted"):
-                critical.append(f"{aweme_id} 未尝试视频画像")
-            if not candidate.get("account_portrait_attempted"):
-                critical.append(f"{aweme_id} 未尝试作者画像")
-            if not candidate.get("age_portraits_normalized"):
-                critical.append(f"{aweme_id} 未标准化年龄画像")
-
-        if bucket == "pending_evaluation":
-            message = f"{aweme_id} 等待 Agent 补证和评估"
-            if intended_status == "finished":
-                pending_evaluation_messages.append(message)
-            else:
-                warnings.append(message)
-
-    retained_count = bucket_counts.get("primary", 0)
-    if retained_count >= 5 and intended_status == "finished":
-        warnings.extend(pending_evaluation_messages)
-    else:
-        critical.extend(pending_evaluation_messages)
-
-    if retained_count < 5:
-        warnings.append(
-            f"当前保留 {retained_count} 条,低于优先目标 5 条;"
-            "若仍有高价值搜索前沿应继续探索,候选确实不足时允许结束"
-        )
-    if retained_count > 0:
-        exploration_note = (
-            "已达到 5 条优先目标"
-            if retained_count >= 5
-            else "尚未达到 5 条优先目标;仅在剩余前沿价值较低时允许结束"
-        )
-        warnings.extend(
-            f"{exploration_note};未继续探索: {item}"
-            for item in coverage_violations
-        )
-    else:
-        critical.extend(coverage_violations)
-
-    if not valid_candidates:
-        warnings.append("没有候选;应确认是搜索无结果而非提前停止")
-    can_finish = intended_status != "finished" or not critical
-    result = {
-        "title": "视频发现流程审计",
-        "can_finish": can_finish,
-        "critical_violations": list(dict.fromkeys(critical)),
-        "warnings": list(dict.fromkeys(warnings)),
-        "coverage": {
-            "search_pages": len(valid_searches),
-            "root_keywords": sorted(root_keywords),
-            "tag_search_count": len(tag_searches),
-            "candidate_count": len(valid_candidates),
-            "retained_candidate_count": retained_count,
-            "bucket_counts": bucket_counts,
-        },
-        "output": (
-            f"can_finish={can_finish},严重问题 {len(set(critical))} 个,"
-            f"警告 {len(set(warnings))} 个"
-        ),
-    }
-    return json.dumps(result, ensure_ascii=False)

+ 26 - 0
agents/find_agent/support/batch_search_and_record.py

@@ -0,0 +1,26 @@
+"""批量执行多关键词搜索;每完成一页立即自动落库。"""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+_SUPPORTED_PROVIDERS = {"internal_keyword", "tikhub"}
+_SOURCE_TYPES = {"demand", "seed", "point", "tag", "pagination", "mixed"}
+_MAX_SEARCH_TASKS = 6
+_MAX_PAGES_PER_TASK = 2
+
+
+def _load_result(raw: str) -> dict[str, Any]:
+    try:
+        value = json.loads(raw)
+    except (TypeError, ValueError):
+        return {"error": "搜索工具返回了无效 JSON", "raw_result": str(raw)}
+    return value if isinstance(value, dict) else {"error": "搜索工具返回值不是对象"}
+
+
+def _positive_page_limit(value: Any) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError):
+        parsed = 1
+    return min(max(parsed, 1), _MAX_PAGES_PER_TASK)

+ 359 - 0
agents/find_agent/support/douyin_detail.py

@@ -0,0 +1,359 @@
+"""
+抖音视频详情工具
+
+根据 content_id(aweme_id)调用内部爬虫服务获取视频详情与真实播放链接。
+支持单个或批量查询。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from typing import Any, Optional
+
+import httpx
+
+
+logger = logging.getLogger(__name__)
+
+_MIN_REQUEST_INTERVAL_SECONDS = 10.1
+_rate_limit_lock = asyncio.Lock()
+_last_request_monotonic: float = 0.0
+
+DOUYIN_DETAIL_API = "http://8.217.190.241:8888/crawler/dou_yin/detail"
+DEFAULT_TIMEOUT = 60.0
+MAX_DETAIL_ITEMS = 8
+
+_PLAY_URL_MARKER = "douyin.com/aweme/v1/play/"
+
+# 详情接口中保留的有效字段(去掉长期无意义的空壳字段)
+_KEEP_FIELDS = (
+    "channel",
+    "channel_content_id",
+    "content_link",
+    "title",
+    "content_type",
+    "body_text",
+    "location",
+    "source_url",
+    "topic_list",
+    "image_url_list",
+    "video_url_list",
+    "multi_bitrate",
+    "bgm_data",
+    "is_original",
+    "channel_account_id",
+    "channel_account_name",
+    "channel_account_avatar",
+    "view_count",
+    "play_count",
+    "like_count",
+    "collect_count",
+    "comment_count",
+    "share_count",
+    "looking_count",
+    "modify_timestamp",
+    "update_timestamp",
+)
+
+
+def _is_play_url(url: str) -> bool:
+    return bool(url) and _PLAY_URL_MARKER in url
+
+
+def _pick_url(*candidates: str) -> str:
+    """优先选 aweme/v1/play 链接,否则回退第一个非空 URL。"""
+    urls = [u for u in candidates if u]
+    for url in urls:
+        if _is_play_url(url):
+            return url
+    return urls[0] if urls else ""
+
+
+def _extract_video_url(detail_data: dict[str, Any]) -> str:
+    """优先取 video_url_list[0],并偏好 aweme/v1/play 可播放链接。"""
+    candidates: list[str] = []
+
+    video_url_list = detail_data.get("video_url_list")
+    if isinstance(video_url_list, list):
+        for item in video_url_list:
+            if isinstance(item, dict):
+                url = item.get("video_url") or ""
+                if url:
+                    candidates.append(url)
+
+    multi_bitrate = detail_data.get("multi_bitrate")
+    if isinstance(multi_bitrate, dict):
+        for ratio in ("1080p", "720p", "540p", "default"):
+            bit_info = multi_bitrate.get(ratio)
+            if isinstance(bit_info, dict):
+                url = bit_info.get("video_url") or ""
+                if url:
+                    candidates.append(url)
+
+    return _pick_url(*candidates)
+
+
+def _extract_cover_url(detail_data: dict[str, Any]) -> str:
+    image_url_list = detail_data.get("image_url_list")
+    if isinstance(image_url_list, list) and image_url_list:
+        first = image_url_list[0]
+        if isinstance(first, dict):
+            return first.get("image_url") or ""
+    return ""
+
+
+def _extract_video_duration(detail_data: dict[str, Any]) -> int:
+    video_url_list = detail_data.get("video_url_list")
+    if isinstance(video_url_list, list) and video_url_list:
+        first = video_url_list[0]
+        if isinstance(first, dict):
+            try:
+                return int(first.get("video_duration") or 0)
+            except (TypeError, ValueError):
+                return 0
+    return 0
+
+
+def _normalize_content_ids(content_ids: list[str]) -> list[str]:
+    """去重且保序,过滤空值。"""
+    seen: set[str] = set()
+    result: list[str] = []
+    for item in content_ids:
+        cid = str(item).strip()
+        if not cid or cid in seen:
+            continue
+        seen.add(cid)
+        result.append(cid)
+    return result
+
+
+def _build_detail_result(detail: dict[str, Any], content_id: str) -> dict[str, Any]:
+    """保留接口有效字段,并补充常用便捷字段。"""
+    channel_content_id = str(detail.get("channel_content_id") or content_id)
+    result: dict[str, Any] = {
+        "content_id": content_id,
+        "video_url": _extract_video_url(detail),
+        "video_duration": _extract_video_duration(detail),
+        "cover_url": _extract_cover_url(detail),
+    }
+
+    for key in _KEEP_FIELDS:
+        if key not in detail:
+            continue
+        value = detail.get(key)
+        if key == "channel_content_id":
+            result[key] = channel_content_id
+        elif key == "content_link":
+            result[key] = value or (
+                f"https://www.douyin.com/video/{channel_content_id}" if channel_content_id else ""
+            )
+        else:
+            result[key] = value
+
+    return result
+
+
+def _build_item_summary(index: int, result: dict[str, Any]) -> str:
+    lines = [
+        f"{index}. {result.get('title') or result.get('body_text') or '无标题'}",
+        f"   content_id: {result.get('content_id', '')}",
+        f"   页面链接: {result.get('content_link', '')}",
+        f"   视频链接: {result.get('video_url', '') or '未获取到'}",
+        f"   时长: {result.get('video_duration', 0)} 秒",
+        f"   作者: {result.get('channel_account_name', '')}",
+        f"   sec_uid: {result.get('channel_account_id', '')}",
+        (
+            f"   数据: 点赞 {result.get('like_count') or 0:,} | "
+            f"评论 {result.get('comment_count') or 0:,} | "
+            f"分享 {result.get('share_count') or 0:,} | "
+            f"收藏 {result.get('collect_count') or 0:,}"
+        ),
+    ]
+    return "\n".join(lines)
+
+
+def _build_output_summary(
+    details: list[dict[str, Any]],
+    errors: list[dict[str, str]],
+) -> str:
+    lines = [
+        f"抖音视频详情:成功 {len(details)} 条"
+        + (f",失败 {len(errors)} 条" if errors else "")
+    ]
+    lines.append("")
+
+    for i, item in enumerate(details, 1):
+        lines.append(_build_item_summary(i, item))
+        lines.append("")
+
+    if errors:
+        lines.append("失败列表:")
+        for err in errors:
+            lines.append(f"- {err.get('content_id', '')}: {err.get('error', '')}")
+
+    return "\n".join(lines).rstrip()
+
+
+def _error_result(
+    error: str,
+    *,
+    title: str = "抖音详情获取失败",
+    input_error: bool = False,
+) -> str:
+    return json.dumps(
+        {"error": error, "title": title, "input_error": input_error},
+        ensure_ascii=False,
+    )
+
+
+async def _wait_rate_limit() -> None:
+    global _last_request_monotonic
+    async with _rate_limit_lock:
+        now_mono = time.monotonic()
+        wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
+        if wait_seconds > 0:
+            await asyncio.sleep(wait_seconds)
+        _last_request_monotonic = time.monotonic()
+
+
+async def _fetch_one_detail(
+    client: httpx.AsyncClient,
+    content_id: str,
+) -> dict[str, Any]:
+    """拉取单条详情。成功返回 detail 字典;失败抛出 Exception。"""
+    await _wait_rate_limit()
+    response = await client.post(
+        DOUYIN_DETAIL_API,
+        json={"content_id": content_id},
+        headers={"Content-Type": "application/json"},
+    )
+    response.raise_for_status()
+    body = response.json()
+
+    if body.get("code") not in (0, None):
+        raise RuntimeError(f"接口返回错误: code={body.get('code')} msg={body.get('msg')}")
+
+    data_block = body.get("data", {}) if isinstance(body.get("data"), dict) else {}
+    detail_raw = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
+    if not detail_raw:
+        raise RuntimeError(f"未查到视频详情: content_id={content_id}")
+
+    return _build_detail_result(detail_raw, content_id)
+
+
+async def douyin_detail(
+    content_ids: list[str],
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    抖音视频详情(支持批量)
+
+    根据 content_id(搜索结果中的 aweme_id)获取视频详情与真实播放链接。
+    用于在 douyin_search 选中目标视频后,再拉取可播放的 video_url。
+
+    Args:
+        content_ids: 视频 ID 列表,对应搜索结果中的 aweme_id。
+            单个传 ["123"],多个传 ["123", "456"]
+        timeout: 单次请求超时时间(秒),默认 60
+
+    Returns:
+        JSON 字符串,包含:
+        - output: 文本摘要
+        - details: 详情列表(含 video_url、作者、互动、BGM、多码率等有效字段)
+        - errors: 失败项列表
+        - success_count / failed_count / results_count
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    ids = _normalize_content_ids(content_ids)
+
+    if not ids:
+        return _error_result("content_ids 不能为空")
+    if len(ids) > MAX_DETAIL_ITEMS:
+        return _error_result(
+            f"content_ids 最多 {MAX_DETAIL_ITEMS} 条,请先按相关性、分享价值和备选潜力筛选",
+            input_error=True,
+        )
+
+    details: list[dict[str, Any]] = []
+    errors: list[dict[str, str]] = []
+
+    try:
+        async with httpx.AsyncClient(
+            timeout=request_timeout,
+            trust_env=False,
+            headers={"User-Agent": "curl/8.6.0", "Accept": "*/*"},
+        ) as client:
+            for content_id in ids:
+                try:
+                    detail = await _fetch_one_detail(client, content_id)
+                    details.append(detail)
+                except httpx.HTTPStatusError as e:
+                    msg = f"HTTP {e.response.status_code}: {e.response.text}"
+                    logger.error("douyin_detail HTTP error: content_id=%s status=%d", content_id, e.response.status_code)
+                    errors.append({"content_id": content_id, "error": msg})
+                except httpx.TimeoutException:
+                    msg = f"请求超时({request_timeout}秒)"
+                    logger.error("douyin_detail timeout: content_id=%s", content_id)
+                    errors.append({"content_id": content_id, "error": msg})
+                except httpx.RequestError as e:
+                    msg = f"网络错误: {e}"
+                    logger.error("douyin_detail network error: content_id=%s error=%s", content_id, e)
+                    errors.append({"content_id": content_id, "error": msg})
+                except Exception as e:
+                    msg = str(e)
+                    logger.warning("douyin_detail item failed: content_id=%s error=%s", content_id, e)
+                    errors.append({"content_id": content_id, "error": msg})
+
+        duration_ms = int((time.time() - start_time) * 1000)
+        logger.info(
+            "douyin_detail completed: requested=%d success=%d failed=%d duration_ms=%d",
+            len(ids),
+            len(details),
+            len(errors),
+            duration_ms,
+        )
+
+        if not details and errors:
+            return _error_result(
+                f"全部失败({len(errors)} 条): {errors[0].get('error', '')}"
+            )
+
+        payload = {
+            "title": f"抖音详情: {len(details)}/{len(ids)}",
+            "output": _build_output_summary(details, errors),
+            "results_count": len(ids),
+            "success_count": len(details),
+            "failed_count": len(errors),
+            "details": details,
+            "errors": errors,
+            "duration_ms": duration_ms,
+        }
+        # 单条时额外提供 detail,方便旧逻辑取值
+        if len(details) == 1:
+            payload["detail"] = details[0]
+        return json.dumps(payload, ensure_ascii=False)
+
+    except Exception as e:
+        logger.error("douyin_detail unexpected error: error=%s", e, exc_info=True)
+        return _error_result(f"未知错误: {e}")
+
+
+async def main() -> None:
+    result_json = await douyin_detail(
+        content_ids=["7641118685977614586", "7307654921879358747"]
+    )
+    result = json.loads(result_json)
+    if "error" in result and "details" not in result:
+        print(f"获取失败: {result['error']}")
+    else:
+        print(result["output"])
+        print(f"\nsuccess={result.get('success_count')} failed={result.get('failed_count')}")
+        for item in result.get("details", []):
+            print(f"- {item.get('content_id')}: {item.get('video_url')}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 273 - 0
agents/find_agent/support/douyin_search.py

@@ -0,0 +1,273 @@
+"""
+抖音关键词搜索工具
+
+调用内部爬虫服务进行抖音关键词搜索。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from typing import Any, Optional
+
+import httpx
+
+from agents.find_agent.support.search_persistence import persist_search_payload
+
+logger = logging.getLogger(__name__)
+
+_MIN_REQUEST_INTERVAL_SECONDS = 10.1
+_rate_limit_lock = asyncio.Lock()
+_last_request_monotonic: float = 0.0
+
+# API 基础配置
+DOUYIN_SEARCH_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword"
+DEFAULT_TIMEOUT = 60.0
+DOUYIN_ACCOUNT_ID = "771431222"
+
+
+def _build_search_results(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    """将 API 原始条目转换为结构化搜索结果。"""
+    results = []
+    for item in items:
+        author = item.get("author", {}) if isinstance(item.get("author"), dict) else {}
+        stats = item.get("statistics", {}) if isinstance(item.get("statistics"), dict) else {}
+        aweme_id = item.get("aweme_id", "")
+        results.append(
+            {
+                "aweme_id": aweme_id,
+                "desc": (item.get("desc") or item.get("item_title") or "无标题")[:100],
+                "url": f"https://www.douyin.com/video/{aweme_id}" if aweme_id else "",
+                "author": {
+                    "nickname": author.get("nickname", "未知作者"),
+                    "sec_uid": author.get("sec_uid", ""),
+                },
+                "statistics": {
+                    "digg_count": stats.get("digg_count", 0),
+                    "comment_count": stats.get("comment_count", 0),
+                    "share_count": stats.get("share_count", 0),
+                },
+            }
+        )
+    return results
+
+
+def _build_output_summary(
+    keyword: str,
+    items: list[dict[str, Any]],
+    has_more: bool,
+    cursor_value: str,
+) -> str:
+    """生成给 LLM 阅读的文本摘要。"""
+    lines = [f"搜索关键词「{keyword}」"]
+    lines.append(
+        f"找到 {len(items)} 条结果"
+        + (f",还有更多(cursor={cursor_value})" if has_more else "")
+    )
+    lines.append("")
+
+    for i, item in enumerate(items, 1):
+        aweme_id = item.get("aweme_id", "unknown")
+        desc = (item.get("desc") or item.get("item_title") or "无标题")[:50]
+
+        author = item.get("author", {}) if isinstance(item.get("author"), dict) else {}
+        author_name = author.get("nickname", "未知作者")
+        author_id = author.get("sec_uid", "")
+
+        stats = item.get("statistics", {}) if isinstance(item.get("statistics"), dict) else {}
+        digg_count = stats.get("digg_count", 0)
+        comment_count = stats.get("comment_count", 0)
+        share_count = stats.get("share_count", 0)
+
+        lines.append(f"{i}. {desc}")
+        lines.append(f"   ID: {aweme_id}")
+        lines.append(f"   链接: https://www.douyin.com/video/{aweme_id}")
+        lines.append(f"   作者: {author_name}")
+        lines.append(f"   sec_uid: {author_id}")
+        lines.append(f"   数据: 点赞 {digg_count:,} | 评论 {comment_count:,} | 分享 {share_count:,}")
+        lines.append("")
+
+    return "\n".join(lines)
+
+
+def _success_result(
+    keyword: str,
+    data: dict[str, Any],
+    items: list[dict[str, Any]],
+    has_more: bool,
+    cursor_value: str,
+    duration_ms: int,
+) -> str:
+    """构建成功时的 JSON 字符串返回值。"""
+    search_results = _build_search_results(items)
+    payload = {
+        "title": f"抖音搜索: {keyword}",
+        "output": _build_output_summary(keyword, items, has_more, cursor_value),
+        "keyword": keyword,
+        "results_count": len(items),
+        "has_more": has_more,
+        "next_cursor": cursor_value,
+        "search_results": search_results,
+        "duration_ms": duration_ms,
+    }
+    return json.dumps(payload, ensure_ascii=False)
+
+
+def _error_result(error: str, *, title: str = "抖音搜索失败") -> str:
+    """构建失败时的 JSON 字符串返回值。"""
+    return json.dumps({"error": error, "title": title}, ensure_ascii=False)
+
+
+async def _douyin_search_raw(
+    keyword: str,
+    content_type: str = "视频",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    cursor: str = "0",
+    account_id: str = DOUYIN_ACCOUNT_ID,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    抖音关键词搜索
+
+    通过关键词搜索抖音平台的视频内容,支持多种排序和筛选方式。
+
+    Args:
+        keyword: 搜索关键词
+        content_type: 内容类型(可选:视频/图文, 默认 "视频")
+        sort_type: 排序方式(可选:综合排序/最新发布/最多点赞, 默认 "综合排序")
+        publish_time: 发布时间范围(可选:不限/一天内/一周内/半年内, 默认 "不限")
+        cursor: 分页游标,用于获取下一页结果,默认 "0"
+        account_id: 账号ID(可选)
+        timeout: 超时时间(秒),默认 60
+
+    Returns:
+        JSON 字符串,包含 output(文本摘要)和 search_results(结构化列表)。
+        search_results 中每项含 aweme_id、desc、author、statistics。
+        使用 next_cursor 可获取下一页。
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+
+    try:
+        global _last_request_monotonic
+        async with _rate_limit_lock:
+            now_mono = time.monotonic()
+            wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
+            if wait_seconds > 0:
+                await asyncio.sleep(wait_seconds)
+            _last_request_monotonic = time.monotonic()
+
+        payload = {
+            "keyword": keyword,
+            "content_type": content_type,
+            "sort_type": sort_type,
+            "publish_time": publish_time,
+            "cursor": cursor,
+            "account_id": account_id,
+        }
+
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            response = await client.post(
+                DOUYIN_SEARCH_API,
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            response.raise_for_status()
+            data = response.json()
+
+        data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
+        items = data_block.get("data", []) if isinstance(data_block.get("data"), list) else []
+        has_more = bool(data_block.get("has_more", False))
+        cursor_value = str(data_block.get("next_cursor", ""))
+
+        duration_ms = int((time.time() - start_time) * 1000)
+        logger.info(
+            "douyin_search completed: keyword=%s results=%d has_more=%s duration_ms=%d",
+            keyword,
+            len(items),
+            has_more,
+            duration_ms,
+        )
+
+        return _success_result(keyword, data, items, has_more, cursor_value, duration_ms)
+
+    except httpx.HTTPStatusError as e:
+        logger.error(
+            "douyin_search HTTP error: keyword=%s status=%d",
+            keyword,
+            e.response.status_code,
+        )
+        return _error_result(f"HTTP {e.response.status_code}: {e.response.text}")
+    except httpx.TimeoutException:
+        logger.error("douyin_search timeout: keyword=%s timeout=%s", keyword, request_timeout)
+        return _error_result(f"请求超时({request_timeout}秒)")
+    except httpx.RequestError as e:
+        logger.error("douyin_search network error: keyword=%s error=%s", keyword, e)
+        return _error_result(f"网络错误: {e}")
+    except Exception as e:
+        logger.error("douyin_search unexpected error: keyword=%s error=%s", keyword, e, exc_info=True)
+        return _error_result(f"未知错误: {e}")
+
+
+async def douyin_search(
+    run_id: str,
+    keyword: str,
+    query_reason: str,
+    source_type: str,
+    source_value: str | None = None,
+    parent_search_id: int | None = None,
+    page_no: int = 1,
+    content_type: str = "视频",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    cursor: str = "0",
+    account_id: str = DOUYIN_ACCOUNT_ID,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    搜索一页抖音视频,创建搜索记录和候选记录,返回视频基础信息及数据库 ID。
+    """
+    result = await _douyin_search_raw(
+        keyword=keyword,
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+        cursor=cursor,
+        account_id=account_id,
+        timeout=timeout,
+    )
+    return await asyncio.to_thread(
+        persist_search_payload,
+        result,
+        run_id=run_id,
+        keyword=keyword,
+        query_reason=query_reason,
+        source_type=source_type,
+        source_value=source_value,
+        parent_search_id=parent_search_id,
+        cursor=cursor,
+        page_no=page_no,
+        provider="internal_keyword",
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+    )
+
+
+async def main() -> None:
+    result_json = await _douyin_search_raw(
+        keyword="养老政策",
+        account_id=DOUYIN_ACCOUNT_ID,
+    )
+    result = json.loads(result_json)
+    if "error" in result:
+        print(f"搜索失败: {result['error']}")
+    else:
+        print(result["output"])
+        print(f"\n共 {result['results_count']} 条结果")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 456 - 0
agents/find_agent/support/douyin_search_tikhub.py

@@ -0,0 +1,456 @@
+"""通过 TikHub 搜索抖音视频,输出 find_agent 统一候选格式。"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import time
+from typing import Any
+
+import httpx
+from dotenv import load_dotenv
+
+from agents.find_agent.support.search_persistence import persist_search_payload
+from supply_agent.paths import find_project_root
+
+logger = logging.getLogger(__name__)
+
+DOUYIN_SEARCH_TIKHUB_API = (
+    "https://api.tikhub.io/api/v1/douyin/search/fetch_video_search_v2"
+)
+DEFAULT_TIMEOUT = 60.0
+_MIN_REQUEST_INTERVAL_SECONDS = 1.0
+_rate_limit_lock = asyncio.Lock()
+_last_request_monotonic = 0.0
+_env_loaded = False
+
+_CONTENT_TYPE_MAP = {
+    "不限": "0",
+    "视频": "1",
+    "图片": "2",
+    "图文": "2",
+    "文章": "3",
+    "0": "0",
+    "1": "1",
+    "2": "2",
+    "3": "3",
+}
+_SORT_TYPE_MAP = {
+    "综合排序": "0",
+    "最多点赞": "1",
+    "最新发布": "2",
+    "0": "0",
+    "1": "1",
+    "2": "2",
+}
+_PUBLISH_TIME_MAP = {
+    "不限": "0",
+    "一天内": "1",
+    "最近一天": "1",
+    "一周内": "7",
+    "最近一周": "7",
+    "半年内": "180",
+    "最近半年": "180",
+    "0": "0",
+    "1": "1",
+    "7": "7",
+    "180": "180",
+}
+_DURATION_MAP = {
+    "不限": "0",
+    "一分钟内": "0-1",
+    "1分钟以内": "0-1",
+    "1-5分钟": "1-5",
+    "五分钟以上": "5-10000",
+    "5分钟以上": "5-10000",
+    "0": "0",
+    "0-1": "0-1",
+    "1-5": "1-5",
+    "5-10000": "5-10000",
+}
+
+
+def _ensure_env_loaded() -> None:
+    global _env_loaded
+    if _env_loaded:
+        return
+    load_dotenv(find_project_root() / ".env")
+    _env_loaded = True
+
+
+def _safe_int(value: Any, default: int = 0) -> int:
+    if isinstance(value, bool) or value is None:
+        return default
+    try:
+        return int(float(str(value).strip()))
+    except (TypeError, ValueError):
+        return default
+
+
+def _enum_value(value: str, mapping: dict[str, str], field: str) -> str:
+    normalized = str(value).strip()
+    if normalized not in mapping:
+        choices = " / ".join(key for key in mapping if not key.isdigit())
+        raise ValueError(f"{field} 不支持「{value}」,可选:{choices}")
+    return mapping[normalized]
+
+
+def _get_aweme_info(item: Any) -> dict[str, Any]:
+    if not isinstance(item, dict):
+        return {}
+    data = item.get("data")
+    if not isinstance(data, dict):
+        return {}
+    aweme_info = data.get("aweme_info")
+    return aweme_info if isinstance(aweme_info, dict) else {}
+
+
+def _extract_topics(aweme: dict[str, Any]) -> list[str]:
+    topics: list[str] = []
+    for item in aweme.get("topic_list") or []:
+        if isinstance(item, str):
+            topics.append(item.strip())
+        elif isinstance(item, dict):
+            topic = (
+                item.get("topic_name")
+                or item.get("cha_name")
+                or item.get("hashtag_name")
+                or item.get("name")
+            )
+            if topic:
+                topics.append(str(topic).strip())
+    for item in aweme.get("text_extra") or []:
+        if isinstance(item, dict):
+            topic = item.get("hashtag_name")
+            if topic:
+                topics.append(str(topic).strip())
+    for item in aweme.get("cha_list") or []:
+        if isinstance(item, dict):
+            topic = item.get("cha_name")
+            if topic:
+                topics.append(str(topic).strip())
+    return list(dict.fromkeys(topic for topic in topics if topic))
+
+
+def _normalize_aweme(aweme: dict[str, Any]) -> dict[str, Any] | None:
+    aweme_id = str(aweme.get("aweme_id") or "").strip()
+    if not aweme_id:
+        return None
+    author = aweme.get("author") if isinstance(aweme.get("author"), dict) else {}
+    stats = (
+        aweme.get("statistics")
+        if isinstance(aweme.get("statistics"), dict)
+        else {}
+    )
+    return {
+        "aweme_id": aweme_id,
+        "desc": str(
+            aweme.get("desc") or aweme.get("item_title") or "无标题"
+        )[:200],
+        "url": f"https://www.douyin.com/video/{aweme_id}",
+        "author": {
+            "nickname": str(author.get("nickname") or "未知作者"),
+            "sec_uid": str(author.get("sec_uid") or ""),
+        },
+        "statistics": {
+            "digg_count": _safe_int(stats.get("digg_count")),
+            "comment_count": _safe_int(stats.get("comment_count")),
+            "share_count": _safe_int(stats.get("share_count")),
+            "collect_count": _safe_int(stats.get("collect_count")),
+            "play_count": _safe_int(stats.get("play_count")),
+        },
+        "duration_ms": _safe_int(aweme.get("duration")),
+        "topics": _extract_topics(aweme),
+    }
+
+
+def _summary(
+    keyword: str,
+    results: list[dict[str, Any]],
+    *,
+    filtered_count: int,
+    has_more: bool,
+    next_cursor: int,
+    search_id: str,
+) -> str:
+    lines = [
+        f"TikHub 搜索关键词「{keyword}」",
+        (
+            f"保留 {len(results)} 条"
+            + (f",过滤短视频 {filtered_count} 条" if filtered_count else "")
+            + (
+                f",还有更多(cursor={next_cursor}, search_id={search_id})"
+                if has_more
+                else ""
+            )
+        ),
+        "",
+    ]
+    for index, item in enumerate(results, 1):
+        stats = item["statistics"]
+        lines.extend(
+            [
+                f"{index}. {item['desc'][:50]}",
+                f"   ID: {item['aweme_id']}",
+                f"   链接: {item['url']}",
+                (
+                    f"   作者: {item['author']['nickname']} | "
+                    f"sec_uid: {item['author']['sec_uid']}"
+                ),
+                (
+                    f"   数据: 点赞 {stats['digg_count']:,} | "
+                    f"评论 {stats['comment_count']:,} | "
+                    f"分享 {stats['share_count']:,} | "
+                    f"收藏 {stats['collect_count']:,}"
+                ),
+                f"   标签: {'、'.join(item['topics']) or '无'}",
+                "",
+            ]
+        )
+    return "\n".join(lines).rstrip()
+
+
+def _error_result(error: str) -> str:
+    return json.dumps(
+        {"error": error, "title": "TikHub 抖音搜索失败"},
+        ensure_ascii=False,
+    )
+
+
+async def _wait_rate_limit() -> None:
+    global _last_request_monotonic
+    async with _rate_limit_lock:
+        elapsed = time.monotonic() - _last_request_monotonic
+        if elapsed < _MIN_REQUEST_INTERVAL_SECONDS:
+            await asyncio.sleep(_MIN_REQUEST_INTERVAL_SECONDS - elapsed)
+        _last_request_monotonic = time.monotonic()
+
+
+async def _douyin_search_tikhub_raw(
+    keyword: str,
+    content_type: str = "视频",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    cursor: int = 0,
+    filter_duration: str = "不限",
+    search_id: str = "",
+    backtrace: str = "",
+    min_duration_seconds: int = 0,
+    timeout: float | None = None,
+) -> str:
+    """
+    使用 TikHub 搜索抖音视频,支持多关键词探索和完整分页状态。
+
+    这是 douyin_search 的独立搜索来源。首次搜索 cursor=0、search_id/backtrace 为空;
+    翻页时必须把上次返回的 next_cursor、search_id、backtrace 原样传回。
+
+    Args:
+        keyword: Agent 自主确定的实际搜索词。
+        content_type: 不限 / 视频 / 图片 / 文章,默认视频;也兼容 TikHub 数字代码。
+        sort_type: 综合排序 / 最多点赞 / 最新发布;也兼容 0 / 1 / 2。
+        publish_time: 不限 / 一天内 / 一周内 / 半年内;也兼容 0 / 1 / 7 / 180。
+        cursor: 首次为 0,翻页使用上次返回的 next_cursor。
+        filter_duration: 不限 / 一分钟内 / 1-5分钟 / 5分钟以上。
+        search_id: 翻页状态,必须使用同一搜索返回值。
+        backtrace: 翻页回溯状态,必须使用同一搜索返回值。
+        min_duration_seconds: 客户端最短时长过滤,默认 0 表示不过滤。
+        timeout: 请求超时秒数,默认 60。
+
+    Returns:
+        JSON 字符串。search_results 与 douyin_search 格式兼容,并额外包含
+        duration_ms、topics、收藏数和播放数;分页字段为 has_more、next_cursor、
+        search_id、backtrace。
+    """
+    keyword_text = str(keyword).strip()
+    if not keyword_text:
+        return _error_result("keyword 不能为空")
+
+    try:
+        content_type_value = _enum_value(content_type, _CONTENT_TYPE_MAP, "content_type")
+        sort_type_value = _enum_value(sort_type, _SORT_TYPE_MAP, "sort_type")
+        publish_time_value = _enum_value(
+            publish_time, _PUBLISH_TIME_MAP, "publish_time"
+        )
+        duration_value = _enum_value(
+            filter_duration, _DURATION_MAP, "filter_duration"
+        )
+    except ValueError as exc:
+        return _error_result(str(exc))
+
+    _ensure_env_loaded()
+    api_key = os.getenv("TIKHUB_API_KEY", "").strip()
+    if not api_key:
+        return _error_result("未设置环境变量 TIKHUB_API_KEY")
+
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    payload = {
+        "keyword": keyword_text,
+        "cursor": max(0, int(cursor)),
+        "sort_type": sort_type_value,
+        "publish_time": publish_time_value,
+        "filter_duration": duration_value,
+        "content_type": content_type_value,
+        "search_id": str(search_id or ""),
+        "backtrace": str(backtrace or ""),
+    }
+
+    try:
+        await _wait_rate_limit()
+        async with httpx.AsyncClient(
+            timeout=request_timeout,
+            trust_env=False,
+            headers={
+                "Content-Type": "application/json",
+                "Authorization": f"Bearer {api_key}",
+            },
+        ) as client:
+            response = await client.post(DOUYIN_SEARCH_TIKHUB_API, json=payload)
+            response.raise_for_status()
+            body = response.json()
+
+        data = body.get("data") if isinstance(body.get("data"), dict) else {}
+        business_data = (
+            data.get("business_data")
+            if isinstance(data.get("business_data"), list)
+            else []
+        )
+        config = (
+            data.get("business_config")
+            if isinstance(data.get("business_config"), dict)
+            else {}
+        )
+        next_page = (
+            config.get("next_page")
+            if isinstance(config.get("next_page"), dict)
+            else {}
+        )
+
+        results: list[dict[str, Any]] = []
+        seen: set[str] = set()
+        filtered_count = 0
+        minimum_ms = max(0, int(min_duration_seconds)) * 1000
+        for raw_item in business_data:
+            normalized = _normalize_aweme(_get_aweme_info(raw_item))
+            if normalized is None or normalized["aweme_id"] in seen:
+                continue
+            if (
+                minimum_ms
+                and normalized["duration_ms"]
+                and normalized["duration_ms"] < minimum_ms
+            ):
+                filtered_count += 1
+                continue
+            seen.add(normalized["aweme_id"])
+            results.append(normalized)
+
+        has_more = bool(config.get("has_more") in (1, True, "1"))
+        next_cursor = _safe_int(next_page.get("cursor"))
+        next_search_id = str(next_page.get("search_id") or search_id or "")
+        next_backtrace = str(
+            next_page.get("backtrace") or config.get("backtrace") or backtrace or ""
+        )
+        duration_ms = int((time.time() - start_time) * 1000)
+        result = {
+            "title": f"TikHub 抖音搜索: {keyword_text}",
+            "output": _summary(
+                keyword_text,
+                results,
+                filtered_count=filtered_count,
+                has_more=has_more,
+                next_cursor=next_cursor,
+                search_id=next_search_id,
+            ),
+            "provider": "tikhub",
+            "keyword": keyword_text,
+            "request_params": payload,
+            "results_count": len(results),
+            "filtered_count": filtered_count,
+            "has_more": has_more,
+            "next_cursor": next_cursor,
+            "search_id": next_search_id,
+            "backtrace": next_backtrace,
+            "search_results": results,
+            "duration_ms": duration_ms,
+        }
+        logger.info(
+            "douyin_search_tikhub completed: keyword=%s results=%d has_more=%s duration_ms=%d",
+            keyword_text,
+            len(results),
+            has_more,
+            duration_ms,
+        )
+        return json.dumps(result, ensure_ascii=False)
+    except httpx.HTTPStatusError as exc:
+        text = exc.response.text[:1000]
+        logger.error(
+            "douyin_search_tikhub HTTP error: keyword=%s status=%d",
+            keyword_text,
+            exc.response.status_code,
+        )
+        return _error_result(f"HTTP {exc.response.status_code}: {text}")
+    except httpx.TimeoutException:
+        return _error_result(f"请求超时({request_timeout}秒)")
+    except httpx.RequestError as exc:
+        return _error_result(f"网络错误: {exc}")
+    except Exception as exc:
+        logger.error(
+            "douyin_search_tikhub unexpected error: keyword=%s error=%s",
+            keyword_text,
+            exc,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {exc}")
+
+
+async def douyin_search_tikhub(
+    run_id: str,
+    keyword: str,
+    query_reason: str,
+    source_type: str,
+    source_value: str | None = None,
+    parent_search_id: int | None = None,
+    page_no: int = 1,
+    content_type: str = "视频",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    cursor: int = 0,
+    filter_duration: str = "不限",
+    search_id: str = "",
+    backtrace: str = "",
+    min_duration_seconds: int = 0,
+    timeout: float | None = None,
+) -> str:
+    """
+    使用 TikHub 搜索一页抖音视频,返回候选基础信息、数据库 ID 和分页状态。
+    """
+    result = await _douyin_search_tikhub_raw(
+        keyword=keyword,
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+        cursor=cursor,
+        filter_duration=filter_duration,
+        search_id=search_id,
+        backtrace=backtrace,
+        min_duration_seconds=min_duration_seconds,
+        timeout=timeout,
+    )
+    return await asyncio.to_thread(
+        persist_search_payload,
+        result,
+        run_id=run_id,
+        keyword=keyword,
+        query_reason=query_reason,
+        source_type=source_type,
+        source_value=source_value,
+        parent_search_id=parent_search_id,
+        cursor=str(cursor),
+        page_no=page_no,
+        provider="tikhub",
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+        provider_state={"search_id": search_id, "backtrace": backtrace},
+    )

+ 301 - 0
agents/find_agent/support/douyin_user_videos.py

@@ -0,0 +1,301 @@
+"""查询抖音作者作品,输出 find_agent 统一候选格式。"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from typing import Any
+
+import httpx
+
+from agents.find_agent.support.search_persistence import persist_search_payload
+
+logger = logging.getLogger(__name__)
+
+DOUYIN_USER_VIDEOS_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/blogger"
+DEFAULT_TIMEOUT = 60.0
+_MIN_REQUEST_INTERVAL_SECONDS = 10.1
+_rate_limit_lock = asyncio.Lock()
+_last_request_monotonic = 0.0
+_SORT_TYPES = {"最新", "最热"}
+
+
+def _safe_int(value: Any, default: int = 0) -> int:
+    if isinstance(value, bool) or value is None:
+        return default
+    try:
+        return int(float(str(value).strip()))
+    except (TypeError, ValueError):
+        return default
+
+
+def _extract_topics(item: dict[str, Any]) -> list[str]:
+    topics: list[str] = []
+    for topic in item.get("topic_list") or []:
+        if isinstance(topic, str):
+            topics.append(topic.strip())
+        elif isinstance(topic, dict):
+            name = (
+                topic.get("topic_name")
+                or topic.get("cha_name")
+                or topic.get("hashtag_name")
+                or topic.get("name")
+            )
+            if name:
+                topics.append(str(name).strip())
+    for topic in item.get("text_extra") or []:
+        if isinstance(topic, dict) and topic.get("hashtag_name"):
+            topics.append(str(topic["hashtag_name"]).strip())
+    for topic in item.get("cha_list") or []:
+        if isinstance(topic, dict) and topic.get("cha_name"):
+            topics.append(str(topic["cha_name"]).strip())
+    return list(dict.fromkeys(topic for topic in topics if topic))
+
+
+def _normalize_video(item: dict[str, Any]) -> dict[str, Any] | None:
+    aweme_id = str(item.get("aweme_id") or "").strip()
+    if not aweme_id:
+        return None
+    author = item.get("author") if isinstance(item.get("author"), dict) else {}
+    stats = (
+        item.get("statistics")
+        if isinstance(item.get("statistics"), dict)
+        else {}
+    )
+    video = item.get("video") if isinstance(item.get("video"), dict) else {}
+    duration_ms = _safe_int(video.get("duration") or item.get("duration"))
+    return {
+        "aweme_id": aweme_id,
+        "desc": str(item.get("desc") or item.get("item_title") or "无标题")[:200],
+        "url": f"https://www.douyin.com/video/{aweme_id}",
+        "author": {
+            "nickname": str(author.get("nickname") or "未知作者"),
+            "sec_uid": str(author.get("sec_uid") or ""),
+        },
+        "statistics": {
+            "digg_count": _safe_int(stats.get("digg_count")),
+            "comment_count": _safe_int(stats.get("comment_count")),
+            "share_count": _safe_int(stats.get("share_count")),
+            "collect_count": _safe_int(stats.get("collect_count")),
+            "play_count": _safe_int(stats.get("play_count")),
+        },
+        "duration_ms": duration_ms,
+        "topics": _extract_topics(item),
+    }
+
+
+def _summary(
+    account_id: str,
+    results: list[dict[str, Any]],
+    *,
+    filtered_count: int,
+    has_more: bool,
+    next_cursor: str,
+) -> str:
+    lines = [
+        f"账号 {account_id} 的作品列表",
+        (
+            f"保留 {len(results)} 条"
+            + (f",过滤短视频 {filtered_count} 条" if filtered_count else "")
+            + (f",还有更多(cursor={next_cursor})" if has_more else "")
+        ),
+        "",
+    ]
+    for index, item in enumerate(results, 1):
+        stats = item["statistics"]
+        lines.extend(
+            [
+                f"{index}. {item['desc'][:50]}",
+                f"   ID: {item['aweme_id']}",
+                f"   链接: {item['url']}",
+                (
+                    f"   数据: 点赞 {stats['digg_count']:,} | "
+                    f"评论 {stats['comment_count']:,} | "
+                    f"分享 {stats['share_count']:,} | "
+                    f"收藏 {stats['collect_count']:,}"
+                ),
+                f"   标签: {'、'.join(item['topics']) or '无'}",
+                "",
+            ]
+        )
+    return "\n".join(lines).rstrip()
+
+
+def _error_result(error: str) -> str:
+    return json.dumps(
+        {"error": error, "title": "抖音作者作品获取失败"},
+        ensure_ascii=False,
+    )
+
+
+async def _wait_rate_limit() -> None:
+    global _last_request_monotonic
+    async with _rate_limit_lock:
+        elapsed = time.monotonic() - _last_request_monotonic
+        if elapsed < _MIN_REQUEST_INTERVAL_SECONDS:
+            await asyncio.sleep(_MIN_REQUEST_INTERVAL_SECONDS - elapsed)
+        _last_request_monotonic = time.monotonic()
+
+
+async def _douyin_user_videos_raw(
+    account_id: str,
+    sort_type: str = "最热",
+    cursor: str = "",
+    min_duration_seconds: int = 0,
+    timeout: float | None = None,
+) -> str:
+    """
+    获取指定抖音作者的作品列表,支持最热/最新排序和游标翻页。
+
+    当候选作者的粉丝画像偏老或某条视频表现优秀时,可用该工具扩展同作者内容。
+    返回结构与 douyin_search.search_results 一致,可直接保存为搜索轨迹和候选。
+
+    Args:
+        account_id: author.sec_uid,必须使用完整值。
+        sort_type: 最热 / 最新,默认最热。
+        cursor: 首次为空;翻页使用上次返回的 next_cursor。
+        min_duration_seconds: 最短时长过滤,默认 0 表示不过滤。
+        timeout: 请求超时秒数,默认 60。
+
+    Returns:
+        JSON 字符串,包含 user_videos、search_results、has_more 和 next_cursor。
+        user_videos 与 search_results 是同一个统一结构化列表。
+    """
+    account_text = str(account_id).strip()
+    if not account_text:
+        return _error_result("account_id 不能为空")
+    if sort_type not in _SORT_TYPES:
+        return _error_result(f"sort_type 必须是: {sorted(_SORT_TYPES)}")
+
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    payload = {
+        "account_id": account_text,
+        "sort_type": sort_type,
+        "cursor": str(cursor or ""),
+    }
+
+    try:
+        await _wait_rate_limit()
+        async with httpx.AsyncClient(
+            timeout=request_timeout,
+            trust_env=False,
+            headers={"Content-Type": "application/json"},
+        ) as client:
+            response = await client.post(DOUYIN_USER_VIDEOS_API, json=payload)
+            response.raise_for_status()
+            body = response.json()
+
+        data = body.get("data") if isinstance(body.get("data"), dict) else {}
+        items = data.get("data") if isinstance(data.get("data"), list) else []
+        minimum_ms = max(0, int(min_duration_seconds)) * 1000
+        filtered_count = 0
+        seen: set[str] = set()
+        results: list[dict[str, Any]] = []
+        for raw_item in items:
+            if not isinstance(raw_item, dict):
+                continue
+            normalized = _normalize_video(raw_item)
+            if normalized is None or normalized["aweme_id"] in seen:
+                continue
+            if (
+                minimum_ms
+                and normalized["duration_ms"]
+                and normalized["duration_ms"] < minimum_ms
+            ):
+                filtered_count += 1
+                continue
+            seen.add(normalized["aweme_id"])
+            results.append(normalized)
+
+        has_more = bool(data.get("has_more") in (1, True, "1"))
+        next_cursor = str(data.get("next_cursor") or "")
+        duration_ms = int((time.time() - start_time) * 1000)
+        result = {
+            "title": f"抖音作者作品: {account_text}",
+            "output": _summary(
+                account_text,
+                results,
+                filtered_count=filtered_count,
+                has_more=has_more,
+                next_cursor=next_cursor,
+            ),
+            "provider": "internal_blogger",
+            "account_id": account_text,
+            "sort_type": sort_type,
+            "cursor": str(cursor or ""),
+            "results_count": len(results),
+            "filtered_count": filtered_count,
+            "has_more": has_more,
+            "next_cursor": next_cursor,
+            "user_videos": results,
+            "search_results": results,
+            "duration_ms": duration_ms,
+        }
+        logger.info(
+            "douyin_user_videos completed: account_id=%s results=%d has_more=%s duration_ms=%d",
+            account_text,
+            len(results),
+            has_more,
+            duration_ms,
+        )
+        return json.dumps(result, ensure_ascii=False)
+    except httpx.HTTPStatusError as exc:
+        text = exc.response.text[:1000]
+        logger.error(
+            "douyin_user_videos HTTP error: account_id=%s status=%d",
+            account_text,
+            exc.response.status_code,
+        )
+        return _error_result(f"HTTP {exc.response.status_code}: {text}")
+    except httpx.TimeoutException:
+        return _error_result(f"请求超时({request_timeout}秒)")
+    except httpx.RequestError as exc:
+        return _error_result(f"网络错误: {exc}")
+    except Exception as exc:
+        logger.error(
+            "douyin_user_videos unexpected error: account_id=%s error=%s",
+            account_text,
+            exc,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {exc}")
+
+
+async def douyin_user_videos(
+    run_id: str,
+    account_id: str,
+    query_reason: str,
+    source_value: str | None = None,
+    parent_search_id: int | None = None,
+    page_no: int = 1,
+    sort_type: str = "最热",
+    cursor: str = "",
+    min_duration_seconds: int = 0,
+    timeout: float | None = None,
+) -> str:
+    """
+    获取一页作者作品,创建搜索记录和候选记录并返回对应数据库 ID。
+    """
+    result = await _douyin_user_videos_raw(
+        account_id=account_id,
+        sort_type=sort_type,
+        cursor=cursor,
+        min_duration_seconds=min_duration_seconds,
+        timeout=timeout,
+    )
+    return await asyncio.to_thread(
+        persist_search_payload,
+        result,
+        run_id=run_id,
+        keyword=f"author:{account_id}",
+        query_reason=query_reason,
+        source_type="author",
+        source_value=source_value or account_id,
+        parent_search_id=parent_search_id,
+        cursor=cursor,
+        page_no=page_no,
+        provider="internal_blogger",
+        sort_type=sort_type,
+    )

+ 1 - 6
agents/find_agent/tools/hotspot_profile.py → agents/find_agent/support/portrait.py

@@ -14,8 +14,7 @@ from typing import Any, Optional
 
 
 import httpx
 import httpx
 
 
-from agents.find_agent.tools.decision_support import normalize_age_portrait_pair
-from supply_agent.tools import tool
+from agents.find_agent.support.age_portrait import normalize_age_portrait_pair
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -187,7 +186,6 @@ def _error_result(
     )
     )
 
 
 
 
-@tool
 async def get_account_fans_portrait(
 async def get_account_fans_portrait(
     account_id: str,
     account_id: str,
     need_province: bool = False,
     need_province: bool = False,
@@ -282,7 +280,6 @@ async def get_account_fans_portrait(
         return _error_result(f"未知错误: {e}", title="账号粉丝画像获取失败")
         return _error_result(f"未知错误: {e}", title="账号粉丝画像获取失败")
 
 
 
 
-@tool
 async def get_content_fans_portrait(
 async def get_content_fans_portrait(
     content_id: str,
     content_id: str,
     need_province: bool = False,
     need_province: bool = False,
@@ -377,7 +374,6 @@ async def get_content_fans_portrait(
         return _error_result(f"未知错误: {e}", title="内容点赞用户画像获取失败")
         return _error_result(f"未知错误: {e}", title="内容点赞用户画像获取失败")
 
 
 
 
-@tool
 async def batch_fetch_portraits(
 async def batch_fetch_portraits(
     candidates_json: str,
     candidates_json: str,
     fetch_account_portrait: bool = False,
     fetch_account_portrait: bool = False,
@@ -577,7 +573,6 @@ async def batch_fetch_portraits(
                     content_block.get("portrait_data"),
                     content_block.get("portrait_data"),
                     account_block.get("portrait_data"),
                     account_block.get("portrait_data"),
                 )
                 )
-                item_result["age_portraits_normalized"] = True
                 results.append(item_result)
                 results.append(item_result)
                 c_part = item_result["content"] or {}
                 c_part = item_result["content"] or {}
                 a_part = item_result["account"] or {}
                 a_part = item_result["account"] or {}

+ 1 - 1
agents/find_agent/tools/qwen_video_analyze.py → agents/find_agent/support/qwen_video_analysis.py

@@ -1,5 +1,5 @@
 """
 """
-历史千问视频解析实现。
+历史千问视频解析内部实现。
 
 
 当前 find_agent 明确不使用视频理解,本模块未注册到 Agent,也不属于在线能力。
 当前 find_agent 明确不使用视频理解,本模块未注册到 Agent,也不属于在线能力。
 保留文件仅用于历史兼容,不得因文件或数据库字段存在而推断能力已启用。
 保留文件仅用于历史兼容,不得因文件或数据库字段存在而推断能力已启用。

+ 232 - 0
agents/find_agent/support/search_persistence.py

@@ -0,0 +1,232 @@
+"""搜索结果的内部持久化流程,不暴露为 Agent 工具。"""
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+from typing import Any
+
+from supply_infra.services.video_discovery_service import (
+    RunNotFoundError,
+    format_db_error,
+    get_video_discovery_service,
+)
+
+logger = logging.getLogger(__name__)
+
+_SOURCE_TYPES = {
+    "demand",
+    "seed",
+    "point",
+    "tag",
+    "author",
+    "pagination",
+    "mixed",
+}
+_ROOT_SOURCE_TYPES = {"demand", "seed", "point", "mixed"}
+
+
+def _load_payload(payload_json: str) -> dict[str, Any]:
+    try:
+        payload = json.loads(payload_json)
+    except (TypeError, ValueError):
+        return {"error": "搜索接口返回了无效 JSON", "raw_result": str(payload_json)}
+    if not isinstance(payload, dict):
+        return {"error": "搜索接口返回值不是对象", "raw_result": payload}
+    return payload
+
+
+def _clean_text(value: Any, *, max_length: int | None = None) -> str | None:
+    if value is None:
+        return None
+    text = str(value).strip()
+    if not text:
+        return None
+    return text[:max_length] if max_length else text
+
+
+def _nonnegative_int(value: Any) -> int | None:
+    if value is None or value == "":
+        return None
+    try:
+        return max(0, int(value))
+    except (TypeError, ValueError):
+        return None
+
+
+def _search_key(values: dict[str, Any]) -> str:
+    identity = {
+        key: values.get(key)
+        for key in (
+            "provider",
+            "keyword",
+            "content_type",
+            "sort_type",
+            "publish_time",
+            "cursor",
+        )
+    }
+    raw = json.dumps(identity, ensure_ascii=False, sort_keys=True)
+    return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+def _candidate_from_search_result(
+    item: dict[str, Any],
+    keyword: str,
+) -> dict[str, Any] | None:
+    aweme_id = _clean_text(
+        item.get("aweme_id") or item.get("content_id"),
+        max_length=64,
+    )
+    if not aweme_id:
+        return None
+
+    author = item.get("author") if isinstance(item.get("author"), dict) else {}
+    stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
+    topics = item.get("topics") if isinstance(item.get("topics"), list) else []
+    return {
+        "aweme_id": aweme_id,
+        "title": _clean_text(item.get("desc") or item.get("title"), max_length=512),
+        "content_link": _clean_text(
+            item.get("url") or item.get("content_link"),
+            max_length=1024,
+        ),
+        "author_name": _clean_text(
+            author.get("nickname") or item.get("author_name"),
+            max_length=256,
+        ),
+        "author_sec_uid": _clean_text(
+            author.get("sec_uid") or item.get("author_sec_uid"),
+            max_length=256,
+        ),
+        "like_count": _nonnegative_int(
+            stats.get("digg_count") or item.get("like_count")
+        ),
+        "comment_count": _nonnegative_int(
+            stats.get("comment_count") or item.get("comment_count")
+        ),
+        "share_count": _nonnegative_int(
+            stats.get("share_count") or item.get("share_count")
+        ),
+        "collect_count": _nonnegative_int(
+            stats.get("collect_count") or item.get("collect_count")
+        ),
+        "play_count": _nonnegative_int(
+            stats.get("play_count") or item.get("play_count")
+        ),
+        "tags_json": (
+            json.dumps(topics, ensure_ascii=False) if topics else None
+        ),
+        "_source_keyword": keyword,
+    }
+
+
+def persist_search_payload(
+    payload_json: str,
+    *,
+    run_id: str,
+    keyword: str,
+    query_reason: str,
+    source_type: str,
+    cursor: str,
+    page_no: int,
+    provider: str,
+    source_value: str | None = None,
+    parent_search_id: int | None = None,
+    content_type: str = "视频",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    provider_state: dict[str, Any] | None = None,
+) -> str:
+    """新增搜索记录和本页全部候选,并把数据库 ID 拼回搜索结果。"""
+    payload = _load_payload(payload_json)
+    run_text = _clean_text(run_id, max_length=64)
+    keyword_text = _clean_text(keyword, max_length=256)
+    reason_text = _clean_text(query_reason)
+    if not run_text or not keyword_text or not reason_text:
+        payload["error"] = "run_id、keyword、query_reason 不能为空"
+        payload["input_error"] = True
+        return json.dumps(payload, ensure_ascii=False, default=str)
+    if source_type not in _SOURCE_TYPES:
+        payload["error"] = f"source_type 必须是: {sorted(_SOURCE_TYPES)}"
+        payload["input_error"] = True
+        return json.dumps(payload, ensure_ascii=False, default=str)
+
+    raw_results = payload.get("search_results")
+    results = raw_results if isinstance(raw_results, list) else []
+    candidate_rows = [
+        row
+        for item in results
+        if isinstance(item, dict)
+        if (row := _candidate_from_search_result(dict(item), keyword_text)) is not None
+    ]
+
+    normalized_page_no = max(1, int(page_no))
+    normalized_source_type = (
+        "pagination" if normalized_page_no > 1 else source_type
+    )
+    normalized_parent_search_id = (
+        None
+        if normalized_source_type in _ROOT_SOURCE_TYPES
+        else parent_search_id
+    )
+
+    merged_provider_state = dict(provider_state or {})
+    for key in ("search_id", "backtrace"):
+        value = payload.get(key)
+        if value not in (None, ""):
+            merged_provider_state[key] = value
+    provider_search_id = payload.get("search_id")
+
+    search_values: dict[str, Any] = {
+        "run_id": run_text,
+        "keyword": keyword_text,
+        "query_reason": reason_text,
+        "source_type": normalized_source_type,
+        "source_value": _clean_text(source_value),
+        "parent_search_id": normalized_parent_search_id,
+        "provider": _clean_text(provider, max_length=32) or "internal_keyword",
+        "provider_state_json": (
+            json.dumps(merged_provider_state, ensure_ascii=False)
+            if merged_provider_state
+            else None
+        ),
+        "content_type": _clean_text(content_type, max_length=16) or "视频",
+        "sort_type": _clean_text(sort_type, max_length=32) or "综合排序",
+        "publish_time": _clean_text(publish_time, max_length=32) or "不限",
+        "cursor": _clean_text(cursor, max_length=128) or "0",
+        "page_no": normalized_page_no,
+        "results_count": len(results),
+        "new_candidate_count": 0,
+        "has_more": int(bool(payload.get("has_more"))),
+        "next_cursor": (
+            _clean_text(payload.get("next_cursor"), max_length=128)
+        ),
+        "result_ids_json": None,
+        "status": "failed" if payload.get("error") else "success",
+        "error_message": _clean_text(payload.get("error")),
+    }
+    search_values["search_key"] = _search_key(search_values)
+
+    try:
+        saved = get_video_discovery_service().save_search_page(
+            run_text,
+            search_values,
+            candidate_rows,
+        )
+    except RunNotFoundError as exc:
+        payload["error"] = str(exc)
+        payload["input_error"] = True
+        return json.dumps(payload, ensure_ascii=False, default=str)
+    except Exception as exc:
+        logger.error("persist search payload failed: %s", exc, exc_info=True)
+        payload["error"] = format_db_error(exc)
+        return json.dumps(payload, ensure_ascii=False, default=str)
+
+    payload.pop("search_results", None)
+    payload.pop("user_videos", None)
+    if provider_search_id not in (None, ""):
+        payload["provider_search_id"] = provider_search_id
+    payload.update(saved)
+    payload["persisted"] = True
+    return json.dumps(payload, ensure_ascii=False, default=str)

+ 329 - 0
agents/find_agent/support/video_discovery.py

@@ -0,0 +1,329 @@
+"""持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
+from __future__ import annotations
+
+import json
+import logging
+import uuid
+from decimal import Decimal
+from typing import Any
+
+from supply_infra.services.video_discovery_service import (
+    RunNotFoundError,
+    format_db_error,
+    get_video_discovery_service,
+)
+
+logger = logging.getLogger(__name__)
+
+_RUN_STATUSES = {"running", "finished", "failed"}
+_FINAL_DECISION_BUCKETS = {"primary", "rejected"}
+
+
+def _json(value: Any) -> str:
+    return json.dumps(value, ensure_ascii=False, default=str)
+
+
+def _input_error(message: str) -> str:
+    return _json({"error": message, "input_error": True})
+
+
+def _clean_text(value: Any, *, max_length: int | None = None) -> str | None:
+    if value is None:
+        return None
+    text = str(value).strip()
+    if not text:
+        return None
+    return text[:max_length] if max_length else text
+
+
+def _nonnegative_int(value: Any) -> int | None:
+    if value is None or value == "":
+        return None
+    try:
+        return max(0, int(value))
+    except (TypeError, ValueError):
+        return None
+
+
+def _optional_decimal(value: Any, places: int) -> Decimal | None:
+    if value is None or value == "":
+        return None
+    try:
+        number = Decimal(str(value))
+    except (ArithmeticError, TypeError, ValueError):
+        return None
+    quantum = Decimal(1).scaleb(-places)
+    return number.quantize(quantum)
+
+
+def create_video_discovery_run(
+    demand_word: str,
+    relevant_points: list[dict[str, Any]],
+    seed_video_id: str | None = None,
+    seed_video_title: str | None = None,
+    demand_grade_id: int | None = None,
+    intent_summary: str | None = None,
+    run_id: str | None = None,
+) -> str:
+    """
+    创建一次可追踪的视频发现运行。
+
+    若用户消息已提供预创建 run_id,必须原样传入 run_id;工具会复用已有记录,
+    不会重复创建。
+
+    Args:
+        demand_word: 用户给定需求词;它是输入语义,不强制作为实际搜索词。
+        relevant_points: 参考视频中与需求相关的点位对象列表。
+        seed_video_id: 兼容旧调用方的可选参考视频 id;调度调用不传。
+        seed_video_title: 兼容旧调用方的可选参考视频标题;调度调用不传。
+        demand_grade_id: 可选 demand_grade.id。
+        intent_summary: Agent 对真正受欢迎内容的初步解释,可稍后更新。
+        run_id: 系统预创建的运行 id;传入已存在记录时直接复用。
+
+    Returns:
+        JSON,包含后续存储工具必须使用的 run_id。
+    """
+    service = get_video_discovery_service()
+    cleaned_run_id = _clean_text(run_id, max_length=64)
+    if cleaned_run_id:
+        try:
+            existing = service.lookup_run(cleaned_run_id)
+            if existing is not None:
+                return _json(
+                    {
+                        "title": "视频发现运行已存在",
+                        "run_id": cleaned_run_id,
+                        "status": existing["status"],
+                        "pre_created": True,
+                        "output": f"run_id={cleaned_run_id}",
+                    }
+                )
+        except Exception as exc:
+            logger.error("create_video_discovery_run lookup failed: %s", exc, exc_info=True)
+            return _json({"error": format_db_error(exc), "title": "查询视频发现运行失败"})
+
+    demand = _clean_text(demand_word, max_length=256)
+    if not demand:
+        return _input_error("demand_word 不能为空")
+
+    new_run_id = cleaned_run_id or uuid.uuid4().hex
+    values = {
+        "run_id": new_run_id,
+        "demand_grade_id": demand_grade_id,
+        "demand_word": demand,
+        "seed_video_id": _clean_text(seed_video_id, max_length=64),
+        "seed_video_title": _clean_text(seed_video_title, max_length=512),
+        "relevant_points_json": _json(relevant_points or []),
+        "intent_summary": _clean_text(intent_summary),
+        "status": "running",
+    }
+    try:
+        created = service.create_run(values)
+        return _json(
+            {
+                "title": "视频发现运行已创建",
+                "run_id": created["run_id"],
+                "status": created["status"],
+                "output": f"run_id={created['run_id']}",
+            }
+        )
+    except Exception as exc:
+        logger.error("create_video_discovery_run failed: %s", exc, exc_info=True)
+        return _json({"error": format_db_error(exc), "title": "创建视频发现运行失败"})
+
+
+def _normalize_candidate_update(item: dict[str, Any]) -> dict[str, Any]:
+    try:
+        candidate_id = int(item.get("candidate_id"))
+    except (TypeError, ValueError):
+        raise ValueError("candidate_id 必须是整数") from None
+    if candidate_id <= 0:
+        raise ValueError("candidate_id 必须大于 0")
+
+    content_age = item.get("content_age_evidence")
+    account_age = item.get("account_age_evidence")
+    age_normalization = item.get("age_normalization")
+    decision_bucket = (
+        _clean_text(item.get("decision_bucket"), max_length=24)
+        or ""
+    )
+    if decision_bucket not in _FINAL_DECISION_BUCKETS:
+        raise ValueError(
+            "decision_bucket 必须是 primary 或 rejected"
+        )
+
+    mapping = {
+        "candidate_id": candidate_id,
+        "title": _clean_text(item.get("title"), max_length=512),
+        "content_link": _clean_text(item.get("content_link"), max_length=1024),
+        "author_name": _clean_text(item.get("author_name"), max_length=256),
+        "author_sec_uid": _clean_text(item.get("author_sec_uid"), max_length=256),
+        "tags_json": item.get("tags") if "tags" in item else None,
+        "play_count": _nonnegative_int(item.get("play_count")),
+        "like_count": _nonnegative_int(item.get("like_count")),
+        "comment_count": _nonnegative_int(item.get("comment_count")),
+        "collect_count": _nonnegative_int(item.get("collect_count")),
+        "share_count": _nonnegative_int(item.get("share_count")),
+        "content_age_evidence_json": (
+            _json(content_age) if content_age is not None else None
+        ),
+        "account_age_evidence_json": (
+            _json(account_age) if account_age is not None else None
+        ),
+        "age_normalization_json": (
+            _json(age_normalization) if age_normalization is not None else None
+        ),
+        "relevance_score": _optional_decimal(item.get("relevance_score"), 6),
+        "elder_score": _optional_decimal(item.get("elder_score"), 6),
+        "share_score": _optional_decimal(item.get("share_score"), 6),
+        "value_score": _optional_decimal(item.get("value_score"), 2),
+        "decision_reason": _clean_text(item.get("decision_reason")),
+        "decision_bucket": decision_bucket,
+    }
+    return mapping
+
+
+def batch_update_video_discovery_candidates(
+    run_id: str,
+    items: list[dict[str, Any]],
+) -> str:
+    """
+    严格按 candidate_id 批量更新候选详情、证据、评分和最终分池。
+
+    只更新 video_discovery_candidate;不会新增候选、修改搜索记录或修改运行状态。
+
+    Args:
+        run_id: 发现运行 id。
+        items: 候选数组。每项必须包含搜索工具返回的 candidate_id,并直接提供
+            decision_bucket;可更新标题、链接、作者、互动量、标签、双侧年龄证据、
+            画像标准化结果、R/E/S/V 和最终分池理由。
+
+    Returns:
+        JSON,包含 updated_count 和按 candidate_id 更新后的候选记录。
+    """
+    run_text = _clean_text(run_id, max_length=64)
+    if not run_text:
+        return _input_error("run_id 不能为空")
+
+    rows: list[dict[str, Any]] = []
+    errors: list[str] = []
+    for index, item in enumerate(items or []):
+        if not isinstance(item, dict):
+            errors.append(f"[{index}] 不是对象")
+            continue
+        try:
+            rows.append(_normalize_candidate_update(dict(item)))
+        except ValueError as exc:
+            errors.append(f"[{index}] {exc}")
+    if errors:
+        return _json(
+            {
+                "error": "候选更新参数不合法",
+                "input_error": True,
+                "errors": errors,
+            }
+        )
+    if not rows:
+        return _input_error("items 不能为空")
+
+    try:
+        updated = get_video_discovery_service().update_candidates(
+            run_text,
+            rows,
+        )
+        payload = {
+            "title": "候选已更新",
+            "run_id": run_text,
+            "updated_count": updated["updated_count"],
+            "candidates": updated["candidates"],
+            "output": f"更新 {updated['updated_count']} 条候选",
+        }
+        return _json(payload)
+    except RunNotFoundError as exc:
+        return _input_error(str(exc))
+    except ValueError as exc:
+        return _input_error(str(exc))
+    except Exception as exc:
+        logger.error(
+            "batch_update_video_discovery_candidates failed: %s",
+            exc,
+            exc_info=True,
+        )
+        return _json({"error": format_db_error(exc), "title": "更新候选失败"})
+
+
+def update_video_discovery_run_status(
+    run_id: str,
+    status: str,
+    intent_summary: str | None = None,
+    stop_reason: str | None = None,
+) -> str:
+    """单独更新 video_discovery_run 的状态、意图摘要和停止原因。"""
+    run_text = _clean_text(run_id, max_length=64)
+    if not run_text:
+        return _input_error("run_id 不能为空")
+    if status not in _RUN_STATUSES:
+        return _input_error(f"status 必须是: {sorted(_RUN_STATUSES)}")
+    try:
+        run = get_video_discovery_service().update_run_status(
+            run_text,
+            status=status,
+            intent_summary=_clean_text(intent_summary),
+            stop_reason=_clean_text(stop_reason),
+        )
+        return _json(
+            {
+                "title": "视频发现运行状态已更新",
+                "run": run,
+                "output": (
+                    f"run_id={run_text},status={run['status']},"
+                    f"primary_count={run['primary_count']}"
+                ),
+            }
+        )
+    except RunNotFoundError as exc:
+        return _input_error(str(exc))
+    except Exception as exc:
+        logger.error(
+            "update_video_discovery_run_status failed: %s",
+            exc,
+            exc_info=True,
+        )
+        return _json({"error": format_db_error(exc), "title": "更新运行状态失败"})
+
+
+def query_video_discovery_state(
+    run_id: str,
+    include_rejected: bool = True,
+    limit: int = 100,
+) -> str:
+    """
+    查询一次运行已经保存的搜索轨迹、主推荐与淘汰候选。
+
+    用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
+    """
+    run_text = _clean_text(run_id, max_length=64)
+    if not run_text:
+        return _json({"error": "run_id 不能为空"})
+    try:
+        state = get_video_discovery_service().get_full_state(
+            run_text,
+            include_rejected=include_rejected,
+            limit=limit,
+        )
+        run = state["run"]
+        payload = {
+            "title": f"视频发现状态: {run_text}",
+            "run": run,
+            "searches": state["searches"],
+            "candidates": state["candidates"],
+            "output": (
+                f"搜索页 {run['search_count']};主推荐 {run['primary_count']}"
+            ),
+        }
+        return _json(payload)
+    except RunNotFoundError:
+        return _json({"error": f"run_id 不存在: {run_text}"})
+    except Exception as exc:
+        logger.error("query_video_discovery_state failed: %s", exc, exc_info=True)
+        return _json({"error": format_db_error(exc), "title": "查询视频发现状态失败"})

+ 39 - 18
agents/find_agent/tools/__init__.py

@@ -5,31 +5,55 @@ find_agent 工具包
 """
 """
 from __future__ import annotations
 from __future__ import annotations
 
 
+import sys
 from collections.abc import Callable
 from collections.abc import Callable
 from typing import Any
 from typing import Any
 
 
+from agents.find_agent.support import (
+    portrait as _portrait,
+    qwen_video_analysis as _qwen_video_analysis,
+    search_persistence as _search_persistence,
+    video_discovery as _video_discovery,
+)
+from agents.find_agent.tools.batch_fetch_portraits import batch_fetch_portraits
+from agents.find_agent.tools.batch_search_and_record import batch_search_and_record
+from agents.find_agent.tools.batch_update_video_discovery_candidates import (
+    batch_update_video_discovery_candidates,
+)
 from agents.find_agent.tools.douyin_detail import douyin_detail
 from agents.find_agent.tools.douyin_detail import douyin_detail
 from agents.find_agent.tools.douyin_search import douyin_search
 from agents.find_agent.tools.douyin_search import douyin_search
 from agents.find_agent.tools.douyin_search_tikhub import douyin_search_tikhub
 from agents.find_agent.tools.douyin_search_tikhub import douyin_search_tikhub
 from agents.find_agent.tools.douyin_user_videos import douyin_user_videos
 from agents.find_agent.tools.douyin_user_videos import douyin_user_videos
-from agents.find_agent.tools.decision_support import (
-    normalize_age_portraits,
-)
-from agents.find_agent.tools.hotspot_profile import (
-    batch_fetch_portraits,
+from agents.find_agent.tools.get_account_fans_portrait import (
     get_account_fans_portrait,
     get_account_fans_portrait,
+)
+from agents.find_agent.tools.get_content_fans_portrait import (
     get_content_fans_portrait,
     get_content_fans_portrait,
 )
 )
-from agents.find_agent.tools.video_discovery_store import (
-    audit_video_discovery_run,
-    batch_save_video_candidate_evaluations,
-    create_video_discovery_run,
+from agents.find_agent.tools.normalize_age_portraits import normalize_age_portraits
+from agents.find_agent.tools.query_video_discovery_state import (
     query_video_discovery_state,
     query_video_discovery_state,
-    record_video_search_page,
+)
+from agents.find_agent.tools.update_video_discovery_run_status import (
+    update_video_discovery_run_status,
 )
 )
 from supply_agent.tools.registry import ToolRegistry
 from supply_agent.tools.registry import ToolRegistry
 
 
+# 旧调用方的模块路径兼容。实现均已迁出 tools,且不会注册为 Agent 工具。
+_LEGACY_MODULE_ALIASES = {
+    "hotspot_profile": _portrait,
+    "qwen_video_analyze": _qwen_video_analysis,
+    "search_persistence": _search_persistence,
+    "video_discovery_store": _video_discovery,
+}
+for _legacy_name, _support_module in _LEGACY_MODULE_ALIASES.items():
+    sys.modules.setdefault(
+        f"{__name__}.{_legacy_name}",
+        _support_module,
+    )
+
 ALL_TOOLS: list[Callable[..., Any]] = [
 ALL_TOOLS: list[Callable[..., Any]] = [
+    batch_search_and_record,
     douyin_search,
     douyin_search,
     douyin_search_tikhub,
     douyin_search_tikhub,
     douyin_user_videos,
     douyin_user_videos,
@@ -38,15 +62,14 @@ ALL_TOOLS: list[Callable[..., Any]] = [
     get_account_fans_portrait,
     get_account_fans_portrait,
     batch_fetch_portraits,
     batch_fetch_portraits,
     normalize_age_portraits,
     normalize_age_portraits,
-    create_video_discovery_run,
-    record_video_search_page,
-    batch_save_video_candidate_evaluations,
-    audit_video_discovery_run,
+    batch_update_video_discovery_candidates,
+    update_video_discovery_run_status,
     query_video_discovery_state,
     query_video_discovery_state,
 ]
 ]
 
 
 __all__ = [
 __all__ = [
     "ALL_TOOLS",
     "ALL_TOOLS",
+    "batch_search_and_record",
     "douyin_search",
     "douyin_search",
     "douyin_search_tikhub",
     "douyin_search_tikhub",
     "douyin_user_videos",
     "douyin_user_videos",
@@ -55,10 +78,8 @@ __all__ = [
     "get_account_fans_portrait",
     "get_account_fans_portrait",
     "batch_fetch_portraits",
     "batch_fetch_portraits",
     "normalize_age_portraits",
     "normalize_age_portraits",
-    "create_video_discovery_run",
-    "record_video_search_page",
-    "batch_save_video_candidate_evaluations",
-    "audit_video_discovery_run",
+    "batch_update_video_discovery_candidates",
+    "update_video_discovery_run_status",
     "query_video_discovery_state",
     "query_video_discovery_state",
     "register_all_tools",
     "register_all_tools",
 ]
 ]

+ 19 - 0
agents/find_agent/tools/batch_fetch_portraits.py

@@ -0,0 +1,19 @@
+"""批量获取候选视频画像。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.portrait import (
+    batch_fetch_portraits as _batch_fetch_portraits,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _batch_fetch_portraits,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+async def batch_fetch_portraits(*args: Any, **kwargs: Any) -> str:
+    return await _batch_fetch_portraits(*args, **kwargs)

+ 187 - 0
agents/find_agent/tools/batch_search_and_record.py

@@ -0,0 +1,187 @@
+"""批量执行多关键词搜索并自动保存。"""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from agents.find_agent.support.batch_search_and_record import (
+    _MAX_SEARCH_TASKS,
+    _SOURCE_TYPES,
+    _SUPPORTED_PROVIDERS,
+    _load_result,
+    _positive_page_limit,
+)
+from agents.find_agent.support.douyin_search import douyin_search
+from agents.find_agent.support.douyin_search_tikhub import douyin_search_tikhub
+from supply_agent.tools import tool
+
+
+@tool
+async def batch_search_and_record(
+    run_id: str,
+    searches: list[dict[str, Any]],
+) -> str:
+    """
+    批量执行多个关键词搜索,并在每一页返回后立即保存搜索轨迹和候选视频。
+
+    Args:
+        run_id: 本次视频发现运行 id。
+        searches: 搜索任务数组,最多 6 个。每项必须包含 keyword、query_reason、
+            source_type;可包含 provider(internal_keyword / tikhub)、max_pages
+            (1~2)、source_value、parent_search_id、content_type、sort_type、
+            publish_time、filter_duration、min_duration_seconds。
+    """
+    run_text = str(run_id or "").strip()
+    if not run_text:
+        return json.dumps(
+            {"error": "run_id 不能为空", "input_error": True},
+            ensure_ascii=False,
+        )
+    if not isinstance(searches, list) or not searches:
+        return json.dumps(
+            {"error": "searches 必须是非空数组", "input_error": True},
+            ensure_ascii=False,
+        )
+    if len(searches) > _MAX_SEARCH_TASKS:
+        return json.dumps(
+            {
+                "error": f"searches 单次最多 {_MAX_SEARCH_TASKS} 个",
+                "input_error": True,
+            },
+            ensure_ascii=False,
+        )
+
+    task_results: list[dict[str, Any]] = []
+    total_pages = 0
+    total_new_candidates = 0
+    errors: list[str] = []
+
+    for index, raw_task in enumerate(searches):
+        if not isinstance(raw_task, dict):
+            errors.append(f"[{index}] 搜索任务不是对象")
+            continue
+
+        keyword = str(raw_task.get("keyword") or "").strip()
+        query_reason = str(raw_task.get("query_reason") or "").strip()
+        source_type = str(raw_task.get("source_type") or "").strip()
+        provider = str(raw_task.get("provider") or "internal_keyword").strip()
+        if not keyword or not query_reason:
+            errors.append(f"[{index}] keyword、query_reason 不能为空")
+            continue
+        if source_type not in _SOURCE_TYPES:
+            errors.append(f"[{index}] source_type 不支持: {source_type}")
+            continue
+        if provider not in _SUPPORTED_PROVIDERS:
+            errors.append(f"[{index}] provider 不支持: {provider}")
+            continue
+
+        max_pages = _positive_page_limit(raw_task.get("max_pages", 1))
+        cursor: str | int = raw_task.get(
+            "cursor",
+            0 if provider == "tikhub" else "0",
+        )
+        provider_search_id = str(raw_task.get("search_id") or "")
+        backtrace = str(raw_task.get("backtrace") or "")
+        parent_search_id = raw_task.get("parent_search_id")
+        page_results: list[dict[str, Any]] = []
+
+        for page_no in range(1, max_pages + 1):
+            common = {
+                "run_id": run_text,
+                "keyword": keyword,
+                "query_reason": query_reason,
+                "source_type": source_type,
+                "source_value": raw_task.get("source_value"),
+                "parent_search_id": parent_search_id,
+                "page_no": page_no,
+                "content_type": str(raw_task.get("content_type") or "视频"),
+                "sort_type": str(raw_task.get("sort_type") or "综合排序"),
+                "publish_time": str(raw_task.get("publish_time") or "不限"),
+            }
+            if provider == "tikhub":
+                raw_result = await douyin_search_tikhub(
+                    **common,
+                    cursor=int(cursor or 0),
+                    filter_duration=str(
+                        raw_task.get("filter_duration") or "不限"
+                    ),
+                    search_id=provider_search_id,
+                    backtrace=backtrace,
+                    min_duration_seconds=int(
+                        raw_task.get("min_duration_seconds") or 0
+                    ),
+                )
+            else:
+                raw_result = await douyin_search(
+                    **common,
+                    cursor=str(cursor or "0"),
+                )
+
+            result = _load_result(raw_result)
+            candidates = (
+                result.get("candidates")
+                if isinstance(result.get("candidates"), list)
+                else []
+            )
+            page_results.append(
+                {
+                    "page_no": page_no,
+                    "results_count": int(result.get("results_count") or 0),
+                    "has_more": bool(result.get("has_more")),
+                    "next_cursor": result.get("next_cursor"),
+                    "search_id": result.get("search_id"),
+                    "new_candidate_count": int(
+                        result.get("new_candidate_count") or 0
+                    ),
+                    "candidates": candidates,
+                    "persisted": bool(result.get("persisted")),
+                    "error": result.get("error"),
+                }
+            )
+
+            if result.get("persisted"):
+                total_pages += 1
+                total_new_candidates += int(
+                    result.get("new_candidate_count") or 0
+                )
+            if result.get("error"):
+                errors.append(
+                    f"[{index}] {keyword} 第 {page_no} 页: {result['error']}"
+                )
+                break
+            if not result.get("has_more") or page_no >= max_pages:
+                break
+
+            cursor = result.get("next_cursor") or cursor
+            provider_search_id = str(
+                result.get("provider_search_id") or provider_search_id
+            )
+            backtrace = str(result.get("backtrace") or backtrace)
+            parent_search_id = result.get("search_id") or parent_search_id
+
+        task_results.append(
+            {
+                "index": index,
+                "keyword": keyword,
+                "provider": provider,
+                "pages": page_results,
+            }
+        )
+
+    payload = {
+        "title": "批量搜索并自动落库",
+        "run_id": run_text,
+        "task_count": len(task_results),
+        "saved_page_count": total_pages,
+        "new_candidate_count": total_new_candidates,
+        "error_count": len(errors),
+        "errors": errors,
+        "tasks": task_results,
+        "output": (
+            f"完成 {len(task_results)} 个搜索任务,保存 {total_pages} 页,"
+            f"新增候选 {total_new_candidates} 条,错误 {len(errors)} 个"
+        ),
+    }
+    if errors and not total_pages:
+        payload["error"] = "批量搜索没有成功保存任何搜索页"
+    return json.dumps(payload, ensure_ascii=False)

+ 19 - 0
agents/find_agent/tools/batch_update_video_discovery_candidates.py

@@ -0,0 +1,19 @@
+"""按候选记录 ID 批量更新视频发现候选。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.video_discovery import (
+    batch_update_video_discovery_candidates as _batch_update_candidates,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _batch_update_candidates,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+def batch_update_video_discovery_candidates(*args: Any, **kwargs: Any) -> str:
+    return _batch_update_candidates(*args, **kwargs)

+ 19 - 0
agents/find_agent/tools/create_video_discovery_run.py

@@ -0,0 +1,19 @@
+"""创建视频发现运行。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.video_discovery import (
+    create_video_discovery_run as _create_video_discovery_run,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _create_video_discovery_run,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+def create_video_discovery_run(*args: Any, **kwargs: Any) -> str:
+    return _create_video_discovery_run(*args, **kwargs)

+ 13 - 355
agents/find_agent/tools/douyin_detail.py

@@ -1,362 +1,20 @@
-"""
-抖音视频详情工具
-
-根据 content_id(aweme_id)调用内部爬虫服务获取视频详情与真实播放链接。
-支持单个或批量查询。
-"""
+"""批量获取抖音视频详情。"""
 from __future__ import annotations
 from __future__ import annotations
 
 
-import asyncio
-import json
-import logging
-import time
-from typing import Any, Optional
-
-import httpx
-
-from supply_agent.tools import tool
-
-logger = logging.getLogger(__name__)
-
-_MIN_REQUEST_INTERVAL_SECONDS = 10.1
-_rate_limit_lock = asyncio.Lock()
-_last_request_monotonic: float = 0.0
-
-DOUYIN_DETAIL_API = "http://8.217.190.241:8888/crawler/dou_yin/detail"
-DEFAULT_TIMEOUT = 60.0
-MAX_DETAIL_ITEMS = 8
-
-_PLAY_URL_MARKER = "douyin.com/aweme/v1/play/"
+from functools import wraps
+from typing import Any
 
 
-# 详情接口中保留的有效字段(去掉长期无意义的空壳字段)
-_KEEP_FIELDS = (
-    "channel",
-    "channel_content_id",
-    "content_link",
-    "title",
-    "content_type",
-    "body_text",
-    "location",
-    "source_url",
-    "topic_list",
-    "image_url_list",
-    "video_url_list",
-    "multi_bitrate",
-    "bgm_data",
-    "is_original",
-    "channel_account_id",
-    "channel_account_name",
-    "channel_account_avatar",
-    "view_count",
-    "play_count",
-    "like_count",
-    "collect_count",
-    "comment_count",
-    "share_count",
-    "looking_count",
-    "publish_timestamp",
-    "modify_timestamp",
-    "update_timestamp",
+from agents.find_agent.support.douyin_detail import (
+    MAX_DETAIL_ITEMS as MAX_DETAIL_ITEMS,
+    douyin_detail as _douyin_detail,
 )
 )
-
-
-def _is_play_url(url: str) -> bool:
-    return bool(url) and _PLAY_URL_MARKER in url
-
-
-def _pick_url(*candidates: str) -> str:
-    """优先选 aweme/v1/play 链接,否则回退第一个非空 URL。"""
-    urls = [u for u in candidates if u]
-    for url in urls:
-        if _is_play_url(url):
-            return url
-    return urls[0] if urls else ""
-
-
-def _extract_video_url(detail_data: dict[str, Any]) -> str:
-    """优先取 video_url_list[0],并偏好 aweme/v1/play 可播放链接。"""
-    candidates: list[str] = []
-
-    video_url_list = detail_data.get("video_url_list")
-    if isinstance(video_url_list, list):
-        for item in video_url_list:
-            if isinstance(item, dict):
-                url = item.get("video_url") or ""
-                if url:
-                    candidates.append(url)
-
-    multi_bitrate = detail_data.get("multi_bitrate")
-    if isinstance(multi_bitrate, dict):
-        for ratio in ("1080p", "720p", "540p", "default"):
-            bit_info = multi_bitrate.get(ratio)
-            if isinstance(bit_info, dict):
-                url = bit_info.get("video_url") or ""
-                if url:
-                    candidates.append(url)
-
-    return _pick_url(*candidates)
-
-
-def _extract_cover_url(detail_data: dict[str, Any]) -> str:
-    image_url_list = detail_data.get("image_url_list")
-    if isinstance(image_url_list, list) and image_url_list:
-        first = image_url_list[0]
-        if isinstance(first, dict):
-            return first.get("image_url") or ""
-    return ""
-
-
-def _extract_video_duration(detail_data: dict[str, Any]) -> int:
-    video_url_list = detail_data.get("video_url_list")
-    if isinstance(video_url_list, list) and video_url_list:
-        first = video_url_list[0]
-        if isinstance(first, dict):
-            try:
-                return int(first.get("video_duration") or 0)
-            except (TypeError, ValueError):
-                return 0
-    return 0
-
-
-def _normalize_content_ids(content_ids: list[str]) -> list[str]:
-    """去重且保序,过滤空值。"""
-    seen: set[str] = set()
-    result: list[str] = []
-    for item in content_ids:
-        cid = str(item).strip()
-        if not cid or cid in seen:
-            continue
-        seen.add(cid)
-        result.append(cid)
-    return result
-
-
-def _build_detail_result(detail: dict[str, Any], content_id: str) -> dict[str, Any]:
-    """保留接口有效字段,并补充常用便捷字段。"""
-    channel_content_id = str(detail.get("channel_content_id") or content_id)
-    result: dict[str, Any] = {
-        "content_id": content_id,
-        "video_url": _extract_video_url(detail),
-        "video_duration": _extract_video_duration(detail),
-        "cover_url": _extract_cover_url(detail),
-    }
-
-    for key in _KEEP_FIELDS:
-        if key not in detail:
-            continue
-        value = detail.get(key)
-        if key == "channel_content_id":
-            result[key] = channel_content_id
-        elif key == "content_link":
-            result[key] = value or (
-                f"https://www.douyin.com/video/{channel_content_id}" if channel_content_id else ""
-            )
-        else:
-            result[key] = value
-
-    return result
-
-
-def _build_item_summary(index: int, result: dict[str, Any]) -> str:
-    lines = [
-        f"{index}. {result.get('title') or result.get('body_text') or '无标题'}",
-        f"   content_id: {result.get('content_id', '')}",
-        f"   页面链接: {result.get('content_link', '')}",
-        f"   视频链接: {result.get('video_url', '') or '未获取到'}",
-        f"   时长: {result.get('video_duration', 0)} 秒",
-        f"   作者: {result.get('channel_account_name', '')}",
-        f"   sec_uid: {result.get('channel_account_id', '')}",
-        (
-            f"   数据: 点赞 {result.get('like_count') or 0:,} | "
-            f"评论 {result.get('comment_count') or 0:,} | "
-            f"分享 {result.get('share_count') or 0:,} | "
-            f"收藏 {result.get('collect_count') or 0:,}"
-        ),
-    ]
-    return "\n".join(lines)
-
-
-def _build_output_summary(
-    details: list[dict[str, Any]],
-    errors: list[dict[str, str]],
-) -> str:
-    lines = [
-        f"抖音视频详情:成功 {len(details)} 条"
-        + (f",失败 {len(errors)} 条" if errors else "")
-    ]
-    lines.append("")
-
-    for i, item in enumerate(details, 1):
-        lines.append(_build_item_summary(i, item))
-        lines.append("")
-
-    if errors:
-        lines.append("失败列表:")
-        for err in errors:
-            lines.append(f"- {err.get('content_id', '')}: {err.get('error', '')}")
-
-    return "\n".join(lines).rstrip()
-
-
-def _error_result(
-    error: str,
-    *,
-    title: str = "抖音详情获取失败",
-    input_error: bool = False,
-) -> str:
-    return json.dumps(
-        {"error": error, "title": title, "input_error": input_error},
-        ensure_ascii=False,
-    )
-
-
-async def _wait_rate_limit() -> None:
-    global _last_request_monotonic
-    async with _rate_limit_lock:
-        now_mono = time.monotonic()
-        wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
-        if wait_seconds > 0:
-            await asyncio.sleep(wait_seconds)
-        _last_request_monotonic = time.monotonic()
-
-
-async def _fetch_one_detail(
-    client: httpx.AsyncClient,
-    content_id: str,
-) -> dict[str, Any]:
-    """拉取单条详情。成功返回 detail 字典;失败抛出 Exception。"""
-    await _wait_rate_limit()
-    response = await client.post(
-        DOUYIN_DETAIL_API,
-        json={"content_id": content_id},
-        headers={"Content-Type": "application/json"},
-    )
-    response.raise_for_status()
-    body = response.json()
-
-    if body.get("code") not in (0, None):
-        raise RuntimeError(f"接口返回错误: code={body.get('code')} msg={body.get('msg')}")
-
-    data_block = body.get("data", {}) if isinstance(body.get("data"), dict) else {}
-    detail_raw = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
-    if not detail_raw:
-        raise RuntimeError(f"未查到视频详情: content_id={content_id}")
-
-    return _build_detail_result(detail_raw, content_id)
+from supply_agent.tools import tool
 
 
 
 
 @tool
 @tool
-async def douyin_detail(
-    content_ids: list[str],
-    timeout: Optional[float] = None,
-) -> str:
-    """
-    抖音视频详情(支持批量)
-
-    根据 content_id(搜索结果中的 aweme_id)获取视频详情与真实播放链接。
-    用于在 douyin_search 选中目标视频后,再拉取可播放的 video_url。
-
-    Args:
-        content_ids: 视频 ID 列表,对应搜索结果中的 aweme_id。
-            单个传 ["123"],多个传 ["123", "456"]
-        timeout: 单次请求超时时间(秒),默认 60
-
-    Returns:
-        JSON 字符串,包含:
-        - output: 文本摘要
-        - details: 详情列表(含 video_url、作者、互动、BGM、多码率等有效字段)
-        - errors: 失败项列表
-        - success_count / failed_count / results_count
-    """
-    start_time = time.time()
-    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
-    ids = _normalize_content_ids(content_ids)
-
-    if not ids:
-        return _error_result("content_ids 不能为空")
-    if len(ids) > MAX_DETAIL_ITEMS:
-        return _error_result(
-            f"content_ids 最多 {MAX_DETAIL_ITEMS} 条,请先按相关性、分享价值和备选潜力筛选",
-            input_error=True,
-        )
-
-    details: list[dict[str, Any]] = []
-    errors: list[dict[str, str]] = []
-
-    try:
-        async with httpx.AsyncClient(
-            timeout=request_timeout,
-            trust_env=False,
-            headers={"User-Agent": "curl/8.6.0", "Accept": "*/*"},
-        ) as client:
-            for content_id in ids:
-                try:
-                    detail = await _fetch_one_detail(client, content_id)
-                    details.append(detail)
-                except httpx.HTTPStatusError as e:
-                    msg = f"HTTP {e.response.status_code}: {e.response.text}"
-                    logger.error("douyin_detail HTTP error: content_id=%s status=%d", content_id, e.response.status_code)
-                    errors.append({"content_id": content_id, "error": msg})
-                except httpx.TimeoutException:
-                    msg = f"请求超时({request_timeout}秒)"
-                    logger.error("douyin_detail timeout: content_id=%s", content_id)
-                    errors.append({"content_id": content_id, "error": msg})
-                except httpx.RequestError as e:
-                    msg = f"网络错误: {e}"
-                    logger.error("douyin_detail network error: content_id=%s error=%s", content_id, e)
-                    errors.append({"content_id": content_id, "error": msg})
-                except Exception as e:
-                    msg = str(e)
-                    logger.warning("douyin_detail item failed: content_id=%s error=%s", content_id, e)
-                    errors.append({"content_id": content_id, "error": msg})
-
-        duration_ms = int((time.time() - start_time) * 1000)
-        logger.info(
-            "douyin_detail completed: requested=%d success=%d failed=%d duration_ms=%d",
-            len(ids),
-            len(details),
-            len(errors),
-            duration_ms,
-        )
-
-        if not details and errors:
-            return _error_result(
-                f"全部失败({len(errors)} 条): {errors[0].get('error', '')}"
-            )
-
-        payload = {
-            "title": f"抖音详情: {len(details)}/{len(ids)}",
-            "output": _build_output_summary(details, errors),
-            "results_count": len(ids),
-            "success_count": len(details),
-            "failed_count": len(errors),
-            "details": details,
-            "errors": errors,
-            "duration_ms": duration_ms,
-        }
-        # 单条时额外提供 detail,方便旧逻辑取值
-        if len(details) == 1:
-            payload["detail"] = details[0]
-        return json.dumps(payload, ensure_ascii=False)
-
-    except Exception as e:
-        logger.error("douyin_detail unexpected error: error=%s", e, exc_info=True)
-        return _error_result(f"未知错误: {e}")
-
-
-async def main() -> None:
-    result_json = await douyin_detail(
-        content_ids=["7641118685977614586", "7307654921879358747"]
-    )
-    result = json.loads(result_json)
-    if "error" in result and "details" not in result:
-        print(f"获取失败: {result['error']}")
-    else:
-        print(result["output"])
-        print(f"\nsuccess={result.get('success_count')} failed={result.get('failed_count')}")
-        for item in result.get("details", []):
-            print(f"- {item.get('content_id')}: {item.get('video_url')}")
-
-
-if __name__ == "__main__":
-    asyncio.run(main())
+@wraps(
+    _douyin_detail,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+async def douyin_detail(*args: Any, **kwargs: Any) -> str:
+    return await _douyin_detail(*args, **kwargs)

+ 45 - 210
agents/find_agent/tools/douyin_search.py

@@ -1,127 +1,31 @@
-"""
-抖音关键词搜索工具
-
-调用内部爬虫服务进行抖音关键词搜索。
-"""
+"""搜索一页抖音视频并自动保存。"""
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
-import json
-import logging
-import time
-from typing import Any, Optional
-
-import httpx
-
+from typing import Optional
+
+import httpx as httpx
+
+from agents.find_agent.support.douyin_search import (
+    DOUYIN_ACCOUNT_ID,
+    _build_search_results as _build_search_results,
+    _douyin_search_raw,
+    _error_result as _error_result,
+    _success_result as _success_result,
+)
+from agents.find_agent.support.search_persistence import persist_search_payload
 from supply_agent.tools import tool
 from supply_agent.tools import tool
 
 
-logger = logging.getLogger(__name__)
-
-_MIN_REQUEST_INTERVAL_SECONDS = 10.1
-_rate_limit_lock = asyncio.Lock()
-_last_request_monotonic: float = 0.0
-
-# API 基础配置
-DOUYIN_SEARCH_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword"
-DEFAULT_TIMEOUT = 60.0
-DOUYIN_ACCOUNT_ID = "771431222"
-
-
-def _build_search_results(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
-    """将 API 原始条目转换为结构化搜索结果。"""
-    results = []
-    for item in items:
-        author = item.get("author", {}) if isinstance(item.get("author"), dict) else {}
-        stats = item.get("statistics", {}) if isinstance(item.get("statistics"), dict) else {}
-        aweme_id = item.get("aweme_id", "")
-        results.append(
-            {
-                "aweme_id": aweme_id,
-                "desc": (item.get("desc") or item.get("item_title") or "无标题")[:100],
-                "url": f"https://www.douyin.com/video/{aweme_id}" if aweme_id else "",
-                "author": {
-                    "nickname": author.get("nickname", "未知作者"),
-                    "sec_uid": author.get("sec_uid", ""),
-                },
-                "statistics": {
-                    "digg_count": stats.get("digg_count", 0),
-                    "comment_count": stats.get("comment_count", 0),
-                    "share_count": stats.get("share_count", 0),
-                },
-            }
-        )
-    return results
-
-
-def _build_output_summary(
-    keyword: str,
-    items: list[dict[str, Any]],
-    has_more: bool,
-    cursor_value: str,
-) -> str:
-    """生成给 LLM 阅读的文本摘要。"""
-    lines = [f"搜索关键词「{keyword}」"]
-    lines.append(
-        f"找到 {len(items)} 条结果"
-        + (f",还有更多(cursor={cursor_value})" if has_more else "")
-    )
-    lines.append("")
-
-    for i, item in enumerate(items, 1):
-        aweme_id = item.get("aweme_id", "unknown")
-        desc = (item.get("desc") or item.get("item_title") or "无标题")[:50]
-
-        author = item.get("author", {}) if isinstance(item.get("author"), dict) else {}
-        author_name = author.get("nickname", "未知作者")
-        author_id = author.get("sec_uid", "")
-
-        stats = item.get("statistics", {}) if isinstance(item.get("statistics"), dict) else {}
-        digg_count = stats.get("digg_count", 0)
-        comment_count = stats.get("comment_count", 0)
-        share_count = stats.get("share_count", 0)
-
-        lines.append(f"{i}. {desc}")
-        lines.append(f"   ID: {aweme_id}")
-        lines.append(f"   链接: https://www.douyin.com/video/{aweme_id}")
-        lines.append(f"   作者: {author_name}")
-        lines.append(f"   sec_uid: {author_id}")
-        lines.append(f"   数据: 点赞 {digg_count:,} | 评论 {comment_count:,} | 分享 {share_count:,}")
-        lines.append("")
-
-    return "\n".join(lines)
-
-
-def _success_result(
-    keyword: str,
-    data: dict[str, Any],
-    items: list[dict[str, Any]],
-    has_more: bool,
-    cursor_value: str,
-    duration_ms: int,
-) -> str:
-    """构建成功时的 JSON 字符串返回值。"""
-    search_results = _build_search_results(items)
-    payload = {
-        "title": f"抖音搜索: {keyword}",
-        "output": _build_output_summary(keyword, items, has_more, cursor_value),
-        "keyword": keyword,
-        "results_count": len(items),
-        "has_more": has_more,
-        "next_cursor": cursor_value,
-        "search_results": search_results,
-        "duration_ms": duration_ms,
-    }
-    return json.dumps(payload, ensure_ascii=False)
-
-
-def _error_result(error: str, *, title: str = "抖音搜索失败") -> str:
-    """构建失败时的 JSON 字符串返回值。"""
-    return json.dumps({"error": error, "title": title}, ensure_ascii=False)
-
 
 
 @tool
 @tool
 async def douyin_search(
 async def douyin_search(
+    run_id: str,
     keyword: str,
     keyword: str,
+    query_reason: str,
+    source_type: str,
+    source_value: str | None = None,
+    parent_search_id: int | None = None,
+    page_no: int = 1,
     content_type: str = "视频",
     content_type: str = "视频",
     sort_type: str = "综合排序",
     sort_type: str = "综合排序",
     publish_time: str = "不限",
     publish_time: str = "不限",
@@ -129,98 +33,29 @@ async def douyin_search(
     account_id: str = DOUYIN_ACCOUNT_ID,
     account_id: str = DOUYIN_ACCOUNT_ID,
     timeout: Optional[float] = None,
     timeout: Optional[float] = None,
 ) -> str:
 ) -> str:
-    """
-    抖音关键词搜索
-
-    通过关键词搜索抖音平台的视频内容,支持多种排序和筛选方式。
-
-    Args:
-        keyword: 搜索关键词
-        content_type: 内容类型(可选:视频/图文, 默认 "视频")
-        sort_type: 排序方式(可选:综合排序/最新发布/最多点赞, 默认 "综合排序")
-        publish_time: 发布时间范围(可选:不限/一天内/一周内/半年内, 默认 "不限")
-        cursor: 分页游标,用于获取下一页结果,默认 "0"
-        account_id: 账号ID(可选)
-        timeout: 超时时间(秒),默认 60
-
-    Returns:
-        JSON 字符串,包含 output(文本摘要)和 search_results(结构化列表)。
-        search_results 中每项含 aweme_id、desc、author、statistics。
-        使用 next_cursor 可获取下一页。
-    """
-    start_time = time.time()
-    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
-
-    try:
-        global _last_request_monotonic
-        async with _rate_limit_lock:
-            now_mono = time.monotonic()
-            wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
-            if wait_seconds > 0:
-                await asyncio.sleep(wait_seconds)
-            _last_request_monotonic = time.monotonic()
-
-        payload = {
-            "keyword": keyword,
-            "content_type": content_type,
-            "sort_type": sort_type,
-            "publish_time": publish_time,
-            "cursor": cursor,
-            "account_id": account_id,
-        }
-
-        async with httpx.AsyncClient(timeout=request_timeout) as client:
-            response = await client.post(
-                DOUYIN_SEARCH_API,
-                json=payload,
-                headers={"Content-Type": "application/json"},
-            )
-            response.raise_for_status()
-            data = response.json()
-
-        data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
-        items = data_block.get("data", []) if isinstance(data_block.get("data"), list) else []
-        has_more = bool(data_block.get("has_more", False))
-        cursor_value = str(data_block.get("next_cursor", ""))
-
-        duration_ms = int((time.time() - start_time) * 1000)
-        logger.info(
-            "douyin_search completed: keyword=%s results=%d has_more=%s duration_ms=%d",
-            keyword,
-            len(items),
-            has_more,
-            duration_ms,
-        )
-
-        return _success_result(keyword, data, items, has_more, cursor_value, duration_ms)
-
-    except httpx.HTTPStatusError as e:
-        logger.error(
-            "douyin_search HTTP error: keyword=%s status=%d",
-            keyword,
-            e.response.status_code,
-        )
-        return _error_result(f"HTTP {e.response.status_code}: {e.response.text}")
-    except httpx.TimeoutException:
-        logger.error("douyin_search timeout: keyword=%s timeout=%s", keyword, request_timeout)
-        return _error_result(f"请求超时({request_timeout}秒)")
-    except httpx.RequestError as e:
-        logger.error("douyin_search network error: keyword=%s error=%s", keyword, e)
-        return _error_result(f"网络错误: {e}")
-    except Exception as e:
-        logger.error("douyin_search unexpected error: keyword=%s error=%s", keyword, e, exc_info=True)
-        return _error_result(f"未知错误: {e}")
-
-
-async def main() -> None:
-    result_json = await douyin_search(keyword="养老政策", account_id=DOUYIN_ACCOUNT_ID)
-    result = json.loads(result_json)
-    if "error" in result:
-        print(f"搜索失败: {result['error']}")
-    else:
-        print(result["output"])
-        print(f"\n共 {result['results_count']} 条结果")
-
-
-if __name__ == "__main__":
-    asyncio.run(main())
+    """搜索一页抖音视频,创建搜索记录和候选记录,返回基础信息及数据库 ID。"""
+    result = await _douyin_search_raw(
+        keyword=keyword,
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+        cursor=cursor,
+        account_id=account_id,
+        timeout=timeout,
+    )
+    return await asyncio.to_thread(
+        persist_search_payload,
+        result,
+        run_id=run_id,
+        keyword=keyword,
+        query_reason=query_reason,
+        source_type=source_type,
+        source_value=source_value,
+        parent_search_id=parent_search_id,
+        cursor=cursor,
+        page_no=page_no,
+        provider="internal_keyword",
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+    )

+ 45 - 384
agents/find_agent/tools/douyin_search_tikhub.py

@@ -1,235 +1,29 @@
-"""通过 TikHub 搜索抖音视频,输出 find_agent 统一候选格式。"""
+"""通过 TikHub 搜索一页抖音视频并自动保存。"""
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
-import json
-import logging
-import os
-import time
-from typing import Any
+import os as os
 
 
-import httpx
-from dotenv import load_dotenv
+import httpx as httpx
 
 
-from supply_agent.paths import find_project_root
-from supply_agent.tools import tool
-
-logger = logging.getLogger(__name__)
-
-DOUYIN_SEARCH_TIKHUB_API = (
-    "https://api.tikhub.io/api/v1/douyin/search/fetch_video_search_v2"
+from agents.find_agent.support.douyin_search_tikhub import (
+    _douyin_search_tikhub_raw,
+    _ensure_env_loaded as _ensure_env_loaded,
+    _wait_rate_limit as _wait_rate_limit,
 )
 )
-DEFAULT_TIMEOUT = 60.0
-_MIN_REQUEST_INTERVAL_SECONDS = 1.0
-_rate_limit_lock = asyncio.Lock()
-_last_request_monotonic = 0.0
-_env_loaded = False
-
-_CONTENT_TYPE_MAP = {
-    "不限": "0",
-    "视频": "1",
-    "图片": "2",
-    "图文": "2",
-    "文章": "3",
-    "0": "0",
-    "1": "1",
-    "2": "2",
-    "3": "3",
-}
-_SORT_TYPE_MAP = {
-    "综合排序": "0",
-    "最多点赞": "1",
-    "最新发布": "2",
-    "0": "0",
-    "1": "1",
-    "2": "2",
-}
-_PUBLISH_TIME_MAP = {
-    "不限": "0",
-    "一天内": "1",
-    "最近一天": "1",
-    "一周内": "7",
-    "最近一周": "7",
-    "半年内": "180",
-    "最近半年": "180",
-    "0": "0",
-    "1": "1",
-    "7": "7",
-    "180": "180",
-}
-_DURATION_MAP = {
-    "不限": "0",
-    "一分钟内": "0-1",
-    "1分钟以内": "0-1",
-    "1-5分钟": "1-5",
-    "五分钟以上": "5-10000",
-    "5分钟以上": "5-10000",
-    "0": "0",
-    "0-1": "0-1",
-    "1-5": "1-5",
-    "5-10000": "5-10000",
-}
-
-
-def _ensure_env_loaded() -> None:
-    global _env_loaded
-    if _env_loaded:
-        return
-    load_dotenv(find_project_root() / ".env")
-    _env_loaded = True
-
-
-def _safe_int(value: Any, default: int = 0) -> int:
-    if isinstance(value, bool) or value is None:
-        return default
-    try:
-        return int(float(str(value).strip()))
-    except (TypeError, ValueError):
-        return default
-
-
-def _enum_value(value: str, mapping: dict[str, str], field: str) -> str:
-    normalized = str(value).strip()
-    if normalized not in mapping:
-        choices = " / ".join(key for key in mapping if not key.isdigit())
-        raise ValueError(f"{field} 不支持「{value}」,可选:{choices}")
-    return mapping[normalized]
-
-
-def _get_aweme_info(item: Any) -> dict[str, Any]:
-    if not isinstance(item, dict):
-        return {}
-    data = item.get("data")
-    if not isinstance(data, dict):
-        return {}
-    aweme_info = data.get("aweme_info")
-    return aweme_info if isinstance(aweme_info, dict) else {}
-
-
-def _extract_topics(aweme: dict[str, Any]) -> list[str]:
-    topics: list[str] = []
-    for item in aweme.get("topic_list") or []:
-        if isinstance(item, str):
-            topics.append(item.strip())
-        elif isinstance(item, dict):
-            topic = (
-                item.get("topic_name")
-                or item.get("cha_name")
-                or item.get("hashtag_name")
-                or item.get("name")
-            )
-            if topic:
-                topics.append(str(topic).strip())
-    for item in aweme.get("text_extra") or []:
-        if isinstance(item, dict):
-            topic = item.get("hashtag_name")
-            if topic:
-                topics.append(str(topic).strip())
-    for item in aweme.get("cha_list") or []:
-        if isinstance(item, dict):
-            topic = item.get("cha_name")
-            if topic:
-                topics.append(str(topic).strip())
-    return list(dict.fromkeys(topic for topic in topics if topic))
-
-
-def _normalize_aweme(aweme: dict[str, Any]) -> dict[str, Any] | None:
-    aweme_id = str(aweme.get("aweme_id") or "").strip()
-    if not aweme_id:
-        return None
-    author = aweme.get("author") if isinstance(aweme.get("author"), dict) else {}
-    stats = (
-        aweme.get("statistics")
-        if isinstance(aweme.get("statistics"), dict)
-        else {}
-    )
-    return {
-        "aweme_id": aweme_id,
-        "desc": str(
-            aweme.get("desc") or aweme.get("item_title") or "无标题"
-        )[:200],
-        "url": f"https://www.douyin.com/video/{aweme_id}",
-        "author": {
-            "nickname": str(author.get("nickname") or "未知作者"),
-            "sec_uid": str(author.get("sec_uid") or ""),
-        },
-        "statistics": {
-            "digg_count": _safe_int(stats.get("digg_count")),
-            "comment_count": _safe_int(stats.get("comment_count")),
-            "share_count": _safe_int(stats.get("share_count")),
-            "collect_count": _safe_int(stats.get("collect_count")),
-            "play_count": _safe_int(stats.get("play_count")),
-        },
-        "duration_ms": _safe_int(aweme.get("duration")),
-        "topics": _extract_topics(aweme),
-    }
-
-
-def _summary(
-    keyword: str,
-    results: list[dict[str, Any]],
-    *,
-    filtered_count: int,
-    has_more: bool,
-    next_cursor: int,
-    search_id: str,
-) -> str:
-    lines = [
-        f"TikHub 搜索关键词「{keyword}」",
-        (
-            f"保留 {len(results)} 条"
-            + (f",过滤短视频 {filtered_count} 条" if filtered_count else "")
-            + (
-                f",还有更多(cursor={next_cursor}, search_id={search_id})"
-                if has_more
-                else ""
-            )
-        ),
-        "",
-    ]
-    for index, item in enumerate(results, 1):
-        stats = item["statistics"]
-        lines.extend(
-            [
-                f"{index}. {item['desc'][:50]}",
-                f"   ID: {item['aweme_id']}",
-                f"   链接: {item['url']}",
-                (
-                    f"   作者: {item['author']['nickname']} | "
-                    f"sec_uid: {item['author']['sec_uid']}"
-                ),
-                (
-                    f"   数据: 点赞 {stats['digg_count']:,} | "
-                    f"评论 {stats['comment_count']:,} | "
-                    f"分享 {stats['share_count']:,} | "
-                    f"收藏 {stats['collect_count']:,}"
-                ),
-                f"   标签: {'、'.join(item['topics']) or '无'}",
-                "",
-            ]
-        )
-    return "\n".join(lines).rstrip()
-
-
-def _error_result(error: str) -> str:
-    return json.dumps(
-        {"error": error, "title": "TikHub 抖音搜索失败"},
-        ensure_ascii=False,
-    )
-
-
-async def _wait_rate_limit() -> None:
-    global _last_request_monotonic
-    async with _rate_limit_lock:
-        elapsed = time.monotonic() - _last_request_monotonic
-        if elapsed < _MIN_REQUEST_INTERVAL_SECONDS:
-            await asyncio.sleep(_MIN_REQUEST_INTERVAL_SECONDS - elapsed)
-        _last_request_monotonic = time.monotonic()
+from agents.find_agent.support.search_persistence import persist_search_payload
+from supply_agent.tools import tool
 
 
 
 
 @tool
 @tool
 async def douyin_search_tikhub(
 async def douyin_search_tikhub(
+    run_id: str,
     keyword: str,
     keyword: str,
+    query_reason: str,
+    source_type: str,
+    source_value: str | None = None,
+    parent_search_id: int | None = None,
+    page_no: int = 1,
     content_type: str = "视频",
     content_type: str = "视频",
     sort_type: str = "综合排序",
     sort_type: str = "综合排序",
     publish_time: str = "不限",
     publish_time: str = "不限",
@@ -240,166 +34,33 @@ async def douyin_search_tikhub(
     min_duration_seconds: int = 0,
     min_duration_seconds: int = 0,
     timeout: float | None = None,
     timeout: float | None = None,
 ) -> str:
 ) -> str:
-    """
-    使用 TikHub 搜索抖音视频,支持多关键词探索和完整分页状态。
-
-    这是 douyin_search 的独立搜索来源。首次搜索 cursor=0、search_id/backtrace 为空;
-    翻页时必须把上次返回的 next_cursor、search_id、backtrace 原样传回。
-
-    Args:
-        keyword: Agent 自主确定的实际搜索词。
-        content_type: 不限 / 视频 / 图片 / 文章,默认视频;也兼容 TikHub 数字代码。
-        sort_type: 综合排序 / 最多点赞 / 最新发布;也兼容 0 / 1 / 2。
-        publish_time: 不限 / 一天内 / 一周内 / 半年内;也兼容 0 / 1 / 7 / 180。
-        cursor: 首次为 0,翻页使用上次返回的 next_cursor。
-        filter_duration: 不限 / 一分钟内 / 1-5分钟 / 5分钟以上。
-        search_id: 翻页状态,必须使用同一搜索返回值。
-        backtrace: 翻页回溯状态,必须使用同一搜索返回值。
-        min_duration_seconds: 客户端最短时长过滤,默认 0 表示不过滤。
-        timeout: 请求超时秒数,默认 60。
-
-    Returns:
-        JSON 字符串。search_results 与 douyin_search 格式兼容,并额外包含
-        duration_ms、topics、收藏数和播放数;分页字段为 has_more、next_cursor、
-        search_id、backtrace。
-    """
-    keyword_text = str(keyword).strip()
-    if not keyword_text:
-        return _error_result("keyword 不能为空")
-
-    try:
-        content_type_value = _enum_value(content_type, _CONTENT_TYPE_MAP, "content_type")
-        sort_type_value = _enum_value(sort_type, _SORT_TYPE_MAP, "sort_type")
-        publish_time_value = _enum_value(
-            publish_time, _PUBLISH_TIME_MAP, "publish_time"
-        )
-        duration_value = _enum_value(
-            filter_duration, _DURATION_MAP, "filter_duration"
-        )
-    except ValueError as exc:
-        return _error_result(str(exc))
-
-    _ensure_env_loaded()
-    api_key = os.getenv("TIKHUB_API_KEY", "").strip()
-    if not api_key:
-        return _error_result("未设置环境变量 TIKHUB_API_KEY")
-
-    start_time = time.time()
-    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
-    payload = {
-        "keyword": keyword_text,
-        "cursor": max(0, int(cursor)),
-        "sort_type": sort_type_value,
-        "publish_time": publish_time_value,
-        "filter_duration": duration_value,
-        "content_type": content_type_value,
-        "search_id": str(search_id or ""),
-        "backtrace": str(backtrace or ""),
-    }
-
-    try:
-        await _wait_rate_limit()
-        async with httpx.AsyncClient(
-            timeout=request_timeout,
-            trust_env=False,
-            headers={
-                "Content-Type": "application/json",
-                "Authorization": f"Bearer {api_key}",
-            },
-        ) as client:
-            response = await client.post(DOUYIN_SEARCH_TIKHUB_API, json=payload)
-            response.raise_for_status()
-            body = response.json()
-
-        data = body.get("data") if isinstance(body.get("data"), dict) else {}
-        business_data = (
-            data.get("business_data")
-            if isinstance(data.get("business_data"), list)
-            else []
-        )
-        config = (
-            data.get("business_config")
-            if isinstance(data.get("business_config"), dict)
-            else {}
-        )
-        next_page = (
-            config.get("next_page")
-            if isinstance(config.get("next_page"), dict)
-            else {}
-        )
-
-        results: list[dict[str, Any]] = []
-        seen: set[str] = set()
-        filtered_count = 0
-        minimum_ms = max(0, int(min_duration_seconds)) * 1000
-        for raw_item in business_data:
-            normalized = _normalize_aweme(_get_aweme_info(raw_item))
-            if normalized is None or normalized["aweme_id"] in seen:
-                continue
-            if (
-                minimum_ms
-                and normalized["duration_ms"]
-                and normalized["duration_ms"] < minimum_ms
-            ):
-                filtered_count += 1
-                continue
-            seen.add(normalized["aweme_id"])
-            results.append(normalized)
-
-        has_more = bool(config.get("has_more") in (1, True, "1"))
-        next_cursor = _safe_int(next_page.get("cursor"))
-        next_search_id = str(next_page.get("search_id") or search_id or "")
-        next_backtrace = str(
-            next_page.get("backtrace") or config.get("backtrace") or backtrace or ""
-        )
-        duration_ms = int((time.time() - start_time) * 1000)
-        result = {
-            "title": f"TikHub 抖音搜索: {keyword_text}",
-            "output": _summary(
-                keyword_text,
-                results,
-                filtered_count=filtered_count,
-                has_more=has_more,
-                next_cursor=next_cursor,
-                search_id=next_search_id,
-            ),
-            "provider": "tikhub",
-            "keyword": keyword_text,
-            "request_params": payload,
-            "results_count": len(results),
-            "filtered_count": filtered_count,
-            "has_more": has_more,
-            "next_cursor": next_cursor,
-            "search_id": next_search_id,
-            "backtrace": next_backtrace,
-            "search_results": results,
-            "duration_ms": duration_ms,
-        }
-        logger.info(
-            "douyin_search_tikhub completed: keyword=%s results=%d has_more=%s duration_ms=%d",
-            keyword_text,
-            len(results),
-            has_more,
-            duration_ms,
-        )
-        return json.dumps(result, ensure_ascii=False)
-    except httpx.HTTPStatusError as exc:
-        text = exc.response.text[:1000]
-        logger.error(
-            "douyin_search_tikhub HTTP error: keyword=%s status=%d",
-            keyword_text,
-            exc.response.status_code,
-        )
-        return _error_result(f"HTTP {exc.response.status_code}: {text}")
-    except httpx.TimeoutException:
-        return _error_result(f"请求超时({request_timeout}秒)")
-    except httpx.RequestError as exc:
-        return _error_result(f"网络错误: {exc}")
-    except Exception as exc:
-        logger.error(
-            "douyin_search_tikhub unexpected error: keyword=%s error=%s",
-            keyword_text,
-            exc,
-            exc_info=True,
-        )
-        return _error_result(f"未知错误: {exc}")
+    """使用 TikHub 搜索一页抖音视频,返回基础信息、数据库 ID 和分页状态。"""
+    result = await _douyin_search_tikhub_raw(
+        keyword=keyword,
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+        cursor=cursor,
+        filter_duration=filter_duration,
+        search_id=search_id,
+        backtrace=backtrace,
+        min_duration_seconds=min_duration_seconds,
+        timeout=timeout,
+    )
+    return await asyncio.to_thread(
+        persist_search_payload,
+        result,
+        run_id=run_id,
+        keyword=keyword,
+        query_reason=query_reason,
+        source_type=source_type,
+        source_value=source_value,
+        parent_search_id=parent_search_id,
+        cursor=str(cursor),
+        page_no=page_no,
+        provider="tikhub",
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+        provider_state={"search_id": search_id, "backtrace": backtrace},
+    )

+ 34 - 248
agents/find_agent/tools/douyin_user_videos.py

@@ -1,264 +1,50 @@
-"""查询抖音作者作品,输出 find_agent 统一候选格式。"""
+"""获取一页作者作品并自动保存。"""
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
-import json
-import logging
-import time
-from typing import Any
 
 
-import httpx
+import httpx as httpx
 
 
+from agents.find_agent.support.douyin_user_videos import (
+    _douyin_user_videos_raw,
+    _wait_rate_limit as _wait_rate_limit,
+)
+from agents.find_agent.support.search_persistence import persist_search_payload
 from supply_agent.tools import tool
 from supply_agent.tools import tool
 
 
-logger = logging.getLogger(__name__)
-
-DOUYIN_USER_VIDEOS_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/blogger"
-DEFAULT_TIMEOUT = 60.0
-_MIN_REQUEST_INTERVAL_SECONDS = 10.1
-_rate_limit_lock = asyncio.Lock()
-_last_request_monotonic = 0.0
-_SORT_TYPES = {"最新", "最热"}
-
-
-def _safe_int(value: Any, default: int = 0) -> int:
-    if isinstance(value, bool) or value is None:
-        return default
-    try:
-        return int(float(str(value).strip()))
-    except (TypeError, ValueError):
-        return default
-
-
-def _extract_topics(item: dict[str, Any]) -> list[str]:
-    topics: list[str] = []
-    for topic in item.get("topic_list") or []:
-        if isinstance(topic, str):
-            topics.append(topic.strip())
-        elif isinstance(topic, dict):
-            name = (
-                topic.get("topic_name")
-                or topic.get("cha_name")
-                or topic.get("hashtag_name")
-                or topic.get("name")
-            )
-            if name:
-                topics.append(str(name).strip())
-    for topic in item.get("text_extra") or []:
-        if isinstance(topic, dict) and topic.get("hashtag_name"):
-            topics.append(str(topic["hashtag_name"]).strip())
-    for topic in item.get("cha_list") or []:
-        if isinstance(topic, dict) and topic.get("cha_name"):
-            topics.append(str(topic["cha_name"]).strip())
-    return list(dict.fromkeys(topic for topic in topics if topic))
-
-
-def _normalize_video(item: dict[str, Any]) -> dict[str, Any] | None:
-    aweme_id = str(item.get("aweme_id") or "").strip()
-    if not aweme_id:
-        return None
-    author = item.get("author") if isinstance(item.get("author"), dict) else {}
-    stats = (
-        item.get("statistics")
-        if isinstance(item.get("statistics"), dict)
-        else {}
-    )
-    video = item.get("video") if isinstance(item.get("video"), dict) else {}
-    duration_ms = _safe_int(video.get("duration") or item.get("duration"))
-    return {
-        "aweme_id": aweme_id,
-        "desc": str(item.get("desc") or item.get("item_title") or "无标题")[:200],
-        "url": f"https://www.douyin.com/video/{aweme_id}",
-        "author": {
-            "nickname": str(author.get("nickname") or "未知作者"),
-            "sec_uid": str(author.get("sec_uid") or ""),
-        },
-        "statistics": {
-            "digg_count": _safe_int(stats.get("digg_count")),
-            "comment_count": _safe_int(stats.get("comment_count")),
-            "share_count": _safe_int(stats.get("share_count")),
-            "collect_count": _safe_int(stats.get("collect_count")),
-            "play_count": _safe_int(stats.get("play_count")),
-        },
-        "duration_ms": duration_ms,
-        "topics": _extract_topics(item),
-    }
-
-
-def _summary(
-    account_id: str,
-    results: list[dict[str, Any]],
-    *,
-    filtered_count: int,
-    has_more: bool,
-    next_cursor: str,
-) -> str:
-    lines = [
-        f"账号 {account_id} 的作品列表",
-        (
-            f"保留 {len(results)} 条"
-            + (f",过滤短视频 {filtered_count} 条" if filtered_count else "")
-            + (f",还有更多(cursor={next_cursor})" if has_more else "")
-        ),
-        "",
-    ]
-    for index, item in enumerate(results, 1):
-        stats = item["statistics"]
-        lines.extend(
-            [
-                f"{index}. {item['desc'][:50]}",
-                f"   ID: {item['aweme_id']}",
-                f"   链接: {item['url']}",
-                (
-                    f"   数据: 点赞 {stats['digg_count']:,} | "
-                    f"评论 {stats['comment_count']:,} | "
-                    f"分享 {stats['share_count']:,} | "
-                    f"收藏 {stats['collect_count']:,}"
-                ),
-                f"   标签: {'、'.join(item['topics']) or '无'}",
-                "",
-            ]
-        )
-    return "\n".join(lines).rstrip()
-
-
-def _error_result(error: str) -> str:
-    return json.dumps(
-        {"error": error, "title": "抖音作者作品获取失败"},
-        ensure_ascii=False,
-    )
-
-
-async def _wait_rate_limit() -> None:
-    global _last_request_monotonic
-    async with _rate_limit_lock:
-        elapsed = time.monotonic() - _last_request_monotonic
-        if elapsed < _MIN_REQUEST_INTERVAL_SECONDS:
-            await asyncio.sleep(_MIN_REQUEST_INTERVAL_SECONDS - elapsed)
-        _last_request_monotonic = time.monotonic()
-
 
 
 @tool
 @tool
 async def douyin_user_videos(
 async def douyin_user_videos(
+    run_id: str,
     account_id: str,
     account_id: str,
+    query_reason: str,
+    source_value: str | None = None,
+    parent_search_id: int | None = None,
+    page_no: int = 1,
     sort_type: str = "最热",
     sort_type: str = "最热",
     cursor: str = "",
     cursor: str = "",
     min_duration_seconds: int = 0,
     min_duration_seconds: int = 0,
     timeout: float | None = None,
     timeout: float | None = None,
 ) -> str:
 ) -> str:
-    """
-    获取指定抖音作者的作品列表,支持最热/最新排序和游标翻页。
-
-    当候选作者的粉丝画像偏老或某条视频表现优秀时,可用该工具扩展同作者内容。
-    返回结构与 douyin_search.search_results 一致,可直接保存为搜索轨迹和候选。
-
-    Args:
-        account_id: author.sec_uid,必须使用完整值。
-        sort_type: 最热 / 最新,默认最热。
-        cursor: 首次为空;翻页使用上次返回的 next_cursor。
-        min_duration_seconds: 最短时长过滤,默认 0 表示不过滤。
-        timeout: 请求超时秒数,默认 60。
-
-    Returns:
-        JSON 字符串,包含 user_videos、search_results、has_more 和 next_cursor。
-        user_videos 与 search_results 是同一个统一结构化列表。
-    """
-    account_text = str(account_id).strip()
-    if not account_text:
-        return _error_result("account_id 不能为空")
-    if sort_type not in _SORT_TYPES:
-        return _error_result(f"sort_type 必须是: {sorted(_SORT_TYPES)}")
-
-    start_time = time.time()
-    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
-    payload = {
-        "account_id": account_text,
-        "sort_type": sort_type,
-        "cursor": str(cursor or ""),
-    }
-
-    try:
-        await _wait_rate_limit()
-        async with httpx.AsyncClient(
-            timeout=request_timeout,
-            trust_env=False,
-            headers={"Content-Type": "application/json"},
-        ) as client:
-            response = await client.post(DOUYIN_USER_VIDEOS_API, json=payload)
-            response.raise_for_status()
-            body = response.json()
-
-        data = body.get("data") if isinstance(body.get("data"), dict) else {}
-        items = data.get("data") if isinstance(data.get("data"), list) else []
-        minimum_ms = max(0, int(min_duration_seconds)) * 1000
-        filtered_count = 0
-        seen: set[str] = set()
-        results: list[dict[str, Any]] = []
-        for raw_item in items:
-            if not isinstance(raw_item, dict):
-                continue
-            normalized = _normalize_video(raw_item)
-            if normalized is None or normalized["aweme_id"] in seen:
-                continue
-            if (
-                minimum_ms
-                and normalized["duration_ms"]
-                and normalized["duration_ms"] < minimum_ms
-            ):
-                filtered_count += 1
-                continue
-            seen.add(normalized["aweme_id"])
-            results.append(normalized)
-
-        has_more = bool(data.get("has_more") in (1, True, "1"))
-        next_cursor = str(data.get("next_cursor") or "")
-        duration_ms = int((time.time() - start_time) * 1000)
-        result = {
-            "title": f"抖音作者作品: {account_text}",
-            "output": _summary(
-                account_text,
-                results,
-                filtered_count=filtered_count,
-                has_more=has_more,
-                next_cursor=next_cursor,
-            ),
-            "provider": "internal_blogger",
-            "account_id": account_text,
-            "sort_type": sort_type,
-            "cursor": str(cursor or ""),
-            "results_count": len(results),
-            "filtered_count": filtered_count,
-            "has_more": has_more,
-            "next_cursor": next_cursor,
-            "user_videos": results,
-            "search_results": results,
-            "duration_ms": duration_ms,
-        }
-        logger.info(
-            "douyin_user_videos completed: account_id=%s results=%d has_more=%s duration_ms=%d",
-            account_text,
-            len(results),
-            has_more,
-            duration_ms,
-        )
-        return json.dumps(result, ensure_ascii=False)
-    except httpx.HTTPStatusError as exc:
-        text = exc.response.text[:1000]
-        logger.error(
-            "douyin_user_videos HTTP error: account_id=%s status=%d",
-            account_text,
-            exc.response.status_code,
-        )
-        return _error_result(f"HTTP {exc.response.status_code}: {text}")
-    except httpx.TimeoutException:
-        return _error_result(f"请求超时({request_timeout}秒)")
-    except httpx.RequestError as exc:
-        return _error_result(f"网络错误: {exc}")
-    except Exception as exc:
-        logger.error(
-            "douyin_user_videos unexpected error: account_id=%s error=%s",
-            account_text,
-            exc,
-            exc_info=True,
-        )
-        return _error_result(f"未知错误: {exc}")
+    """获取一页作者作品,创建搜索记录和候选记录并返回对应数据库 ID。"""
+    result = await _douyin_user_videos_raw(
+        account_id=account_id,
+        sort_type=sort_type,
+        cursor=cursor,
+        min_duration_seconds=min_duration_seconds,
+        timeout=timeout,
+    )
+    return await asyncio.to_thread(
+        persist_search_payload,
+        result,
+        run_id=run_id,
+        keyword=f"author:{account_id}",
+        query_reason=query_reason,
+        source_type="author",
+        source_value=source_value or account_id,
+        parent_search_id=parent_search_id,
+        cursor=cursor,
+        page_no=page_no,
+        provider="internal_blogger",
+        sort_type=sort_type,
+    )

+ 19 - 0
agents/find_agent/tools/get_account_fans_portrait.py

@@ -0,0 +1,19 @@
+"""获取抖音账号粉丝画像。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.portrait import (
+    get_account_fans_portrait as _get_account_fans_portrait,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _get_account_fans_portrait,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+async def get_account_fans_portrait(*args: Any, **kwargs: Any) -> str:
+    return await _get_account_fans_portrait(*args, **kwargs)

+ 19 - 0
agents/find_agent/tools/get_content_fans_portrait.py

@@ -0,0 +1,19 @@
+"""获取抖音内容点赞用户画像。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.portrait import (
+    get_content_fans_portrait as _get_content_fans_portrait,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _get_content_fans_portrait,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+async def get_content_fans_portrait(*args: Any, **kwargs: Any) -> str:
+    return await _get_content_fans_portrait(*args, **kwargs)

+ 19 - 0
agents/find_agent/tools/normalize_age_portraits.py

@@ -0,0 +1,19 @@
+"""标准化视频与作者年龄画像。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.age_portrait import (
+    normalize_age_portraits as _normalize_age_portraits,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _normalize_age_portraits,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+def normalize_age_portraits(*args: Any, **kwargs: Any) -> str:
+    return _normalize_age_portraits(*args, **kwargs)

+ 19 - 0
agents/find_agent/tools/query_video_discovery_state.py

@@ -0,0 +1,19 @@
+"""查询视频发现运行状态。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.video_discovery import (
+    query_video_discovery_state as _query_video_discovery_state,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _query_video_discovery_state,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+def query_video_discovery_state(*args: Any, **kwargs: Any) -> str:
+    return _query_video_discovery_state(*args, **kwargs)

+ 19 - 0
agents/find_agent/tools/update_video_discovery_run_status.py

@@ -0,0 +1,19 @@
+"""更新视频发现运行状态。"""
+from __future__ import annotations
+
+from functools import wraps
+from typing import Any
+
+from agents.find_agent.support.video_discovery import (
+    update_video_discovery_run_status as _update_video_discovery_run_status,
+)
+from supply_agent.tools import tool
+
+
+@tool
+@wraps(
+    _update_video_discovery_run_status,
+    assigned=("__name__", "__qualname__", "__doc__", "__annotations__"),
+)
+def update_video_discovery_run_status(*args: Any, **kwargs: Any) -> str:
+    return _update_video_discovery_run_status(*args, **kwargs)

+ 0 - 572
agents/find_agent/tools/video_discovery_store.py

@@ -1,572 +0,0 @@
-"""持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
-from __future__ import annotations
-
-import hashlib
-import json
-import logging
-import uuid
-from decimal import Decimal
-from typing import Any
-
-from agents.find_agent.tools.decision_support import (
-    audit_video_discovery_process,
-)
-from supply_agent.tools import tool
-from supply_infra.services.video_discovery_service import (
-    RunNotFoundError,
-    format_db_error,
-    get_video_discovery_service,
-)
-
-logger = logging.getLogger(__name__)
-
-_RUN_STATUSES = {"running", "finished", "failed"}
-_FINAL_DECISION_BUCKETS = {"primary", "rejected"}
-_SOURCE_TYPES = {
-    "demand",
-    "seed",
-    "point",
-    "tag",
-    "author",
-    "pagination",
-    "mixed",
-}
-_ROOT_SOURCE_TYPES = {"demand", "seed", "point", "mixed"}
-
-
-def _json(value: Any) -> str:
-    return json.dumps(value, ensure_ascii=False, default=str)
-
-
-def _input_error(message: str) -> str:
-    return _json({"error": message, "input_error": True})
-
-
-def _load_json(value: str | None, default: Any) -> Any:
-    if not value:
-        return default
-    try:
-        return json.loads(value)
-    except (TypeError, ValueError):
-        return default
-
-
-def _clean_text(value: Any, *, max_length: int | None = None) -> str | None:
-    if value is None:
-        return None
-    text = str(value).strip()
-    if not text:
-        return None
-    return text[:max_length] if max_length else text
-
-
-def _nonnegative_int(value: Any) -> int | None:
-    if value is None or value == "":
-        return None
-    try:
-        return max(0, int(value))
-    except (TypeError, ValueError):
-        return None
-
-
-def _optional_decimal(value: Any, places: int) -> Decimal | None:
-    if value is None or value == "":
-        return None
-    try:
-        number = Decimal(str(value))
-    except (ArithmeticError, TypeError, ValueError):
-        return None
-    quantum = Decimal(1).scaleb(-places)
-    return number.quantize(quantum)
-
-
-def _search_key(values: dict[str, Any]) -> str:
-    identity = {
-        key: values.get(key)
-        for key in (
-            "provider",
-            "keyword",
-            "content_type",
-            "sort_type",
-            "publish_time",
-            "cursor",
-        )
-    }
-    raw = json.dumps(identity, ensure_ascii=False, sort_keys=True)
-    return hashlib.sha256(raw.encode("utf-8")).hexdigest()
-
-
-def _candidate_from_search_result(item: dict[str, Any], keyword: str) -> dict[str, Any] | None:
-    aweme_id = _clean_text(item.get("aweme_id") or item.get("content_id"), max_length=64)
-    if not aweme_id:
-        return None
-
-    author = item.get("author") if isinstance(item.get("author"), dict) else {}
-    stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
-    topics = item.get("topics") if isinstance(item.get("topics"), list) else []
-    return {
-        "aweme_id": aweme_id,
-        "title": _clean_text(item.get("desc") or item.get("title"), max_length=512),
-        "content_link": _clean_text(
-            item.get("url") or item.get("content_link"), max_length=1024
-        ),
-        "author_name": _clean_text(
-            author.get("nickname") or item.get("author_name"), max_length=256
-        ),
-        "author_sec_uid": _clean_text(
-            author.get("sec_uid") or item.get("author_sec_uid"), max_length=256
-        ),
-        "like_count": _nonnegative_int(
-            stats.get("digg_count") or item.get("like_count")
-        ),
-        "comment_count": _nonnegative_int(
-            stats.get("comment_count") or item.get("comment_count")
-        ),
-        "share_count": _nonnegative_int(
-            stats.get("share_count") or item.get("share_count")
-        ),
-        "collect_count": _nonnegative_int(
-            stats.get("collect_count") or item.get("collect_count")
-        ),
-        "play_count": _nonnegative_int(
-            stats.get("play_count") or item.get("play_count")
-        ),
-        "tags_json": _json(topics) if topics else None,
-        "_source_keyword": keyword,
-    }
-
-
-@tool
-def create_video_discovery_run(
-    demand_word: str,
-    seed_video_title: str,
-    relevant_points: list[dict[str, Any]],
-    seed_video_id: str | None = None,
-    demand_grade_id: int | None = None,
-    intent_summary: str | None = None,
-    run_id: str | None = None,
-) -> str:
-    """
-    创建一次可追踪的视频发现运行。
-
-    若用户消息已提供预创建 run_id,必须原样传入 run_id;工具会复用已有记录,
-    不会重复创建。
-
-    Args:
-        demand_word: 用户给定需求词;它是输入语义,不强制作为实际搜索词。
-        seed_video_title: 与需求相关的参考视频标题。
-        relevant_points: 参考视频中与需求相关的点位对象列表。
-        seed_video_id: 可选参考视频 id。
-        demand_grade_id: 可选 demand_grade.id。
-        intent_summary: Agent 对真正受欢迎内容的初步解释,可稍后更新。
-        run_id: 系统预创建的运行 id;传入已存在记录时直接复用。
-
-    Returns:
-        JSON,包含后续存储工具必须使用的 run_id。
-    """
-    service = get_video_discovery_service()
-    cleaned_run_id = _clean_text(run_id, max_length=64)
-    if cleaned_run_id:
-        try:
-            existing = service.lookup_run(cleaned_run_id)
-            if existing is not None:
-                return _json(
-                    {
-                        "title": "视频发现运行已存在",
-                        "run_id": cleaned_run_id,
-                        "status": existing["status"],
-                        "pre_created": True,
-                        "output": f"run_id={cleaned_run_id}",
-                    }
-                )
-        except Exception as exc:
-            logger.error("create_video_discovery_run lookup failed: %s", exc, exc_info=True)
-            return _json({"error": format_db_error(exc), "title": "查询视频发现运行失败"})
-
-    demand = _clean_text(demand_word, max_length=256)
-    if not demand:
-        return _input_error("demand_word 不能为空")
-
-    new_run_id = cleaned_run_id or uuid.uuid4().hex
-    values = {
-        "run_id": new_run_id,
-        "demand_grade_id": demand_grade_id,
-        "demand_word": demand,
-        "seed_video_id": _clean_text(seed_video_id, max_length=64),
-        "seed_video_title": _clean_text(seed_video_title, max_length=512),
-        "relevant_points_json": _json(relevant_points or []),
-        "intent_summary": _clean_text(intent_summary),
-        "status": "running",
-    }
-    try:
-        created = service.create_run(values)
-        return _json(
-            {
-                "title": "视频发现运行已创建",
-                "run_id": created["run_id"],
-                "status": created["status"],
-                "output": f"run_id={created['run_id']}",
-            }
-        )
-    except Exception as exc:
-        logger.error("create_video_discovery_run failed: %s", exc, exc_info=True)
-        return _json({"error": format_db_error(exc), "title": "创建视频发现运行失败"})
-
-
-@tool
-def record_video_search_page(
-    run_id: str,
-    keyword: str,
-    query_reason: str,
-    source_type: str,
-    results: list[dict[str, Any]],
-    cursor: str = "0",
-    page_no: int = 1,
-    has_more: bool = False,
-    next_cursor: str | None = None,
-    source_value: str | None = None,
-    parent_search_id: int | None = None,
-    provider: str = "internal_keyword",
-    provider_state: dict[str, Any] | None = None,
-    content_type: str = "视频",
-    sort_type: str = "综合排序",
-    publish_time: str = "不限",
-    error_message: str | None = None,
-) -> str:
-    """
-    保存一次关键词搜索页,并把该页视频幂等并入候选集。
-
-    每次 douyin_search 后调用。关键词可来自需求语义、参考标题、点位、优质视频标签,
-    或前一页的 next_cursor;source_type 用于保留扩展来源。
-
-    Args:
-        run_id: create_video_discovery_run 返回值。
-        keyword: Agent 本次自主确定的实际搜索词。
-        query_reason: 该词验证的内容假设。
-        source_type: demand / seed / point / tag / author / pagination / mixed。
-        results: douyin_search.search_results 数组。
-        cursor / page_no / has_more / next_cursor: 本页翻页状态。
-        source_value: 触发扩展的点位、标签或父关键词。
-        parent_search_id: 标签扩展或翻页对应的父搜索记录。
-        provider: internal_keyword / tikhub / internal_blogger 等来源标识。
-        provider_state: 来源特有的分页状态,如 TikHub 的 search_id/backtrace。
-        content_type / sort_type / publish_time: 原样保存搜索条件。
-        error_message: 搜索失败时保存错误;results 可为空。
-    """
-    run_text = _clean_text(run_id, max_length=64)
-    keyword_text = _clean_text(keyword, max_length=256)
-    reason_text = _clean_text(query_reason)
-    if not run_text or not keyword_text or not reason_text:
-        return _input_error("run_id、keyword、query_reason 不能为空")
-    if source_type not in _SOURCE_TYPES:
-        return _input_error(f"source_type 必须是: {sorted(_SOURCE_TYPES)}")
-
-    normalized_page_no = max(1, int(page_no))
-    normalized_source_type = (
-        "pagination" if normalized_page_no > 1 else source_type
-    )
-    normalized_parent_search_id = (
-        None
-        if normalized_source_type in _ROOT_SOURCE_TYPES
-        else parent_search_id
-    )
-
-    candidate_rows = [
-        row
-        for item in results or []
-        if isinstance(item, dict)
-        if (row := _candidate_from_search_result(dict(item), keyword_text)) is not None
-    ]
-    search_values: dict[str, Any] = {
-        "run_id": run_text,
-        "keyword": keyword_text,
-        "query_reason": reason_text,
-        "source_type": normalized_source_type,
-        "source_value": _clean_text(source_value),
-        "parent_search_id": normalized_parent_search_id,
-        "provider": _clean_text(provider, max_length=32) or "internal_keyword",
-        "provider_state_json": _json(provider_state) if provider_state else None,
-        "content_type": _clean_text(content_type, max_length=16) or "视频",
-        "sort_type": _clean_text(sort_type, max_length=32) or "综合排序",
-        "publish_time": _clean_text(publish_time, max_length=32) or "不限",
-        "cursor": _clean_text(cursor, max_length=128) or "0",
-        "page_no": normalized_page_no,
-        "results_count": len(results or []),
-        "new_candidate_count": 0,
-        "has_more": int(bool(has_more)),
-        "next_cursor": _clean_text(next_cursor, max_length=128),
-        "result_ids_json": None,
-        "status": "failed" if error_message else "success",
-        "error_message": _clean_text(error_message),
-    }
-    search_values["search_key"] = _search_key(search_values)
-
-    try:
-        saved = get_video_discovery_service().save_search_page(
-            run_text,
-            search_values,
-            candidate_rows,
-        )
-        payload = {
-            "title": "搜索页已保存",
-            **saved,
-            "output": (
-                f"search_id={saved['search_id']},本页 {saved['results_count']} 条,"
-                f"新增候选 {saved['new_candidate_count']} 条"
-            ),
-        }
-        return _json(payload)
-    except RunNotFoundError as exc:
-        return _input_error(str(exc))
-    except Exception as exc:
-        logger.error("record_video_search_page failed: %s", exc, exc_info=True)
-        return _json({"error": format_db_error(exc), "title": "保存搜索页失败"})
-
-
-def _normalize_evaluation(item: dict[str, Any]) -> dict[str, Any]:
-    aweme_id = _clean_text(item.get("aweme_id"), max_length=64)
-    if not aweme_id:
-        raise ValueError("aweme_id 不能为空")
-
-    content_age = item.get("content_age_evidence")
-    account_age = item.get("account_age_evidence")
-    age_normalization = item.get("age_normalization")
-    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"))
-    age_portraits_normalized = bool(item.get("age_portraits_normalized"))
-    decision_bucket = (
-        _clean_text(item.get("decision_bucket"), max_length=24)
-        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),
-        "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 [],
-        "source_search_ids_json": item.get("source_search_ids") or [],
-        "tags_json": item.get("tags") or [],
-        "hit_points_json": item.get("hit_points") or [],
-        "play_count": _nonnegative_int(item.get("play_count")),
-        "like_count": _nonnegative_int(item.get("like_count")),
-        "comment_count": _nonnegative_int(item.get("comment_count")),
-        "collect_count": _nonnegative_int(item.get("collect_count")),
-        "share_count": _nonnegative_int(item.get("share_count")),
-        "publish_timestamp": _nonnegative_int(item.get("publish_timestamp")),
-        "content_age_evidence_json": (
-            _json(content_age) if content_age is not None else None
-        ),
-        "account_age_evidence_json": (
-            _json(account_age) if account_age is not None else None
-        ),
-        "age_normalization_json": (
-            _json(age_normalization) if age_normalization is not None else None
-        ),
-        "detail_verified": int(detail_verified),
-        "content_portrait_attempted": int(content_portrait_attempted),
-        "account_portrait_attempted": int(account_portrait_attempted),
-        "age_portraits_normalized": int(age_portraits_normalized),
-        "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),
-        "share_score": _optional_decimal(item.get("share_score"), 6),
-        "value_score": _optional_decimal(item.get("value_score"), 2),
-        "confidence": _clean_text(item.get("confidence"), max_length=16),
-        "relevance_reason": _clean_text(item.get("relevance_reason")),
-        "elder_reason": _clean_text(item.get("elder_reason")),
-        "share_reason": _clean_text(item.get("share_reason")),
-        "decision_reason": _clean_text(item.get("decision_reason")),
-        "decision_bucket": decision_bucket,
-    }
-    return mapping
-
-
-@tool
-def batch_save_video_candidate_evaluations(
-    run_id: str,
-    items: list[dict[str, Any]],
-    run_status: str = "running",
-    intent_summary: str | None = None,
-    stop_reason: str | None = None,
-) -> str:
-    """
-    原样保存 Agent 给出的候选详情、证据、评分和分池。
-
-    本工具不重算 R/E/S/V,不执行画像证据上限,也不根据阈值修改
-    decision_bucket。分数按 Agent 提供的原始值写入(`0~1` 小数)。
-    最终分池只接受 primary / rejected。
-
-    Args:
-        run_id: 发现运行 id。
-        items: 候选数组。每项至少包含 aweme_id,并由 Agent 直接提供
-            decision_bucket。其余详情、证据、评分和理由按模型输出原样保存。
-        run_status: running / finished / failed。
-        intent_summary: 对目标内容的最终解释。
-        stop_reason: 完成或失败时的停止依据。
-    """
-    run_text = _clean_text(run_id, max_length=64)
-    if not run_text:
-        return _input_error("run_id 不能为空")
-    if run_status not in _RUN_STATUSES:
-        return _input_error(
-            f"run_status 必须是: {sorted(_RUN_STATUSES)}"
-        )
-
-    rows: list[dict[str, Any]] = []
-    errors: list[str] = []
-    for index, item in enumerate(items or []):
-        if not isinstance(item, dict):
-            errors.append(f"[{index}] 不是对象")
-            continue
-        try:
-            rows.append(_normalize_evaluation(dict(item)))
-        except ValueError as exc:
-            errors.append(f"[{index}] {exc}")
-
-    try:
-        saved = get_video_discovery_service().save_evaluations_and_finish(
-            run_text,
-            rows,
-            status=run_status,
-            intent_summary=_clean_text(intent_summary),
-            stop_reason=_clean_text(stop_reason),
-        )
-        payload = {
-            "title": "候选评估已保存",
-            "run_id": run_text,
-            "saved_count": saved["saved_count"],
-            "error_count": len(errors),
-            "errors": errors,
-            "input_error": bool(errors and not rows),
-            "audit_relevant_changed": saved["audit_relevant_changed"],
-            "status": saved["status"],
-            "search_count": saved["search_count"],
-            "primary_count": saved["primary_count"],
-            "output": (
-                f"保存 {saved['saved_count']} 条;主推荐 {saved['primary_count']} 条;"
-                f"状态 {saved['status']}"
-            ),
-        }
-        return _json(payload)
-    except RunNotFoundError as exc:
-        return _input_error(str(exc))
-    except Exception as exc:
-        logger.error(
-            "batch_save_video_candidate_evaluations failed: %s", exc, exc_info=True
-        )
-        return _json({"error": format_db_error(exc), "title": "保存候选评估失败"})
-
-
-@tool
-def query_video_discovery_state(
-    run_id: str,
-    include_rejected: bool = True,
-    limit: int = 100,
-) -> str:
-    """
-    查询一次运行已经保存的搜索轨迹、主推荐与淘汰候选。
-
-    用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
-    """
-    run_text = _clean_text(run_id, max_length=64)
-    if not run_text:
-        return _json({"error": "run_id 不能为空"})
-    try:
-        state = get_video_discovery_service().get_full_state(
-            run_text,
-            include_rejected=include_rejected,
-            limit=limit,
-        )
-        run = state["run"]
-        payload = {
-            "title": f"视频发现状态: {run_text}",
-            "run": run,
-            "searches": state["searches"],
-            "candidates": state["candidates"],
-            "output": (
-                f"搜索页 {run['search_count']};主推荐 {run['primary_count']}"
-            ),
-        }
-        return _json(payload)
-    except RunNotFoundError:
-        return _json({"error": f"run_id 不存在: {run_text}"})
-    except Exception as exc:
-        logger.error("query_video_discovery_state failed: %s", exc, exc_info=True)
-        return _json({"error": format_db_error(exc), "title": "查询视频发现状态失败"})
-
-
-@tool
-def audit_video_discovery_run(
-    run_id: str,
-    intended_status: str = "finished",
-) -> str:
-    """
-    直接从数据库读取一次发现运行的完整状态并执行结束审计。
-
-    相比把 query_video_discovery_state 的大量 searches/candidates 再复制给审计工具,
-    本工具只需要 run_id,可避免长参数截断或 malformed function call。数据库可用时
-    应优先使用本工具;数据库不可用的降级流程仍使用 audit_video_discovery_process。
-
-    Args:
-        run_id: create_video_discovery_run 返回的运行 id。
-        intended_status: 准备结束时传 finished。
-
-    Returns:
-        JSON,包含 can_finish、critical_violations、warnings、coverage 和持久化状态。
-    """
-    run_text = _clean_text(run_id, max_length=64)
-    if not run_text:
-        return _input_error("run_id 不能为空")
-    if intended_status not in _RUN_STATUSES:
-        return _input_error(
-            f"intended_status 必须是: {sorted(_RUN_STATUSES)}"
-        )
-
-    try:
-        snapshot = get_video_discovery_service().get_audit_snapshot(run_text)
-        persisted_run = snapshot["persisted_run"]
-        result = _load_json(
-            audit_video_discovery_process(
-                searches=snapshot["searches"],
-                candidates=snapshot["candidates"],
-                intended_status=intended_status,
-            ),
-            {},
-        )
-        if not result:
-            return _json({"error": "审计工具返回了无效结果"})
-        result.update(
-            {
-                "run_id": run_text,
-                "persisted_status": persisted_run["status"],
-                "persisted_search_count": persisted_run["search_count"],
-                "persisted_primary_count": persisted_run["primary_count"],
-            }
-        )
-        if (
-            intended_status == "finished"
-            and persisted_run["status"] != "finished"
-        ):
-            violations = list(result.get("critical_violations") or [])
-            violations.append("运行状态尚未持久化为 finished")
-            result["critical_violations"] = list(dict.fromkeys(violations))
-            result["can_finish"] = False
-        return _json(result)
-    except RunNotFoundError as exc:
-        return _input_error(str(exc))
-    except Exception as exc:
-        logger.error(
-            "audit_video_discovery_run failed: %s",
-            exc,
-            exc_info=True,
-        )
-        return _json(
-            {"error": format_db_error(exc), "title": "数据库运行审计失败"}
-        )

+ 117 - 0
alembic/versions/20260729_01_search_candidate_occurrences.py

@@ -0,0 +1,117 @@
+"""store every search and every candidate occurrence
+
+Revision ID: 20260729_01
+Revises: 20260728_01
+Create Date: 2026-07-29
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260729_01"
+down_revision: str | None = "20260728_01"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    inspector = sa.inspect(op.get_bind())
+    tables = set(inspector.get_table_names())
+    required_tables = {"video_discovery_search", "video_discovery_candidate"}
+    if not required_tables.issubset(tables):
+        # Fresh databases create these tables later from ORM metadata.
+        return
+
+    search_uniques = {
+        item.get("name")
+        for item in inspector.get_unique_constraints("video_discovery_search")
+    }
+    if "uk_video_discovery_search_key" in search_uniques:
+        op.drop_constraint(
+            "uk_video_discovery_search_key",
+            "video_discovery_search",
+            type_="unique",
+        )
+
+    candidate_uniques = {
+        item.get("name")
+        for item in inspector.get_unique_constraints("video_discovery_candidate")
+    }
+    if "uk_video_discovery_candidate_run_aweme" in candidate_uniques:
+        op.drop_constraint(
+            "uk_video_discovery_candidate_run_aweme",
+            "video_discovery_candidate",
+            type_="unique",
+        )
+
+    candidate_columns = {
+        item["name"]
+        for item in inspector.get_columns("video_discovery_candidate")
+    }
+    if "search_id" not in candidate_columns:
+        op.add_column(
+            "video_discovery_candidate",
+            sa.Column(
+                "search_id",
+                sa.BigInteger(),
+                nullable=True,
+                comment="直接关联 video_discovery_search.id;历史数据允许为空",
+            ),
+        )
+
+    candidate_indexes = {
+        item.get("name")
+        for item in inspector.get_indexes("video_discovery_candidate")
+    }
+    if "idx_video_discovery_candidate_search" not in candidate_indexes:
+        op.create_index(
+            "idx_video_discovery_candidate_search",
+            "video_discovery_candidate",
+            ["search_id", "id"],
+        )
+
+    candidate_foreign_keys = {
+        item.get("name")
+        for item in inspector.get_foreign_keys("video_discovery_candidate")
+    }
+    if "fk_video_discovery_candidate_search" not in candidate_foreign_keys:
+        op.create_foreign_key(
+            "fk_video_discovery_candidate_search",
+            "video_discovery_candidate",
+            "video_discovery_search",
+            ["search_id"],
+            ["id"],
+            ondelete="RESTRICT",
+        )
+
+
+def downgrade() -> None:
+    inspector = sa.inspect(op.get_bind())
+    tables = set(inspector.get_table_names())
+    required_tables = {"video_discovery_search", "video_discovery_candidate"}
+    if not required_tables.issubset(tables):
+        return
+
+    op.drop_constraint(
+        "fk_video_discovery_candidate_search",
+        "video_discovery_candidate",
+        type_="foreignkey",
+    )
+    op.drop_index(
+        "idx_video_discovery_candidate_search",
+        table_name="video_discovery_candidate",
+    )
+    op.drop_column("video_discovery_candidate", "search_id")
+    op.create_unique_constraint(
+        "uk_video_discovery_candidate_run_aweme",
+        "video_discovery_candidate",
+        ["run_id", "aweme_id"],
+    )
+    op.create_unique_constraint(
+        "uk_video_discovery_search_key",
+        "video_discovery_search",
+        ["run_id", "search_key"],
+    )

+ 95 - 0
alembic/versions/20260729_02_remove_candidate_audit_fields.py

@@ -0,0 +1,95 @@
+"""remove unused candidate audit and explanation fields
+
+Revision ID: 20260729_02
+Revises: 20260729_01
+Create Date: 2026-07-29
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260729_02"
+down_revision: str | None = "20260729_01"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+_REMOVED_COLUMNS = (
+    "hit_points_json",
+    "publish_timestamp",
+    "detail_verified",
+    "content_portrait_attempted",
+    "account_portrait_attempted",
+    "age_portraits_normalized",
+    "expansion_worthy_tags_json",
+    "confidence",
+    "relevance_reason",
+    "elder_reason",
+    "share_reason",
+    "manual_review_note",
+    "manual_review_status",
+)
+
+
+def _candidate_columns() -> set[str]:
+    inspector = sa.inspect(op.get_bind())
+    if "video_discovery_candidate" not in set(inspector.get_table_names()):
+        return set()
+    return {
+        str(item["name"])
+        for item in inspector.get_columns("video_discovery_candidate")
+    }
+
+
+def upgrade() -> None:
+    columns = _candidate_columns()
+    for column_name in _REMOVED_COLUMNS:
+        if column_name in columns:
+            op.drop_column("video_discovery_candidate", column_name)
+
+
+def downgrade() -> None:
+    columns = _candidate_columns()
+    if not columns:
+        return
+
+    definitions = (
+        sa.Column("hit_points_json", sa.Text(), nullable=True),
+        sa.Column("publish_timestamp", sa.BigInteger(), nullable=True),
+        sa.Column(
+            "detail_verified",
+            sa.Integer(),
+            nullable=False,
+            server_default=sa.text("0"),
+        ),
+        sa.Column(
+            "content_portrait_attempted",
+            sa.Integer(),
+            nullable=False,
+            server_default=sa.text("0"),
+        ),
+        sa.Column(
+            "account_portrait_attempted",
+            sa.Integer(),
+            nullable=False,
+            server_default=sa.text("0"),
+        ),
+        sa.Column(
+            "age_portraits_normalized",
+            sa.Integer(),
+            nullable=False,
+            server_default=sa.text("0"),
+        ),
+        sa.Column("expansion_worthy_tags_json", sa.Text(), nullable=True),
+        sa.Column("confidence", sa.String(length=16), nullable=True),
+        sa.Column("relevance_reason", sa.Text(), nullable=True),
+        sa.Column("elder_reason", sa.Text(), nullable=True),
+        sa.Column("share_reason", sa.Text(), nullable=True),
+        sa.Column("manual_review_note", sa.Text(), nullable=True),
+        sa.Column("manual_review_status", sa.String(length=32), nullable=True),
+    )
+    for column in definitions:
+        if column.name not in columns:
+            op.add_column("video_discovery_candidate", column)

+ 53 - 0
alembic/versions/20260729_03_value_score_range_comment.py

@@ -0,0 +1,53 @@
+"""set video discovery value score comment to 0-1
+
+Revision ID: 20260729_03
+Revises: 20260729_02
+Create Date: 2026-07-29
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260729_03"
+down_revision: str | None = "20260729_02"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _has_value_score_column() -> bool:
+    inspector = sa.inspect(op.get_bind())
+    if "video_discovery_candidate" not in set(inspector.get_table_names()):
+        return False
+    return "value_score" in {
+        str(item["name"])
+        for item in inspector.get_columns("video_discovery_candidate")
+    }
+
+
+def upgrade() -> None:
+    if not _has_value_score_column():
+        return
+    op.alter_column(
+        "video_discovery_candidate",
+        "value_score",
+        existing_type=sa.Numeric(8, 2),
+        existing_nullable=True,
+        comment="联合价值 V,范围 0~1",
+        existing_comment="联合价值 V,范围 0~100",
+    )
+
+
+def downgrade() -> None:
+    if not _has_value_score_column():
+        return
+    op.alter_column(
+        "video_discovery_candidate",
+        "value_score",
+        existing_type=sa.Numeric(8, 2),
+        existing_nullable=True,
+        comment="联合价值 V,范围 0~100",
+        existing_comment="联合价值 V,范围 0~1",
+    )

+ 0 - 22
sql/video_discovery_add_audit_evidence.sql

@@ -1,22 +0,0 @@
--- 已创建 video_discovery_candidate 时执行一次。
--- MySQL 5.7+ / 8.0+
-
-ALTER TABLE `video_discovery_candidate`
-  ADD COLUMN `age_normalization_json` TEXT NULL
-    COMMENT '双侧年龄画像标准化结果 JSON'
-    AFTER `account_age_evidence_json`,
-  ADD COLUMN `detail_verified` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已核验视频详情'
-    AFTER `age_normalization_json`,
-  ADD COLUMN `content_portrait_attempted` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已尝试视频点赞画像'
-    AFTER `detail_verified`,
-  ADD COLUMN `account_portrait_attempted` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已尝试作者粉丝画像'
-    AFTER `content_portrait_attempted`,
-  ADD COLUMN `age_portraits_normalized` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已执行年龄画像标准化'
-    AFTER `account_portrait_attempted`,
-  ADD COLUMN `expansion_worthy_tags_json` TEXT NULL
-    COMMENT '值得继续搜索的标签 JSON'
-    AFTER `age_portraits_normalized`;

+ 15 - 0
sql/video_discovery_search_candidate_occurrences.sql

@@ -0,0 +1,15 @@
+-- 每次搜索新增记录;每条搜索结果新增独立候选并关联 search_id。
+-- 执行前请确认当前表仍存在以下两个旧唯一索引。
+
+ALTER TABLE `video_discovery_search`
+  DROP INDEX `uk_video_discovery_search_key`;
+
+ALTER TABLE `video_discovery_candidate`
+  DROP INDEX `uk_video_discovery_candidate_run_aweme`,
+  ADD COLUMN `search_id` BIGINT NULL
+    COMMENT '直接关联 video_discovery_search.id;历史数据允许为空'
+    AFTER `run_id`,
+  ADD INDEX `idx_video_discovery_candidate_search` (`search_id`, `id`),
+  ADD CONSTRAINT `fk_video_discovery_candidate_search`
+    FOREIGN KEY (`search_id`) REFERENCES `video_discovery_search` (`id`)
+    ON DELETE RESTRICT;

+ 8 - 19
sql/video_discovery_tables.sql

@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS `video_discovery_run` (
 CREATE TABLE IF NOT EXISTS `video_discovery_search` (
 CREATE TABLE IF NOT EXISTS `video_discovery_search` (
   `id` BIGINT NOT NULL AUTO_INCREMENT,
   `id` BIGINT NOT NULL AUTO_INCREMENT,
   `run_id` VARCHAR(64) NOT NULL COMMENT '发现运行 run_id',
   `run_id` VARCHAR(64) NOT NULL COMMENT '发现运行 run_id',
-  `search_key` VARCHAR(64) NOT NULL COMMENT '关键词/筛选/游标组合哈希',
+  `search_key` VARCHAR(64) NOT NULL COMMENT '搜索参数哈希,仅用于追踪,不作为幂等键',
   `keyword` VARCHAR(256) NOT NULL COMMENT 'Agent 自主确定的搜索词',
   `keyword` VARCHAR(256) NOT NULL COMMENT 'Agent 自主确定的搜索词',
   `query_reason` TEXT NOT NULL COMMENT '为何形成该搜索词、希望验证什么',
   `query_reason` TEXT NOT NULL COMMENT '为何形成该搜索词、希望验证什么',
   `source_type` VARCHAR(32) NOT NULL
   `source_type` VARCHAR(32) NOT NULL
@@ -62,7 +62,6 @@ CREATE TABLE IF NOT EXISTS `video_discovery_search` (
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   PRIMARY KEY (`id`),
   PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_video_discovery_search_key` (`run_id`, `search_key`),
   KEY `idx_video_discovery_search_run` (`run_id`, `id`),
   KEY `idx_video_discovery_search_run` (`run_id`, `id`),
   KEY `idx_video_discovery_search_parent` (`run_id`, `parent_search_id`),
   KEY `idx_video_discovery_search_parent` (`run_id`, `parent_search_id`),
   KEY `idx_video_discovery_search_keyword` (`keyword`)
   KEY `idx_video_discovery_search_keyword` (`keyword`)
@@ -72,6 +71,7 @@ CREATE TABLE IF NOT EXISTS `video_discovery_search` (
 CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
 CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `id` BIGINT NOT NULL AUTO_INCREMENT,
   `id` BIGINT NOT NULL AUTO_INCREMENT,
   `run_id` VARCHAR(64) NOT NULL COMMENT '发现运行 run_id',
   `run_id` VARCHAR(64) NOT NULL COMMENT '发现运行 run_id',
+  `search_id` BIGINT NULL COMMENT '直接关联 video_discovery_search.id;历史数据允许为空',
   `aweme_id` VARCHAR(64) NOT NULL COMMENT '抖音视频 id',
   `aweme_id` VARCHAR(64) NOT NULL COMMENT '抖音视频 id',
   `title` VARCHAR(512) NULL COMMENT '视频标题',
   `title` VARCHAR(512) NULL COMMENT '视频标题',
   `content_link` VARCHAR(1024) NULL COMMENT '抖音页面链接',
   `content_link` VARCHAR(1024) NULL COMMENT '抖音页面链接',
@@ -80,32 +80,18 @@ CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `source_keywords_json` TEXT NULL COMMENT '命中过该视频的搜索词 JSON',
   `source_keywords_json` TEXT NULL COMMENT '命中过该视频的搜索词 JSON',
   `source_search_ids_json` TEXT NULL COMMENT '来源搜索轨迹 id JSON',
   `source_search_ids_json` TEXT NULL COMMENT '来源搜索轨迹 id JSON',
   `tags_json` TEXT NULL COMMENT '视频标签/话题 JSON',
   `tags_json` TEXT NULL COMMENT '视频标签/话题 JSON',
-  `hit_points_json` TEXT NULL COMMENT '命中的需求相关点 JSON',
   `play_count` BIGINT NULL COMMENT '播放数快照',
   `play_count` BIGINT NULL COMMENT '播放数快照',
   `like_count` BIGINT NULL COMMENT '点赞数快照',
   `like_count` BIGINT NULL COMMENT '点赞数快照',
   `comment_count` BIGINT NULL COMMENT '评论数快照',
   `comment_count` BIGINT NULL COMMENT '评论数快照',
   `collect_count` BIGINT NULL COMMENT '收藏数快照',
   `collect_count` BIGINT NULL COMMENT '收藏数快照',
   `share_count` BIGINT NULL COMMENT '分享数快照',
   `share_count` BIGINT NULL COMMENT '分享数快照',
-  `publish_timestamp` BIGINT NULL COMMENT '发布时间戳',
   `content_age_evidence_json` TEXT NULL COMMENT '视频点赞用户年龄证据 JSON',
   `content_age_evidence_json` TEXT NULL COMMENT '视频点赞用户年龄证据 JSON',
   `account_age_evidence_json` TEXT NULL COMMENT '作者粉丝年龄证据 JSON',
   `account_age_evidence_json` TEXT NULL COMMENT '作者粉丝年龄证据 JSON',
   `age_normalization_json` TEXT NULL COMMENT '双侧年龄画像标准化结果 JSON',
   `age_normalization_json` TEXT NULL COMMENT '双侧年龄画像标准化结果 JSON',
-  `detail_verified` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已核验视频详情',
-  `content_portrait_attempted` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已尝试视频点赞画像',
-  `account_portrait_attempted` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已尝试作者粉丝画像',
-  `age_portraits_normalized` TINYINT NOT NULL DEFAULT 0
-    COMMENT '是否已执行年龄画像标准化',
-  `expansion_worthy_tags_json` TEXT NULL COMMENT '值得继续搜索的标签 JSON',
   `relevance_score` DECIMAL(8,6) NULL COMMENT 'R,范围 0~1',
   `relevance_score` DECIMAL(8,6) NULL COMMENT 'R,范围 0~1',
   `elder_score` DECIMAL(8,6) NULL COMMENT 'E,范围 0~1',
   `elder_score` DECIMAL(8,6) NULL COMMENT 'E,范围 0~1',
   `share_score` DECIMAL(8,6) NULL COMMENT 'S,范围 0~1',
   `share_score` DECIMAL(8,6) NULL COMMENT 'S,范围 0~1',
-  `value_score` DECIMAL(8,2) NULL COMMENT '联合价值 V,范围 0~100',
-  `confidence` VARCHAR(16) NULL COMMENT 'high / medium / low',
-  `relevance_reason` TEXT NULL COMMENT '需求相关性依据',
-  `elder_reason` TEXT NULL COMMENT '老年倾向依据',
-  `share_reason` TEXT NULL COMMENT '分享价值依据',
+  `value_score` DECIMAL(8,2) NULL COMMENT '联合价值 V,范围 0~1',
   `decision_reason` TEXT NULL COMMENT '最终分池依据',
   `decision_reason` TEXT NULL COMMENT '最终分池依据',
   `decision_bucket` VARCHAR(24) NOT NULL DEFAULT 'pending_evaluation'
   `decision_bucket` VARCHAR(24) NOT NULL DEFAULT 'pending_evaluation'
     COMMENT '最终为 primary / rejected;pending_evaluation 仅为过程状态',
     COMMENT '最终为 primary / rejected;pending_evaluation 仅为过程状态',
@@ -113,8 +99,11 @@ CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   PRIMARY KEY (`id`),
   PRIMARY KEY (`id`),
-  UNIQUE KEY `uk_video_discovery_candidate_run_aweme` (`run_id`, `aweme_id`),
+  KEY `idx_video_discovery_candidate_search` (`search_id`, `id`),
   KEY `idx_video_discovery_candidate_bucket` (`run_id`, `decision_bucket`),
   KEY `idx_video_discovery_candidate_bucket` (`run_id`, `decision_bucket`),
-  KEY `idx_video_discovery_candidate_author` (`author_sec_uid`)
+  KEY `idx_video_discovery_candidate_author` (`author_sec_uid`),
+  CONSTRAINT `fk_video_discovery_candidate_search`
+    FOREIGN KEY (`search_id`) REFERENCES `video_discovery_search` (`id`)
+    ON DELETE RESTRICT
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
   COMMENT='本次运行发现的视频及评估快照';
   COMMENT='本次运行发现的视频及评估快照';

+ 14 - 31
supply_infra/db/models/video_discovery.py

@@ -5,6 +5,7 @@ from decimal import Decimal
 
 
 from sqlalchemy import (
 from sqlalchemy import (
     BigInteger,
     BigInteger,
+    ForeignKey,
     Index,
     Index,
     Integer,
     Integer,
     Numeric,
     Numeric,
@@ -86,7 +87,6 @@ class VideoDiscoverySearch(Base):
 
 
     __tablename__ = "video_discovery_search"
     __tablename__ = "video_discovery_search"
     __table_args__ = (
     __table_args__ = (
-        UniqueConstraint("run_id", "search_key", name="uk_video_discovery_search_key"),
         Index("idx_video_discovery_search_run", "run_id", "id"),
         Index("idx_video_discovery_search_run", "run_id", "id"),
         Index(
         Index(
             "idx_video_discovery_search_parent",
             "idx_video_discovery_search_parent",
@@ -99,7 +99,7 @@ class VideoDiscoverySearch(Base):
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="发现运行 run_id")
     run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="发现运行 run_id")
     search_key: Mapped[str] = mapped_column(
     search_key: Mapped[str] = mapped_column(
-        String(64), nullable=False, comment="关键词/筛选/游标组合哈希"
+        String(64), nullable=False, comment="搜索参数哈希,仅用于追踪,不作为幂等键"
     )
     )
     keyword: Mapped[str] = mapped_column(
     keyword: Mapped[str] = mapped_column(
         String(256), nullable=False, comment="Agent 自主确定的搜索词"
         String(256), nullable=False, comment="Agent 自主确定的搜索词"
@@ -179,15 +179,23 @@ class VideoDiscoveryCandidate(Base):
 
 
     __tablename__ = "video_discovery_candidate"
     __tablename__ = "video_discovery_candidate"
     __table_args__ = (
     __table_args__ = (
-        UniqueConstraint(
-            "run_id", "aweme_id", name="uk_video_discovery_candidate_run_aweme"
-        ),
+        Index("idx_video_discovery_candidate_search", "search_id", "id"),
         Index("idx_video_discovery_candidate_bucket", "run_id", "decision_bucket"),
         Index("idx_video_discovery_candidate_bucket", "run_id", "decision_bucket"),
         Index("idx_video_discovery_candidate_author", "author_sec_uid"),
         Index("idx_video_discovery_candidate_author", "author_sec_uid"),
     )
     )
 
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="发现运行 run_id")
     run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="发现运行 run_id")
+    search_id: Mapped[int | None] = mapped_column(
+        BigInteger,
+        ForeignKey(
+            "video_discovery_search.id",
+            name="fk_video_discovery_candidate_search",
+            ondelete="RESTRICT",
+        ),
+        nullable=True,
+        comment="直接关联 video_discovery_search.id;历史数据允许为空",
+    )
     aweme_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="抖音视频 id")
     aweme_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="抖音视频 id")
     title: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="视频标题")
     title: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="视频标题")
     content_link: Mapped[str | None] = mapped_column(
     content_link: Mapped[str | None] = mapped_column(
@@ -208,15 +216,11 @@ class VideoDiscoveryCandidate(Base):
     tags_json: Mapped[str | None] = mapped_column(
     tags_json: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="视频标签/话题 JSON"
         Text, nullable=True, comment="视频标签/话题 JSON"
     )
     )
-    hit_points_json: Mapped[str | None] = mapped_column(
-        Text, nullable=True, comment="命中的需求相关点 JSON"
-    )
     play_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     play_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     like_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     like_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     comment_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     comment_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     collect_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     collect_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     share_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     share_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
-    publish_timestamp: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
     content_age_evidence_json: Mapped[str | None] = mapped_column(
     content_age_evidence_json: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="视频点赞用户年龄证据 JSON"
         Text, nullable=True, comment="视频点赞用户年龄证据 JSON"
     )
     )
@@ -226,21 +230,6 @@ class VideoDiscoveryCandidate(Base):
     age_normalization_json: Mapped[str | None] = mapped_column(
     age_normalization_json: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="双侧年龄画像标准化结果 JSON"
         Text, nullable=True, comment="双侧年龄画像标准化结果 JSON"
     )
     )
-    detail_verified: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已核验视频详情"
-    )
-    content_portrait_attempted: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已尝试视频点赞画像"
-    )
-    account_portrait_attempted: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已尝试作者粉丝画像"
-    )
-    age_portraits_normalized: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已执行年龄画像标准化"
-    )
-    expansion_worthy_tags_json: Mapped[str | None] = mapped_column(
-        Text, nullable=True, comment="值得继续搜索的标签 JSON"
-    )
     relevance_score: Mapped[Decimal | None] = mapped_column(
     relevance_score: Mapped[Decimal | None] = mapped_column(
         Numeric(8, 6), nullable=True, comment="R,范围 0~1"
         Numeric(8, 6), nullable=True, comment="R,范围 0~1"
     )
     )
@@ -251,14 +240,8 @@ class VideoDiscoveryCandidate(Base):
         Numeric(8, 6), nullable=True, comment="S,范围 0~1"
         Numeric(8, 6), nullable=True, comment="S,范围 0~1"
     )
     )
     value_score: Mapped[Decimal | None] = mapped_column(
     value_score: Mapped[Decimal | None] = mapped_column(
-        Numeric(8, 2), nullable=True, comment="联合价值 V,范围 0~100"
-    )
-    confidence: Mapped[str | None] = mapped_column(
-        String(16), nullable=True, comment="high / medium / low"
+        Numeric(8, 2), nullable=True, comment="联合价值 V,范围 0~1"
     )
     )
-    relevance_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
-    elder_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
-    share_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     decision_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     decision_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
     decision_bucket: Mapped[str] = mapped_column(
     decision_bucket: Mapped[str] = mapped_column(
         String(24),
         String(24),

+ 51 - 113
supply_infra/db/repositories/video_discovery_repo.py

@@ -13,19 +13,6 @@ from supply_infra.db.models.video_discovery import (
 )
 )
 from supply_infra.db.repositories.base import BaseRepository
 from supply_infra.db.repositories.base import BaseRepository
 
 
-_AUDIT_RELEVANT_CANDIDATE_FIELDS = (
-    "relevance_score",
-    "elder_score",
-    "share_score",
-    "decision_bucket",
-    "detail_verified",
-    "content_portrait_attempted",
-    "account_portrait_attempted",
-    "age_portraits_normalized",
-    "expansion_worthy_tags_json",
-)
-
-
 def _json_list(raw: str | None) -> list[Any]:
 def _json_list(raw: str | None) -> list[Any]:
     if not raw:
     if not raw:
         return []
         return []
@@ -136,142 +123,93 @@ class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
         self,
         self,
         search_values: dict[str, Any],
         search_values: dict[str, Any],
         candidate_rows: list[dict[str, Any]],
         candidate_rows: list[dict[str, Any]],
-    ) -> tuple[VideoDiscoverySearch, int]:
-        """幂等保存一个搜索页,并把页内结果并入本次运行候选集。"""
+    ) -> tuple[VideoDiscoverySearch, list[VideoDiscoveryCandidate]]:
+        """新增一个搜索页,并为本页每条结果新增独立候选记录。"""
         run_id = str(search_values["run_id"])
         run_id = str(search_values["run_id"])
-        search_key = str(search_values["search_key"])
-        stmt = select(VideoDiscoverySearch).where(
-            VideoDiscoverySearch.run_id == run_id,
-            VideoDiscoverySearch.search_key == search_key,
-        )
-        search = self.session.scalar(stmt)
-        previous_new_count = 0
-        if search is None:
-            search = VideoDiscoverySearch(**search_values)
-            self.session.add(search)
-            self.session.flush()
-        else:
-            previous_new_count = int(search.new_candidate_count or 0)
-            for key, value in search_values.items():
-                if key not in {
-                    "run_id",
-                    "search_key",
-                    "new_candidate_count",
-                    "result_ids_json",
-                }:
-                    setattr(search, key, value)
-            self.session.flush()
-
-        aweme_ids = [str(row["aweme_id"]) for row in candidate_rows if row.get("aweme_id")]
-        existing: dict[str, VideoDiscoveryCandidate] = {}
-        if aweme_ids:
-            candidate_stmt = select(VideoDiscoveryCandidate).where(
-                VideoDiscoveryCandidate.run_id == run_id,
-                VideoDiscoveryCandidate.aweme_id.in_(aweme_ids),
-            )
-            existing = {
-                str(item.aweme_id): item
-                for item in self.session.scalars(candidate_stmt).all()
-            }
+        search = VideoDiscoverySearch(**search_values)
+        self.session.add(search)
+        self.session.flush()
 
 
-        new_count = 0
-        for row in candidate_rows:
-            aweme_id = str(row.get("aweme_id") or "").strip()
+        candidates: list[VideoDiscoveryCandidate] = []
+        aweme_ids: list[str] = []
+        for raw_row in candidate_rows:
+            row = dict(raw_row)
+            aweme_id = str(row.pop("aweme_id", "") or "").strip()
             if not aweme_id:
             if not aweme_id:
                 continue
                 continue
-            entity = existing.get(aweme_id)
-            if entity is None:
-                entity = VideoDiscoveryCandidate(
-                    run_id=run_id,
-                    aweme_id=aweme_id,
-                    decision_bucket="pending_evaluation",
-                )
-                self.session.add(entity)
-                existing[aweme_id] = entity
-                new_count += 1
-
             source_keyword = row.pop("_source_keyword", None)
             source_keyword = row.pop("_source_keyword", None)
-            if source_keyword:
-                entity.source_keywords_json = _merge_json_list(
-                    entity.source_keywords_json, [source_keyword]
-                )
-            entity.source_search_ids_json = _merge_json_list(
-                entity.source_search_ids_json, [int(search.id)]
+            entity = VideoDiscoveryCandidate(
+                run_id=run_id,
+                search_id=int(search.id),
+                aweme_id=aweme_id,
+                source_keywords_json=(
+                    json.dumps([source_keyword], ensure_ascii=False)
+                    if source_keyword
+                    else None
+                ),
+                source_search_ids_json=json.dumps([int(search.id)]),
+                decision_bucket="pending_evaluation",
             )
             )
             for key, value in row.items():
             for key, value in row.items():
-                if key == "aweme_id" or value is None:
+                if value is None:
                     continue
                     continue
-                if key == "tags_json":
-                    entity.tags_json = _merge_json_list(
-                        entity.tags_json, _json_list(str(value))
-                    )
-                elif hasattr(entity, key):
+                if hasattr(entity, key):
                     setattr(entity, key, value)
                     setattr(entity, key, value)
+            self.session.add(entity)
+            candidates.append(entity)
+            aweme_ids.append(aweme_id)
 
 
-        search.new_candidate_count = previous_new_count + new_count
-        search.result_ids_json = _merge_json_list(search.result_ids_json, aweme_ids)
+        search.new_candidate_count = len(candidates)
+        search.result_ids_json = (
+            json.dumps(aweme_ids, ensure_ascii=False) if aweme_ids else None
+        )
         self.session.flush()
         self.session.flush()
         self._refresh_run_counts(run_id)
         self._refresh_run_counts(run_id)
-        return search, new_count
+        return search, candidates
 
 
-    def save_candidate_evaluations(
+    def update_candidates(
         self,
         self,
         run_id: str,
         run_id: str,
         rows: list[dict[str, Any]],
         rows: list[dict[str, Any]],
-    ) -> tuple[int, bool]:
-        """批量更新候选评估;不存在的 aweme_id 会补建。"""
-        aweme_ids = [str(row["aweme_id"]) for row in rows]
+    ) -> list[VideoDiscoveryCandidate]:
+        """严格按 candidate_id 更新候选;不新增记录、不修改运行状态。"""
+        candidate_ids = [int(row["candidate_id"]) for row in rows]
+        if len(candidate_ids) != len(set(candidate_ids)):
+            raise ValueError("candidate_id 不能重复")
+
         stmt = select(VideoDiscoveryCandidate).where(
         stmt = select(VideoDiscoveryCandidate).where(
             VideoDiscoveryCandidate.run_id == run_id,
             VideoDiscoveryCandidate.run_id == run_id,
-            VideoDiscoveryCandidate.aweme_id.in_(aweme_ids),
+            VideoDiscoveryCandidate.id.in_(candidate_ids),
         )
         )
         existing = {
         existing = {
-            str(item.aweme_id): item for item in self.session.scalars(stmt).all()
+            int(item.id): item for item in self.session.scalars(stmt).all()
         }
         }
+        missing = [candidate_id for candidate_id in candidate_ids if candidate_id not in existing]
+        if missing:
+            raise ValueError(
+                f"candidate_id 不存在或不属于 run_id={run_id}: {missing}"
+            )
 
 
-        audit_relevant_changed = False
         for row in rows:
         for row in rows:
-            aweme_id = str(row["aweme_id"])
-            entity = existing.get(aweme_id)
-            if entity is None:
-                entity = VideoDiscoveryCandidate(run_id=run_id, aweme_id=aweme_id)
-                self.session.add(entity)
-                existing[aweme_id] = entity
-                before_audit_state = None
-            else:
-                before_audit_state = tuple(
-                    getattr(entity, field)
-                    for field in _AUDIT_RELEVANT_CANDIDATE_FIELDS
-                )
-
+            candidate_id = int(row["candidate_id"])
+            entity = existing[candidate_id]
             for key, value in row.items():
             for key, value in row.items():
-                if key == "aweme_id" or value is None:
+                if key in {"candidate_id", "id", "run_id", "search_id", "aweme_id"}:
+                    continue
+                if value is None:
                     continue
                     continue
                 if key in {
                 if key in {
                     "source_keywords_json",
                     "source_keywords_json",
                     "source_search_ids_json",
                     "source_search_ids_json",
                     "tags_json",
                     "tags_json",
-                    "hit_points_json",
-                    "expansion_worthy_tags_json",
                 }:
                 }:
                     values = value if isinstance(value, list) else _json_list(str(value))
                     values = value if isinstance(value, list) else _json_list(str(value))
                     setattr(entity, key, _merge_json_list(getattr(entity, key), values))
                     setattr(entity, key, _merge_json_list(getattr(entity, key), values))
                 elif hasattr(entity, key):
                 elif hasattr(entity, key):
                     setattr(entity, key, value)
                     setattr(entity, key, value)
 
 
-            after_audit_state = tuple(
-                getattr(entity, field)
-                for field in _AUDIT_RELEVANT_CANDIDATE_FIELDS
-            )
-            if (
-                before_audit_state is None
-                or before_audit_state != after_audit_state
-            ):
-                audit_relevant_changed = True
-
         self.session.flush()
         self.session.flush()
-        self._refresh_run_counts(run_id)
-        return len(rows), audit_relevant_changed
+        return [existing[candidate_id] for candidate_id in candidate_ids]
 
 
     def finish_run(
     def finish_run(
         self,
         self,

+ 31 - 55
supply_infra/services/video_discovery_service.py

@@ -61,12 +61,17 @@ def _serialize_search(item: Any) -> dict[str, Any]:
         "parent_search_id": item.parent_search_id,
         "parent_search_id": item.parent_search_id,
         "provider": item.provider,
         "provider": item.provider,
         "provider_state": _load_json(item.provider_state_json, {}),
         "provider_state": _load_json(item.provider_state_json, {}),
+        "content_type": item.content_type,
+        "sort_type": item.sort_type,
+        "publish_time": item.publish_time,
         "cursor": item.cursor,
         "cursor": item.cursor,
         "page_no": item.page_no,
         "page_no": item.page_no,
         "results_count": item.results_count,
         "results_count": item.results_count,
         "new_candidate_count": item.new_candidate_count,
         "new_candidate_count": item.new_candidate_count,
         "has_more": bool(item.has_more),
         "has_more": bool(item.has_more),
         "next_cursor": item.next_cursor,
         "next_cursor": item.next_cursor,
+        "status": item.status,
+        "error_message": item.error_message,
     }
     }
 
 
 
 
@@ -77,6 +82,8 @@ def _serialize_candidate(item: Any) -> dict[str, Any]:
         else item.decision_bucket
         else item.decision_bucket
     )
     )
     return {
     return {
+        "candidate_id": int(item.id),
+        "search_id": int(item.search_id) if item.search_id is not None else None,
         "aweme_id": item.aweme_id,
         "aweme_id": item.aweme_id,
         "title": item.title,
         "title": item.title,
         "content_link": item.content_link,
         "content_link": item.content_link,
@@ -85,7 +92,6 @@ def _serialize_candidate(item: Any) -> dict[str, Any]:
         "source_keywords": _load_json(item.source_keywords_json, []),
         "source_keywords": _load_json(item.source_keywords_json, []),
         "source_search_ids": _load_json(item.source_search_ids_json, []),
         "source_search_ids": _load_json(item.source_search_ids_json, []),
         "tags": _load_json(item.tags_json, []),
         "tags": _load_json(item.tags_json, []),
-        "hit_points": _load_json(item.hit_points_json, []),
         "play_count": item.play_count,
         "play_count": item.play_count,
         "like_count": item.like_count,
         "like_count": item.like_count,
         "comment_count": item.comment_count,
         "comment_count": item.comment_count,
@@ -97,19 +103,10 @@ def _serialize_candidate(item: Any) -> dict[str, Any]:
         "elder_score": float(item.elder_score) if item.elder_score is not None else None,
         "elder_score": float(item.elder_score) if item.elder_score is not None else None,
         "share_score": float(item.share_score) if item.share_score is not None else None,
         "share_score": float(item.share_score) if item.share_score is not None else None,
         "value_score": float(item.value_score) if item.value_score is not None else None,
         "value_score": float(item.value_score) if item.value_score is not None else None,
-        "confidence": item.confidence,
         "decision_bucket": decision_bucket,
         "decision_bucket": decision_bucket,
         "content_age_evidence": _load_json(item.content_age_evidence_json, {}),
         "content_age_evidence": _load_json(item.content_age_evidence_json, {}),
         "account_age_evidence": _load_json(item.account_age_evidence_json, {}),
         "account_age_evidence": _load_json(item.account_age_evidence_json, {}),
         "age_normalization": _load_json(item.age_normalization_json, {}),
         "age_normalization": _load_json(item.age_normalization_json, {}),
-        "detail_verified": bool(item.detail_verified),
-        "content_portrait_attempted": bool(item.content_portrait_attempted),
-        "account_portrait_attempted": bool(item.account_portrait_attempted),
-        "age_portraits_normalized": bool(item.age_portraits_normalized),
-        "expansion_worthy_tags": _load_json(item.expansion_worthy_tags_json, []),
-        "relevance_reason": item.relevance_reason,
-        "elder_reason": item.elder_reason,
-        "share_reason": item.share_reason,
         "decision_reason": item.decision_reason,
         "decision_reason": item.decision_reason,
     }
     }
 
 
@@ -160,24 +157,36 @@ class VideoDiscoveryService:
             repo = VideoDiscoveryRepository(session)
             repo = VideoDiscoveryRepository(session)
             if repo.get_run(run_id) is None:
             if repo.get_run(run_id) is None:
                 raise RunNotFoundError(f"run_id 不存在: {run_id}")
                 raise RunNotFoundError(f"run_id 不存在: {run_id}")
-            search, new_count = repo.save_search_page(search_values, candidate_rows)
+            search, candidates = repo.save_search_page(search_values, candidate_rows)
             return {
             return {
-                "search_id": int(search.id),
+                **_serialize_search(search),
                 "run_id": run_id,
                 "run_id": run_id,
-                "keyword": search.keyword,
-                "source_type": search.source_type,
-                "parent_search_id": search.parent_search_id,
-                "page_no": search.page_no,
-                "results_count": search.results_count,
-                "new_candidate_count": new_count,
-                "has_more": bool(search.has_more),
-                "next_cursor": search.next_cursor,
+                "new_candidate_count": len(candidates),
+                "candidates": [
+                    _serialize_candidate(candidate) for candidate in candidates
+                ],
             }
             }
 
 
-    def save_evaluations_and_finish(
+    def update_candidates(
         self,
         self,
         run_id: str,
         run_id: str,
         rows: list[dict[str, Any]],
         rows: list[dict[str, Any]],
+    ) -> dict[str, Any]:
+        with get_session() as session:
+            repo = VideoDiscoveryRepository(session)
+            if repo.get_run(run_id) is None:
+                raise RunNotFoundError(f"run_id 不存在: {run_id}")
+            candidates = repo.update_candidates(run_id, rows)
+            return {
+                "updated_count": len(candidates),
+                "candidates": [
+                    _serialize_candidate(candidate) for candidate in candidates
+                ],
+            }
+
+    def update_run_status(
+        self,
+        run_id: str,
         *,
         *,
         status: str,
         status: str,
         intent_summary: str | None = None,
         intent_summary: str | None = None,
@@ -187,24 +196,13 @@ class VideoDiscoveryService:
             repo = VideoDiscoveryRepository(session)
             repo = VideoDiscoveryRepository(session)
             if repo.get_run(run_id) is None:
             if repo.get_run(run_id) is None:
                 raise RunNotFoundError(f"run_id 不存在: {run_id}")
                 raise RunNotFoundError(f"run_id 不存在: {run_id}")
-            if rows:
-                saved, audit_relevant_changed = repo.save_candidate_evaluations(
-                    run_id, rows
-                )
-            else:
-                saved, audit_relevant_changed = 0, False
             run = repo.finish_run(
             run = repo.finish_run(
                 run_id,
                 run_id,
                 status=status,
                 status=status,
                 intent_summary=intent_summary,
                 intent_summary=intent_summary,
                 stop_reason=stop_reason,
                 stop_reason=stop_reason,
             )
             )
-            snapshot = _serialize_run(run)
-            return {
-                "saved_count": saved,
-                "audit_relevant_changed": audit_relevant_changed,
-                **snapshot,
-            }
+            return _serialize_run(run)
 
 
     def get_full_state(
     def get_full_state(
         self,
         self,
@@ -233,28 +231,6 @@ class VideoDiscoveryService:
                 "candidates": [_serialize_candidate(item) for item in candidates],
                 "candidates": [_serialize_candidate(item) for item in candidates],
             }
             }
 
 
-    def get_audit_snapshot(self, run_id: str) -> dict[str, Any]:
-        with get_session() as session:
-            repo = VideoDiscoveryRepository(session)
-            run = repo.get_run(run_id)
-            if run is None:
-                raise RunNotFoundError(f"run_id 不存在: {run_id}")
-            return {
-                "persisted_run": {
-                    "status": run.status,
-                    "search_count": int(run.search_count or 0),
-                    "primary_count": int(run.primary_count or 0),
-                },
-                "searches": [
-                    _serialize_search(item)
-                    for item in repo.list_searches(run_id)
-                ],
-                "candidates": [
-                    _serialize_candidate(item)
-                    for item in repo.list_candidates(run_id, limit=500)
-                ],
-            }
-
     def prepare_scheduled_run(
     def prepare_scheduled_run(
         self,
         self,
         *,
         *,

+ 310 - 2
tests/supply_infra/scheduler/test_discover_videos_from_demands.py

@@ -1,12 +1,13 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import importlib
 import json
 import json
 from contextlib import contextmanager
 from contextlib import contextmanager
 from unittest.mock import patch
 from unittest.mock import patch
 
 
 import pytest
 import pytest
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, event, select
 from sqlalchemy.orm import Session, sessionmaker
 from sqlalchemy.orm import Session, sessionmaker
 
 
 from agents.find_agent import create_find_agent
 from agents.find_agent import create_find_agent
@@ -15,12 +16,18 @@ from agents.find_agent.demand_run import (
     FindDemandContext,
     FindDemandContext,
     FindDemandPoint,
     FindDemandPoint,
     FindDemandVideo,
     FindDemandVideo,
+    build_find_agent_user_input,
     prepare_video_discovery_run,
     prepare_video_discovery_run,
 )
 )
 from agents.find_agent.tools import video_discovery_store
 from agents.find_agent.tools import video_discovery_store
+from agents.find_agent.tools.batch_search_and_record import batch_search_and_record
 from supply_infra.db.models.video_discovery import (
 from supply_infra.db.models.video_discovery import (
     VideoDiscoveryCandidate,
     VideoDiscoveryCandidate,
     VideoDiscoveryRun,
     VideoDiscoveryRun,
+    VideoDiscoverySearch,
+)
+from supply_infra.db.repositories.video_discovery_repo import (
+    VideoDiscoveryRepository,
 )
 )
 from supply_infra.scheduler.jobs.discover_videos_from_demands import (
 from supply_infra.scheduler.jobs.discover_videos_from_demands import (
     discover_videos_from_demands,
     discover_videos_from_demands,
@@ -205,17 +212,318 @@ def test_video_discovery_models_exclude_unused_columns() -> None:
         "video_url",
         "video_url",
         "content_analysis",
         "content_analysis",
         "content_analysis_verified",
         "content_analysis_verified",
+        "hit_points_json",
+        "publish_timestamp",
+        "detail_verified",
+        "content_portrait_attempted",
+        "account_portrait_attempted",
+        "age_portraits_normalized",
+        "expansion_worthy_tags_json",
+        "confidence",
+        "relevance_reason",
+        "elder_reason",
+        "share_reason",
+        "manual_review_note",
+        "manual_review_status",
     }.isdisjoint(candidate_columns)
     }.isdisjoint(candidate_columns)
+    assert "search_id" in candidate_columns
+    candidate_constraints = {
+        constraint.name
+        for constraint in VideoDiscoveryCandidate.__table__.constraints
+    }
+    search_constraints = {
+        constraint.name
+        for constraint in VideoDiscoverySearch.__table__.constraints
+    }
+    assert "uk_video_discovery_candidate_run_aweme" not in candidate_constraints
+    assert "fk_video_discovery_candidate_search" in candidate_constraints
+    assert "uk_video_discovery_search_key" not in search_constraints
 
 
 
 
 def test_create_find_agent_registers_discovery_tools() -> None:
 def test_create_find_agent_registers_discovery_tools() -> None:
     agent = create_find_agent()
     agent = create_find_agent()
 
 
     assert agent.name == "find_agent"
     assert agent.name == "find_agent"
-    assert "audit_video_discovery_run" in agent.tools.list_tools()
+    assert "batch_search_and_record" in agent.tools.list_tools()
+    assert "batch_update_video_discovery_candidates" in agent.tools.list_tools()
+    assert "update_video_discovery_run_status" in agent.tools.list_tools()
+    assert "create_video_discovery_run" not in agent.tools.list_tools()
+    assert "batch_save_video_candidate_evaluations" not in agent.tools.list_tools()
+    assert "audit_video_discovery_run" not in agent.tools.list_tools()
     assert "query_video_discovery_state" in agent.tools.list_tools()
     assert "query_video_discovery_state" in agent.tools.list_tools()
 
 
 
 
+def test_find_agent_input_uses_reference_videos_without_seed_fields() -> None:
+    ctx = FindDemandContext(
+        biz_dt="20260729",
+        demand_grade_id=101,
+        demand_name="广场舞",
+        grade="S",
+        videos=[
+            FindDemandVideo(
+                video_id="vid-1",
+                title="参考标题",
+                points=[FindDemandPoint(point="动作简单", point_type="key")],
+            )
+        ],
+    )
+
+    user_input = build_find_agent_user_input(ctx, "scheduled-run")
+
+    assert "seed_video_id:" not in user_input
+    assert "seed_video_title:" not in user_input
+    assert "reference_videos:" in user_input
+    assert '"video_id": "vid-1"' in user_input
+    assert '"title": "参考标题"' in user_input
+    assert "create_video_discovery_run" not in user_input
+    assert "relevant_points" not in user_input
+
+
+@pytest.mark.asyncio
+async def test_douyin_search_automatically_persists_page(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    search_module = importlib.import_module(
+        "agents.find_agent.tools.douyin_search"
+    )
+
+    async def fake_raw_search(**_kwargs):
+        return json.dumps(
+            {
+                "results_count": 1,
+                "has_more": False,
+                "search_results": [{"aweme_id": "auto-saved"}],
+            }
+        )
+
+    persisted: dict[str, object] = {}
+
+    def fake_persist(payload_json: str, **kwargs):
+        persisted.update(kwargs)
+        payload = json.loads(payload_json)
+        payload.update(
+            {
+                "persisted": True,
+                "search_id": 11,
+                "new_candidate_count": 1,
+                "candidates": [
+                    {
+                        "candidate_id": 21,
+                        "search_id": 11,
+                        "aweme_id": "auto-saved",
+                        "title": "自动保存",
+                        "decision_bucket": "pending_evaluation",
+                    }
+                ],
+            }
+        )
+        return json.dumps(payload)
+
+    monkeypatch.setattr(search_module, "_douyin_search_raw", fake_raw_search)
+    monkeypatch.setattr(search_module, "persist_search_payload", fake_persist)
+
+    result = json.loads(
+        await search_module.douyin_search(
+            run_id="run-auto-save",
+            keyword="广场舞",
+            query_reason="验证需求根搜索",
+            source_type="demand",
+        )
+    )
+
+    assert result["persisted"] is True
+    assert result["search_id"] == 11
+    assert result["candidates"][0]["candidate_id"] == 21
+    assert persisted["run_id"] == "run-auto-save"
+    assert persisted["keyword"] == "广场舞"
+    assert persisted["provider"] == "internal_keyword"
+
+
+@pytest.mark.asyncio
+async def test_batch_search_records_each_page_and_carries_parent(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    batch_module = importlib.import_module(
+        "agents.find_agent.tools.batch_search_and_record"
+    )
+    calls: list[dict[str, object]] = []
+
+    async def fake_search(**kwargs):
+        calls.append(kwargs)
+        page_no = int(kwargs["page_no"])
+        return json.dumps(
+            {
+                "results_count": 1,
+                "has_more": page_no == 1,
+                "next_cursor": "next-page" if page_no == 1 else None,
+                "persisted": True,
+                "search_id": 100 + page_no,
+                "new_candidate_count": 1,
+                "candidates": [
+                    {
+                        "candidate_id": 200 + page_no,
+                        "search_id": 100 + page_no,
+                        "aweme_id": "same-video",
+                        "title": f"第 {page_no} 页",
+                        "decision_bucket": "pending_evaluation",
+                    }
+                ],
+            }
+        )
+
+    monkeypatch.setattr(batch_module, "douyin_search", fake_search)
+
+    result = json.loads(
+        await batch_search_and_record(
+            run_id="run-batch",
+            searches=[
+                {
+                    "keyword": "广场舞",
+                    "query_reason": "验证需求根搜索",
+                    "source_type": "demand",
+                    "max_pages": 2,
+                }
+            ],
+        )
+    )
+
+    assert result["saved_page_count"] == 2
+    assert result["new_candidate_count"] == 2
+    assert result["tasks"][0]["pages"][0]["candidates"][0]["candidate_id"] == 201
+    assert result["tasks"][0]["pages"][1]["candidates"][0]["candidate_id"] == 202
+    assert calls[0]["parent_search_id"] is None
+    assert calls[1]["parent_search_id"] == 101
+    assert calls[1]["cursor"] == "next-page"
+
+
+def test_batch_update_candidates_uses_database_candidate_id(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    captured: dict[str, object] = {}
+
+    class FakeService:
+        def update_candidates(self, run_id, rows):
+            captured["run_id"] = run_id
+            captured["rows"] = rows
+            return {
+                "updated_count": 1,
+                "candidates": [
+                    {
+                        "candidate_id": 901,
+                        "search_id": 801,
+                        "aweme_id": "same-video",
+                        "decision_bucket": "primary",
+                    }
+                ],
+            }
+
+    monkeypatch.setattr(
+        video_discovery_store,
+        "get_video_discovery_service",
+        lambda: FakeService(),
+    )
+
+    result = json.loads(
+        video_discovery_store.batch_update_video_discovery_candidates(
+            run_id="run-update",
+            items=[
+                {
+                    "candidate_id": 901,
+                    "decision_bucket": "primary",
+                    "relevance_score": 0.8,
+                    "elder_score": 0.7,
+                    "share_score": 0.6,
+                }
+            ],
+        )
+    )
+
+    assert result["updated_count"] == 1
+    assert captured["run_id"] == "run-update"
+    assert captured["rows"][0]["candidate_id"] == 901
+    assert "aweme_id" not in captured["rows"][0]
+
+
+def test_each_search_inserts_new_candidate_occurrences() -> None:
+    engine = create_engine("sqlite+pysqlite:///:memory:")
+    VideoDiscoveryRun.__table__.create(engine)
+    VideoDiscoverySearch.__table__.create(engine)
+    VideoDiscoveryCandidate.__table__.create(engine)
+    factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+    ids = {"search": 100, "candidate": 1000}
+
+    @event.listens_for(factory.class_, "before_flush")
+    def assign_sqlite_bigint_ids(session, _flush_context, _instances):
+        for entity in session.new:
+            if isinstance(entity, VideoDiscoverySearch) and entity.id is None:
+                ids["search"] += 1
+                entity.id = ids["search"]
+            elif isinstance(entity, VideoDiscoveryCandidate) and entity.id is None:
+                ids["candidate"] += 1
+                entity.id = ids["candidate"]
+
+    with factory() as session:
+        session.add(
+            VideoDiscoveryRun(
+                id=1,
+                run_id="run-occurrences",
+                demand_word="广场舞",
+                relevant_points_json="[]",
+                status="running",
+            )
+        )
+        session.commit()
+
+    search_values = {
+        "run_id": "run-occurrences",
+        "search_key": "same-search-key",
+        "keyword": "广场舞",
+        "query_reason": "验证相同搜索也生成新记录",
+        "source_type": "demand",
+        "provider": "internal_keyword",
+        "content_type": "视频",
+        "sort_type": "综合排序",
+        "publish_time": "不限",
+        "cursor": "0",
+        "page_no": 1,
+        "results_count": 1,
+        "new_candidate_count": 0,
+        "has_more": 0,
+        "status": "success",
+    }
+    candidate_rows = [
+        {
+            "aweme_id": "same-video",
+            "title": "同一个视频",
+            "_source_keyword": "广场舞",
+        }
+    ]
+
+    with factory() as session:
+        repo = VideoDiscoveryRepository(session)
+        first_search, first_candidates = repo.save_search_page(
+            dict(search_values),
+            candidate_rows,
+        )
+        second_search, second_candidates = repo.save_search_page(
+            dict(search_values),
+            candidate_rows,
+        )
+        session.commit()
+
+        assert first_search.id != second_search.id
+        assert first_candidates[0].id != second_candidates[0].id
+        assert first_candidates[0].search_id == first_search.id
+        assert second_candidates[0].search_id == second_search.id
+
+    with factory() as session:
+        searches = session.scalars(select(VideoDiscoverySearch)).all()
+        candidates = session.scalars(select(VideoDiscoveryCandidate)).all()
+        assert len(searches) == 2
+        assert len(candidates) == 2
+        assert {candidate.aweme_id for candidate in candidates} == {"same-video"}
+
+
 def _seed_candidate(
 def _seed_candidate(
     factory: sessionmaker[Session],
     factory: sessionmaker[Session],
     *,
     *,

+ 7 - 9
zhangbo.md

@@ -294,21 +294,19 @@ source_dim
 
 
 当前注册的业务工具包括:
 当前注册的业务工具包括:
 
 
-- `douyin_search`:调用外部抖音关键词搜索服务;
-- `douyin_search_tikhub`:调用 TikHub 搜索并保留 search_id/backtrace 分页状态;
-- `douyin_user_videos`:按作者 sec_uid、排序和游标扩展历史作品;
+- `batch_search_and_record`:批量执行多个关键词和分页搜索,每页创建独立搜索及候选记录;
+- `douyin_search`:调用外部抖音关键词搜索服务并返回数据库记录 ID;
+- `douyin_search_tikhub`:调用 TikHub 搜索,保存分页状态并返回数据库记录 ID;
+- `douyin_user_videos`:按作者 sec_uid、排序和游标扩展历史作品并返回数据库记录 ID;
 - `douyin_detail`:按 content_id 获取视频详情和可播放地址;
 - `douyin_detail`:按 content_id 获取视频详情和可播放地址;
 - `get_content_fans_portrait`:获取视频点赞用户画像;
 - `get_content_fans_portrait`:获取视频点赞用户画像;
 - `get_account_fans_portrait`:获取作者粉丝画像;
 - `get_account_fans_portrait`:获取作者粉丝画像;
 - `batch_fetch_portraits`:批量获取视频画像,并可同时获取作者画像;
 - `batch_fetch_portraits`:批量获取视频画像,并可同时获取作者画像;
 - `normalize_age_portraits`:标准化 `50-` 等年龄桶及双侧证据;
 - `normalize_age_portraits`:标准化 `50-` 等年龄桶及双侧证据;
-- `audit_video_discovery_process`:结束前审计多词、翻页、扩展、证据和双池分流;
-- `qwen_video_analyze`:调用千问视频模型解析视频;
 - `create_video_discovery_run`:创建可追踪的找片运行;
 - `create_video_discovery_run`:创建可追踪的找片运行;
-- `record_video_search_page`:保存 Agent 自主搜索词、扩展来源、游标与本页结果;
-- `batch_save_video_candidate_evaluations`:保存证据、评分并分为正式推荐/人工备选/淘汰;
-- `query_video_discovery_state`:查询搜索树和候选分池;
-- `review_video_discovery_candidate`:记录用户对推荐或备选的人工选择结果。
+- `batch_update_video_discovery_candidates`:按 candidate_id 更新证据、评分和最终分池;
+- `update_video_discovery_run_status`:独立更新找片运行状态;
+- `query_video_discovery_state`:查询搜索树和候选分池。
 
 
 搜索和详情接口有约 10 秒的请求间隔限制。
 搜索和详情接口有约 10 秒的请求间隔限制。