Bläddra i källkod

Merge branch 'master' into feature/zhangbo

zhang 3 timmar sedan
förälder
incheckning
b653d4bd55
100 ändrade filer med 9542 tillägg och 3162 borttagningar
  1. 18 0
      .env.example
  2. 6 0
      .gitignore
  3. 46 47
      PRD.md
  4. 23 0
      README.md
  5. 378 270
      agents/find_agent/PRD.md
  6. 0 135
      agents/find_agent/README.md
  7. 0 119
      agents/find_agent/VALIDATION.md
  8. 2 0
      agents/find_agent/__init__.py
  9. 5 0
      agents/find_agent/async_runner.py
  10. 60 0
      agents/find_agent/completion_guard.py
  11. 26 14
      agents/find_agent/demand_run.py
  12. 122 117
      agents/find_agent/prompt/system_prompt.md
  13. 4 1
      agents/find_agent/run.py
  14. 4 0
      agents/find_agent/support/__init__.py
  15. 1 169
      agents/find_agent/support/age_portrait.py
  16. 26 0
      agents/find_agent/support/batch_search_and_record.py
  17. 375 0
      agents/find_agent/support/douyin_detail.py
  18. 330 0
      agents/find_agent/support/douyin_search.py
  19. 465 0
      agents/find_agent/support/douyin_search_tikhub.py
  20. 310 0
      agents/find_agent/support/douyin_user_videos.py
  21. 1 6
      agents/find_agent/support/portrait.py
  22. 1 1
      agents/find_agent/support/qwen_video_analysis.py
  23. 256 0
      agents/find_agent/support/search_persistence.py
  24. 397 0
      agents/find_agent/support/video_discovery.py
  25. 39 18
      agents/find_agent/tools/__init__.py
  26. 19 0
      agents/find_agent/tools/batch_fetch_portraits.py
  27. 196 0
      agents/find_agent/tools/batch_search_and_record.py
  28. 19 0
      agents/find_agent/tools/batch_update_video_discovery_candidates.py
  29. 19 0
      agents/find_agent/tools/create_video_discovery_run.py
  30. 13 355
      agents/find_agent/tools/douyin_detail.py
  31. 48 210
      agents/find_agent/tools/douyin_search.py
  32. 47 385
      agents/find_agent/tools/douyin_search_tikhub.py
  33. 36 249
      agents/find_agent/tools/douyin_user_videos.py
  34. 19 0
      agents/find_agent/tools/get_account_fans_portrait.py
  35. 19 0
      agents/find_agent/tools/get_content_fans_portrait.py
  36. 19 0
      agents/find_agent/tools/normalize_age_portraits.py
  37. 19 0
      agents/find_agent/tools/query_video_discovery_state.py
  38. 19 0
      agents/find_agent/tools/update_video_discovery_run_status.py
  39. 0 572
      agents/find_agent/tools/video_discovery_store.py
  40. 561 0
      agents/find_agent/优化迭代方案-v3.md
  41. 0 49
      alembic/versions/20260727_02_nullable_manual_deadline.py
  42. 0 75
      alembic/versions/20260727_03_agent_document_injection.py
  43. 0 75
      alembic/versions/20260728_01_drop_unused_scheduler_artifacts.py
  44. 58 9
      alembic/versions/20260729_03_schema_baseline.py
  45. 88 0
      alembic/versions/20260730_04_add_local_auth.py
  46. 33 0
      alembic/versions/20260730_05_immutable_auth_session.py
  47. 81 0
      alembic/versions/20260730_06_add_demand_feedback.py
  48. 51 0
      alembic/versions/20260730_07_add_feedback_username.py
  49. 165 0
      alembic/versions/20260731_08_add_find_agent_p0_gates.py
  50. 133 2
      api/app.py
  51. 80 0
      api/auth_middleware.py
  52. 120 0
      api/routers/auth.py
  53. 32 0
      api/schemas/auth.py
  54. 79 0
      api/schemas/demand_feedback.py
  55. 280 0
      api/services/auth.py
  56. 265 0
      api/services/demand_feedback.py
  57. 159 37
      api/services/video_discovery.py
  58. 452 0
      api/services/video_discovery_records.py
  59. 4 1
      deploy/README.md
  60. 311 0
      prd/08-需求汇总反馈机制设计.md
  61. 103 0
      prd/09-找视频记录展示设计.md
  62. 2 0
      prd/README.md
  63. 76 0
      scripts/backfill_multi_demand_video_urls.py
  64. 10 0
      sql/multi_demand_video_detail_add_url1_url2.sql
  65. 0 22
      sql/video_discovery_add_audit_evidence.sql
  66. 15 0
      sql/video_discovery_search_candidate_occurrences.sql
  67. 27 20
      sql/video_discovery_tables.sql
  68. 8 1
      supply_agent/__init__.py
  69. 2 1
      supply_agent/agent/__init__.py
  70. 11 1
      supply_agent/agent/core.py
  71. 34 1
      supply_agent/agent/loop.py
  72. 15 1
      supply_agent/types.py
  73. 63 1
      supply_infra/config.py
  74. 6 0
      supply_infra/db/models/__init__.py
  75. 39 0
      supply_infra/db/models/auth_session.py
  76. 43 0
      supply_infra/db/models/auth_user.py
  77. 107 0
      supply_infra/db/models/demand_feedback.py
  78. 10 0
      supply_infra/db/models/multi_demand_video_detail.py
  79. 63 26
      supply_infra/db/models/video_discovery.py
  80. 4 0
      supply_infra/db/repositories/__init__.py
  81. 104 0
      supply_infra/db/repositories/auth_repo.py
  82. 105 0
      supply_infra/db/repositories/demand_feedback_repo.py
  83. 22 0
      supply_infra/db/repositories/demand_video_expansion_repo.py
  84. 28 0
      supply_infra/db/repositories/multi_demand_video_detail_repo.py
  85. 17 0
      supply_infra/db/repositories/pipeline_step_run_repo.py
  86. 111 113
      supply_infra/db/repositories/video_discovery_repo.py
  87. 3 1
      supply_infra/odps/client.py
  88. 1 1
      supply_infra/scheduler/constants.py
  89. 163 0
      supply_infra/scheduler/jobs/demand_pool/videos.py
  90. 67 55
      supply_infra/services/video_discovery_service.py
  91. 519 0
      supply_infra/video_discovery_gates.py
  92. 1 0
      tests/api/__init__.py
  93. 172 0
      tests/api/test_demand_feedback.py
  94. 239 0
      tests/api/test_video_discovery_records.py
  95. 0 0
      tests/supply_agent/__init__.py
  96. 229 0
      tests/supply_agent/test_agent_loop.py
  97. 205 0
      tests/supply_agent/test_completion_guard.py
  98. 64 0
      tests/supply_agent/test_publish_hook.py
  99. 44 0
      tests/supply_agent/test_tool_errors.py
  100. 471 2
      tests/supply_infra/scheduler/test_discover_videos_from_demands.py

+ 18 - 0
.env.example

@@ -43,6 +43,14 @@ MYSQL_WRITE_TIMEOUT_SECONDS=30
 MYSQL_OPERATIONAL_RESERVE=4
 MYSQL_ECHO=false
 
+# Local web authentication. The bootstrap account is only created when the
+# username does not already exist; changing these values will not reset it.
+AUTH_SESSION_HOURS=12
+AUTH_COOKIE_SECURE=false
+AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
+AUTH_BOOTSTRAP_ADMIN_PASSWORD=
+AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME=系统管理员
+
 # ODPS (MaxCompute)
 ODPS_ACCESS_ID=
 ODPS_ACCESS_KEY=
@@ -55,6 +63,16 @@ SCHEDULER_TIMEZONE=Asia/Shanghai
 SCHEDULER_CRON_HOUR=15
 SCHEDULER_CRON_MINUTE=0
 
+# find_agent P0 质量门禁(每次运行会保存完整配置快照)
+FIND_AGENT_RULE_VERSION=find-agent-gate-v1
+FIND_AGENT_MIN_DURATION_SECONDS=30
+FIND_AGENT_MIN_SHARE_COUNT=1000
+FIND_AGENT_MIN_CONTENT_50_PLUS_RATIO=0.20
+FIND_AGENT_MIN_ACCOUNT_50_PLUS_RATIO=0.20
+FIND_AGENT_FESTIVAL_LEAD_DAYS=7
+FIND_AGENT_EVENT_MAX_AGE_DAYS=7
+FIND_AGENT_SEASONAL_MAX_AGE_DAYS=180
+
 # Pipeline control plane
 PROCESS_ROLE=api
 DATABASE_AUTO_CREATE=false

+ 6 - 0
.gitignore

@@ -19,6 +19,12 @@ build/
 logs/
 tests/*
 !tests/__init__.py
+!tests/api/
+tests/api/*
+!tests/api/__init__.py
+!tests/api/test_demand_feedback.py
+!tests/api/test_video_discovery_records.py
+!tests/supply_agent/
 !tests/supply_infra/
 tests/supply_infra/*
 !tests/supply_infra/__init__.py

+ 46 - 47
PRD.md

@@ -12,7 +12,7 @@
 
 本文用于统一说明 SupplyAgent 当前项目的完整业务流程,重点回答:
 
-1. 每日定时任务如何贯穿 ODPS、MySQL、业务 Agent、找和 AIGC;
+1. 每日定时任务如何贯穿 ODPS、MySQL、业务 Agent、找视频和 AIGC;
 2. 每一阶段的输入、处理规则、输出、状态与失败方式;
 3. API、前端和人工补偿入口如何消费流水线结果;
 4. 当前实现中已经发现的产品问题、数据风险、工程风险和安全风险;
@@ -37,7 +37,7 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 
 - 通用 Agent 框架:LLM、Tool Calling、Skills、运行日志;
 - 数据基础设施:ODPS、MySQL、OSS;
-- 每日供给流水线:同步、归类、聚合、分级、拓展、找、分发;
+- 每日供给流水线:同步、归类、聚合、分级、拓展、找视频、分发;
 - FastAPI 查询服务;
 - Vue 需求地图和需求/视频证据页面。
 
@@ -59,7 +59,7 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 - 统一的“平台需求”版本化产品对象;
 - 人工反馈和线上效果自动回流到次日决策;
 - 真正调用发布计划完成内容发布;
-- 在页面中展示 `find_agent` 最终候选及完整审计过程;
+- 在页面中展示 `find_agent` 最终候选及完整执行过程;
 - 跨实例的任务编排、分布式锁和可靠消息机制。
 
 ---
@@ -73,8 +73,8 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 | 运维/开发人员 | 观察任务是否运行、定位失败、手动补偿 | Scheduler 状态接口、日志、`python -m supply_infra.scheduler.jobs.*` |
 | ODPS | 提供分类树、需求池、视频解析、ROV/VOV | 定时查询 |
 | MySQL | 保存业务快照、关系、执行状态和候选 | 全流程状态底座 |
-| OpenRouter/模型服务 | 执行归类、分级、拓展和找判断 | Agent Tool Calling |
-| 抖音搜索/画像/TikHub/千问 | 提供找、详情、画像和内容解析证据 | `find_agent` 外部工具 |
+| OpenRouter/模型服务 | 执行归类、分级、拓展和找视频判断 | Agent Tool Calling |
+| 抖音搜索/画像/TikHub/千问 | 提供找视频、详情、画像和内容解析证据 | `find_agent` 外部工具 |
 | OSS | 保存 Agent `.log`、`.jsonl` 和 HTML 可视化 | 每次 Agent 运行后上传 |
 | AIGC 平台 | 创建视频爬取计划并绑定生产计划 | 流水线末端调用 |
 
@@ -95,10 +95,10 @@ SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多
 | 需求等级 | 每日 S/A/B/C/D 结果 | `demand_grade`、`demand_grade_category_rel` |
 | 源视频及点位 | 需求池视频的标题、选题、三类点位 | `multi_demand_video_detail`、`multi_demand_video_point` |
 | 需求拓展 | S/A 需求从视频点位选出的拓展意图 | `demand_video_expansion*` |
-| 找运行 | 搜索树、候选证据、评分和分池 | `video_discovery_run/search/candidate` |
+| 找视频运行 | 搜索树、候选证据、评分和分池 | `video_discovery_run/search/candidate` |
 | AIGC 分发状态 | 候选被分配到的爬取/生产/发布计划标识 | `video_discovery_candidate` |
 | 任务执行记录 | 总流水线 started/finished/failed/skipped | `scheduler_job_execution` |
-| Agent 审计日志 | 模型输入、输出、工具调用及 HTML | 本地 `logs/`、OSS、`oss_logs` |
+| Agent 运行日志 | 模型输入、输出、工具调用及 HTML | 本地 `logs/`、OSS、`oss_logs` |
 
 ---
 
@@ -114,7 +114,7 @@ flowchart LR
     METRIC --> SOURCE_VIDEO["同步源视频标题与三类点位"]
     SOURCE_VIDEO --> GRADE["③ 需求分级<br/>S/A/B/C/D"]
     GRADE --> EXPAND["④ S/A 视频点位拓展"]
-    EXPAND --> FIND["⑤ Top 200 需求找"]
+    EXPAND --> FIND["⑤ Top 200 需求找视频"]
     FIND --> AIGC["⑥ 创建 AIGC 爬取计划<br/>绑定生产计划"]
     AIGC --> API["FastAPI 查询"]
     API --> WEB["Vue 需求地图/需求/视频证据"]
@@ -124,7 +124,7 @@ flowchart LR
 
 - 未显式传入 `biz_dt` 时,按 `SCHEDULER_TIMEZONE` 的当天生成 `YYYYMMDD`;
 - 全局分类树使用 `biz_dt - 1 天` 的 ODPS 分区;
-- 策略需求池、热度、分级、拓展、找使用 `biz_dt`;
+- 策略需求池、热度、分级、拓展、找视频使用 `biz_dt`;
 - 视频解析同步没有使用传入的 `biz_dt`,而是固定读取任务实际执行日的昨天。
 
 ---
@@ -147,8 +147,8 @@ flowchart LR
 | 同一 Scheduler 最大实例 | 1 |
 | 合并错过的执行 | `coalesce=True` |
 | 允许延迟 | 3600 秒 |
-| 找需求上限 | 200 |
-| 找并发 | 2 |
+| 找视频需求上限 | 200 |
+| 找视频并发 | 2 |
 | 分级并发 | 5 |
 | 点位拓展并发 | 5 |
 
@@ -304,7 +304,7 @@ flowchart LR
 
 ### 9.1 目标
 
-将当日需求分为 S/A/B/C/D,供后续点位拓展、找和资源分配使用。
+将当日需求分为 S/A/B/C/D,供后续点位拓展、找视频和资源分配使用。
 
 ### 9.2 自动计划
 
@@ -379,9 +379,9 @@ flowchart LR
 4. 当前实现先按 `score` 降序,再用 S/A 作为次级排序;
 5. 每天最多取前 200 条;
 6. 2 个 worker 并发执行;
-7. 当日存在 `running` 或 `finished` 找记录时默认跳过。
+7. 当日存在 `running` 或 `finished` 找视频记录时默认跳过。
 
-### 11.2 单需求找流程
+### 11.2 单需求找视频流程
 
 1. 系统预创建 `video_discovery_run` 并生成 `run_id`;
 2. Agent 根据需求、参考视频和点位形成 2~3 个搜索假设;
@@ -399,15 +399,15 @@ flowchart LR
    - `rejected`:淘汰;
    - `pending_evaluation`:尚未完成的过程状态,不是最终等级;
 9. Agent 保存最终候选评估并将运行置为 `finished`;
-10. 数据库审计通过后重新查询最终状态,再输出主推荐、淘汰原因、搜索树和缺失证据;
-11. completion guard 校验顺序和报告分池,报告之后不再修改数据库。
+10. 重新查询最终状态,再输出主推荐、淘汰原因、搜索树和缺失证据;
+11. 报告之后不再修改数据库。
 
 ### 11.3 输出
 
 - `video_discovery_run`;
 - `video_discovery_search`;
 - `video_discovery_candidate`;
-- Agent 审计日志和 OSS HTML。
+- Agent 运行日志和 OSS HTML。
 
 ---
 
@@ -466,7 +466,7 @@ flowchart LR
 | 需求归类过程 | 归类 Agent OSS 日志 |
 | 需求汇总/视频发现 | 分级需求、源视频、拓展点位 |
 
-当前前端“视频发现”页面没有读取 `video_discovery_candidate`,因此展示的是找输入证据,
+当前前端“视频发现”页面没有读取 `video_discovery_candidate`,因此展示的是找视频输入证据,
 不是 `find_agent` 最终找到的主推荐和淘汰候选。
 
 ---
@@ -498,7 +498,7 @@ flowchart LR
 - 下游步骤只能消费通过完整性校验的上游产物;
 - 分类树或需求池失败时,不得执行依赖其结果的分级;
 - 分级覆盖不完整时,不得将该日结果作为可发布批次;
-- 找片未完成审计时,不得进入 AIGC 分发。
+- 找视频运行未达到 `finished` 或候选未完成分池时,不得进入 AIGC 分发。
 
 ### FR-03 数据同步
 
@@ -526,19 +526,19 @@ flowchart LR
 
 - 每条 S/A 需求必须有明确结果:有拓展、无拓展、无源视频、无点位或执行失败;
 - 0 条拓展必须是经工具确认的有效业务结果,不能由“未调用保存工具”推断;
-- 没有拓展点的 S/A 需求仍应允许使用原需求进入找
+- 没有拓展点的 S/A 需求仍应允许使用原需求进入找视频
 
 ### FR-07 视频发现
 
 - 入选顺序必须符合确认后的业务策略,S/A 优先级与 score 排序不得冲突;
 - 每个运行必须有租约和超时,崩溃遗留的 `running` 可自动恢复;
-- 完成前必须校验搜索页、候选评估、证据、审计和最终状态;
+- 完成前必须保存搜索页、候选评估、证据和最终状态;
 - 强制重跑必须新建 attempt 或清理旧子记录,不能混用两次搜索状态;
 - 相同视频跨需求发现时必须全局去重或形成“一视频多需求”关系。
 
 ### FR-08 AIGC 分发与发布
 
-- 只有 finished 且审计通过的找片运行可以分发;
+- 只有 `finished` 的找视频运行可以分发;
 - 仅 `primary` 允许自动分发;
 - 候选必须按需求分类或明确的路由规则进入正确计划;
 - 创建、绑定、生产、发布每个外部动作必须有幂等键和独立状态;
@@ -592,13 +592,13 @@ flowchart LR
 
 | ID | 问题 | 影响 | 代码现状/证据 | 产品要求 |
 |---|---|---|---|---|
-| P0-01 | 上游失败后仍继续下游 | 可能用旧树、空需求池或不完整分级继续找和分发 | 总流水线逐步捕获异常并无条件继续 | 建立依赖 DAG 和硬门禁 |
+| P0-01 | 上游失败后仍继续下游 | 可能用旧树、空需求池或不完整分级继续找视频和分发 | 总流水线逐步捕获异常并无条件继续 | 建立依赖 DAG 和硬门禁 |
 | P0-02 | 分级存在失败组时仍可能判定完成 | 部分需求未分级,但总步骤返回成功 | `get_execution_snapshot` 的 `execution_complete` 只统计 pending/running,不统计 failed | failed 必须阻止完成 |
-| P0-03 | 无计划或无分级也可能成功 | 当日需求完全未处理仍进入拓展和找 | 自动计划异常被吞掉;空计划快照可被视为 complete | 校验计划覆盖率和分级覆盖率 |
+| P0-03 | 无计划或无分级也可能成功 | 当日需求完全未处理仍进入拓展和找视频 | 自动计划异常被吞掉;空计划快照可被视为 complete | 校验计划覆盖率和分级覆盖率 |
 | P0-04 | Agent 返回即把分级明细标记 finished | 模型未保存、少保存或保存工具报错时产生假完成 | worker 不核对 `demand_grade` 实际落库覆盖 | 每批结束后按输入逐条验库 |
-| P0-05 | 点位拓展把“未保存”误判为“零结果” | S/A 需求被永久标记完成并从找链路消失 | 从工具文本解析不到数量时默认为 0,仍写 finished | 必须验证保存工具调用和 run 状态 |
-| P0-06 | 找片确定性完成控制(已修复) | 防止在搜索、证据、评估或审计未完成时提前结束 | 已注册数据库审计并启用 completion guard,强制审计后查询最终状态再报告 | 保持顺序与负向回归测试 |
-| P0-07 | AIGC 分发不校验找运行状态 | running/failed 运行中的候选也可能被外发 | publish 查询只筛候选 bucket,不筛 run status/audit | 仅 finished+审计通过可分发 |
+| P0-05 | 点位拓展把“未保存”误判为“零结果” | S/A 需求被永久标记完成并从找视频链路消失 | 从工具文本解析不到数量时默认为 0,仍写 finished | 必须验证保存工具调用和 run 状态 |
+| P0-06 | 找视频结束顺序 | 防止在搜索、证据或评估未完成时提前结束 | Agent 保存候选和 `finished` 状态后查询最终状态 | 保持顺序与负向回归测试 |
+| P0-07 | AIGC 分发不校验找视频运行状态 | running/failed 运行中的候选也可能被外发 | publish 查询只筛候选 bucket,不筛 run status | 仅 `finished` 可分发 |
 | P0-08 | AIGC 按所有计划轮询,不按品类路由 | 健康、历史、时政等视频可能进入错误生产计划 | 代码明确“不区分品类”,均匀分发 | 建立可配置且可解释的分类路由 |
 | P0-09 | “发布”没有真正执行发布 | 业务误以为已发布,实际只绑定了生成计划 | `publish_plan_id` 只入库,没有参与外部 API 调用 | 拆分分发/生产/发布状态并实现确认 |
 | P0-10 | 外部副作用缺少端到端幂等 | 绑定失败、进程崩溃或数据库回写失败会重复创建爬取计划 | 只有 DB 回写成功后才算已处理 | 使用业务幂等键、outbox 和状态机 |
@@ -621,12 +621,12 @@ flowchart LR
 | P1-11 | ROV/VOV 只取前 1000 行且倾向保留较高值 | 长尾缺失、后验结果乐观偏差 | 全量/分页拉取;按业务主键聚合 |
 | P1-12 | 视频解析分区使用运行日昨天,不使用 biz_dt | 历史重跑读错分区,迟到数据无法补齐 | 所有分区从 biz_dt 派生 |
 | P1-13 | 已存在视频详情永远跳过 | 标题、点位或解析结果更新无法自动刷新 | 保存源版本并支持变更 upsert |
-| P1-14 | 无拓展候选的 S/A 需求完全不进入找 | 高优需求因缺少拓展点而丢失 | 原需求本身作为默认搜索根 |
-| P1-15 | 找排序实现与注释不一致 | A 级高 score 可排在 S 级前 | 产品确认排序并加测试 |
-| P1-16 | `running` 找记录无租约,可能永久跳过 | 进程硬退出后任务永远不再执行 | 增加 heartbeat、超时和 attempt |
+| P1-14 | 无拓展候选的 S/A 需求完全不进入找视频 | 高优需求因缺少拓展点而丢失 | 原需求本身作为默认搜索根 |
+| P1-15 | 找视频排序实现与注释不一致 | A 级高 score 可排在 S 级前 | 产品确认排序并加测试 |
+| P1-16 | `running` 找视频记录无租约,可能永久跳过 | 进程硬退出后任务永远不再执行 | 增加 heartbeat、超时和 attempt |
 | P1-17 | 强制重跑复用旧 run_id 和旧子记录 | 两次搜索轨迹、候选和状态互相污染 | 每次重跑新 attempt,显式继承关系 |
 | P1-18 | 同一 aweme_id 可在多个 run 重复分发 | AIGC 重复抓取/生产同一视频 | 建立全局视频资产和发布唯一性 |
-| P1-19 | 最终文字覆盖数据库分池(已修复) | 防止未经审计的文本解析改变候选状态 | 最终报告只读数据库最终状态并由 guard 校验 |
+| P1-19 | 最终文字覆盖数据库分池(已修复) | 防止未经数据库状态确认的文本解析改变候选状态 | 最终报告只读数据库最终状态 |
 | P1-20 | 前端“视频发现”未展示真实发现候选 | 运营无法核对主推荐、备选和发布状态 | 新增 candidate/run API 和页面 |
 | P1-21 | Scheduler 默认启用且随 API 启动 | 开发、扩容或临时环境可能误触生产任务 | 生产显式开启,默认关闭 |
 | P1-22 | 延迟超过 1 小时会丢失当日调度 | API 故障恢复后不会自动补跑 | 按 biz_dt 对账并自动补批次 |
@@ -679,8 +679,8 @@ flowchart TB
     GRADE["生成分级计划并执行"]
     COVER{"计划覆盖率=100%<br/>分级覆盖率=100%?"}
     EXPAND["S/A 拓展<br/>每条都有明确终态"]
-    DISCOVER["找片 attempt<br/>租约/恢复/完成审计"]
-    AUDIT{"运行 finished<br/>候选审计通过?"}
+    DISCOVER["找视频 attempt<br/>租约/恢复/候选分池"]
+    READY{"运行 finished<br/>候选已完成分池?"}
     ROUTE["按需求分类路由 AIGC"]
     OUTBOX["幂等 outbox<br/>创建→绑定→生产→发布"]
     VERIFY["查询外部状态并确认"]
@@ -697,9 +697,9 @@ flowchart TB
     COVER -->|否| STOP
     COVER -->|是| EXPAND
     EXPAND --> DISCOVER
-    DISCOVER --> AUDIT
-    AUDIT -->|否| STOP
-    AUDIT -->|是| ROUTE
+    DISCOVER --> READY
+    READY -->|否| STOP
+    READY -->|是| ROUTE
     ROUTE --> OUTBOX
     OUTBOX --> VERIFY
     VERIFY -->|失败| STOP
@@ -715,8 +715,8 @@ flowchart TB
 - 为总流水线增加依赖门禁;
 - 修复 failed 分级组被视为完成的问题;
 - 增加计划覆盖率、分级覆盖率和逐项落库校验;
-- 保持找片完成守卫、数据库审计和最终状态顺序的负向回归;
-- AIGC 只读取 finished 且审计通过的运行;
+- 保持找视频候选更新、完成状态和最终查询顺序的负向回归;
+- AIGC 只读取 `finished` 的运行;
 - 暂停无分类路由的自动分发,先切换为 dry-run 或人工确认;
 - 对 AIGC 请求日志脱敏;
 - 明确“分发、生产、发布”三种状态。
@@ -727,14 +727,14 @@ flowchart TB
 - 增加业务唯一键和外键;
 - 修复需求池 count-skip、关系清理和树变更同步;
 - 所有分区从 `biz_dt` 派生;
-- 找运行增加租约、超时和恢复;
+- 找视频运行增加租约、超时和恢复;
 - AIGC 外部调用改为 outbox + 幂等状态机;
 - 全局 aweme 去重。
 
 ### 20.3 第三阶段:完善产品闭环
 
 - 重构原始需求、标准词、平台需求和多挂靠关系;
-- 将找结果和 AIGC 状态接入 API/前端;
+- 将找视频结果和 AIGC 状态接入 API/前端;
 - 接入真实生产/发布结果和 ROV/VOV 回流;
 - 统一或下线 `generated_demand` 旁路;
 - 增加策略中心、版本比较和人工反馈。
@@ -766,17 +766,16 @@ flowchart TB
 - 每个 Agent 批次输入都能在数据库逐条找到结果或明确失败原因;
 - 相同数据、模型和策略版本重跑结果可解释、可比较。
 
-### 21.4 找
+### 21.4 找视频
 
 - 每条入选 S/A 需求都有“已完成、无候选、无数据、失败”之一;
 - 无拓展点的 S/A 需求仍能以原需求搜索;
-- finished 运行不存在 `pending_evaluation` 候选;
-- 搜索页、证据、候选分池、审计和最终报告一致;
+- 搜索页和已作出的候选判断能够按 `run_id` 追溯;
 - 崩溃遗留 running 能在超时后自动恢复。
 
 ### 21.5 AIGC
 
-- 100% 候选来自 finished 且审计通过的运行;
+- 100% 候选来自 `finished` 的运行;
 - 每个候选进入与需求分类匹配的计划;
 - 同一 aweme 在同一发布策略下最多外发一次;
 - 创建、绑定、生产、发布状态可分别查询;
@@ -787,7 +786,7 @@ flowchart TB
 
 - `pytest` 可完整收集并通过;
 - P0 路径具备单元测试和集成测试;
-- CI 覆盖同步差异、失败门禁、断点重跑、并发锁、找片审计和 AIGC 幂等;
+- CI 覆盖同步差异、失败门禁、断点重跑、并发锁、找视频状态一致性和 AIGC 幂等;
 - AIGC 默认 dry-run,通过灰度和人工确认后才开启真实外发。
 
 ---
@@ -801,7 +800,7 @@ flowchart TB
 | 分级计划覆盖率 | 100% |
 | 分级结果覆盖率 | 100% |
 | S/A 明确终态覆盖率 | 100% |
-| 找片审计通过率 | 可按失败原因分层,不允许绕过 |
+| 找视频完成状态覆盖率 | 100% |
 | 重复 AIGC 外发率 | 0 |
 | 错误品类路由率 | 0 |
 | 密钥明文日志事件 | 0 |
@@ -834,7 +833,7 @@ flowchart TB
 | 树热度 | `supply_infra/scheduler/jobs/demand_pool/tree_weight.py` |
 | 分级 | `supply_infra/scheduler/jobs/grade_demand_pool.py`、`agents/demand_grade_agent/` |
 | 点位拓展 | `supply_infra/scheduler/jobs/expand_demand_from_video_points.py`、`agents/demand_video_expand_agent/` |
-| 找 | `supply_infra/scheduler/jobs/discover_videos_from_demands.py`、`agents/find_agent/` |
+| 找视频 | `supply_infra/scheduler/jobs/discover_videos_from_demands.py`、`agents/find_agent/` |
 | AIGC 分发 | `supply_infra/scheduler/jobs/publish_videos_from_discovery.py`、`supply_infra/aigc/` |
 | 数据模型 | `supply_infra/db/models/` |
 | API | `api/app.py`、`api/services/` |

+ 23 - 0
README.md

@@ -209,6 +209,29 @@ cd web && npm install && npm run dev
 - 前端: http://127.0.0.1:5173 (开发代理 `/api` → 8080)
 - 默认展开 3 层,可选择展开层数,支持节点手动展开/收起
 
+### 登录与权限
+
+Web 控制台使用本地账号和服务端 Session。系统包含两个固定角色:
+
+- `admin`:全部页面和 API 权限,并可在“用户管理”中创建、禁用和重置账号;
+- `user`:仅可访问“全局需求地图”和“需求汇总”及其只读 API。
+
+每次成功登录都会创建一条独立、不可刷新的会话 Token 记录,同一账号可同时在任意数量
+的浏览器或设备登录。退出当前设备只删除当前 Token;管理员修改账号角色、禁用账号或
+重置密码时,会统一使该账号的旧 Token 失效。
+
+首次部署前先执行数据库迁移,并通过环境变量创建初始管理员:
+
+```bash
+alembic upgrade head
+export AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
+export AUTH_BOOTSTRAP_ADMIN_PASSWORD='replace-with-a-strong-password'
+python -m api
+```
+
+初始管理员只会在该用户名不存在时创建,修改环境变量不会重置已有密码。生产 HTTPS
+环境必须设置 `AUTH_COOKIE_SECURE=true`。
+
 ## 运行测试
 
 ```bash

+ 378 - 270
agents/find_agent/PRD.md

@@ -1,331 +1,439 @@
-# find_agent 产品需求文档(PRD)
+# find_agent 当前执行逻辑 PRD
 
-> 文档版本:v1.2
->
-> 基线日期:2026-07-28
->
-> Agent 定位:老年受众高潜抖音视频发现 Agent
+> 文档版本:v2.0  
+> 代码基线:2026-07-29  
+> 文档性质:As-Is 现状说明
 
-## 1. 文档目的与边界
+## 1. 文档范围
 
-本文只描述 `find_agent` 本身
+本文只描述 `find_agent` 当前已经存在的内部执行逻辑,包括
 
-- 当前职责、输入、输出和能力;
-- 当前使用的工具、判断规则和运行约束;
-- 当前存在的问题及需要优化的点。
+- Agent 实例配置;
+- 单次运行的输入构造;
+- 模型循环与工具调用;
+- 搜索、候选、证据和评估状态;
+- 持久化状态变化;
+- 正常结束、超时和失败判定;
+- Prompt 约束与代码硬约束的实际边界。
 
-本文不描述 SupplyAgent 的整体业务流程,不涉及需求分级、日批调度、跨 Agent 协作、
-下游生产发布、业务里程碑或平台级建设。
+## 2. Agent 实例状态
 
-当前实现依据:
+| 项目 | 当前值 |
+|---|---|
+| Agent 名称 | `find_agent` |
+| 默认模型 | `google/gemini-3-flash-preview` |
+| 模型覆盖 | 创建或运行时显式传入 `model` 可覆盖默认模型 |
+| 温度 | `0.2` |
+| 最大模型迭代 | `60` |
+| 单次运行总超时 | 默认 `600` 秒 |
+| 超时配置 | `FIND_AGENT_TIMEOUT_SECONDS`,最小 `60` 秒,最大 `7200` 秒 |
+| 执行方式 | 异步 ReAct 循环;同步入口负责创建并关闭事件循环 |
+| 视频理解 | 不可用;`qwen_video_analyze` 未注册到 Agent |
 
-- Agent 组装:[agent.py](agent.py)
-- 核心提示词:[prompt/system_prompt.md](prompt/system_prompt.md)
-- 工具注册:[tools/__init__.py](tools/__init__.py)
-- 对外调用:[__init__.py](__init__.py)
+Agent 初始化时注册一个内置 `load_skill` 工具和 12 个 `find_agent` 专属工具。当前
+`find_agent` 没有预加载 Skill,正常执行依赖系统 Prompt 和已注册工具。
 
-## 2. Agent 定位
+## 3. 单次运行输入状态
 
-`find_agent` 根据一条明确的内容需求,从抖音候选中寻找同时满足以下条件的视频:
+### 3.1 上下文对象
 
-1. 与需求真实意图相关;
-2. 有证据支持其受众偏向较高年龄段;
-3. 具备可解释的分享价值。
+单次调度运行对应一个 `FindDemandContext`:
 
-Agent 对搜索词生成、候选补证、评分解释和最终分池负责。它不生产或改写视频,也不负责
-需求优先级、任务调度、内容发布及其他 Agent 的行为。
+| 字段 | 含义 |
+|---|---|
+| `biz_dt` | 业务日期,格式为 `YYYYMMDD` |
+| `demand_grade_id` | 本次需求记录 ID |
+| `demand_name` | 传给 Agent 的 `demand_word` |
+| `grade` | 当前需求等级 |
+| `videos` | 该需求下的参考视频集合 |
+| `videos[].video_id` | 参考视频 ID |
+| `videos[].title` | 参考视频标题;缺失时使用 `(无标题\|video_id)` |
+| `videos[].points` | 参考视频对应的有效拓展点位 |
 
-当前版本明确不使用视频画面、语音、字幕或多模态理解。相关性与分享动机仅依据标题、
-描述、话题、详情文本、互动数据和受众画像判断。
+点位只接受 `inspiration`、`purpose`、`key` 三种类型。空点位被丢弃,同一视频内按
+`(point_type, expanded_text)` 去重。没有有效拓展点位的参考视频不会进入上下文;没有
+有效参考视频的需求不会生成运行上下文。
 
-## 3. 输入与输出
+上下文加载后的实际排序键为:
 
-### 3.1 输入
+`score 降序 → grade(S 在 A 前)→ demand_name → demand_grade_id`
 
-| 字段 | 必需性 | 含义 |
-|---|---|---|
-| `demand_word` | 必需 | 本次找片的需求词和意图边界 |
-| `seed_video_title` | 可选 | 已知相关视频标题,用于消除需求歧义 |
-| `relevant_points` | 可选 | 参考视频中与需求相关的灵感、目的或关键点 |
-| `reference_videos` | 可选 | 多个参考视频及各自相关点 |
-| `run_id` | 可选 | 已创建的发现运行标识;存在时必须复用 |
+因此当前实现先比较 `score`,只有同分时才比较 S/A 等级。
 
-输入信息不足时,Agent 可以继续搜索,但必须降低意图判断的置信度,不能用模型常识补全
-未提供的业务要求。
+### 3.2 运行预创建
 
-### 3.2 输出
+进入模型循环前,系统先按 `(biz_dt, demand_grade_id)` 预创建或复用
+`video_discovery_run`:
 
-Agent 最终输出以下内容:
+1. 新任务生成随机 `run_id`,初始状态为 `running`;
+2. 已有记录且未启用 `force` 时:
+   - 状态为 `finished`,跳过;
+   - 或该运行下已经存在任意候选记录,跳过;
+3. 已有记录但不满足跳过条件时,复用原 `run_id`,并将运行状态重置为 `running`;
+4. 启用 `force` 时复用已有 `run_id` 并重置运行输入,不新建第二条同需求运行。
 
-1. 一句话需求意图理解;
-2. `primary` 主推荐;
-3. `rejected` 淘汰候选及淘汰原因;
-4. Agent 实际执行的搜索记录;
-5. 缺失数据、画像冲突、未继续搜索项和接口错误。
+复用或强制运行不会清空原 `run_id` 下的搜索页和候选,也没有“执行代次”字段。新一次
+模型执行会继续读写同一组持久化记录。
 
-每条主推荐至少包含:
+### 3.3 模型用户消息
 
-- 标题、作者、抖音页面链接和 `aweme_id`;
-- 命中的需求点及相关性证据;
-- 原始分享数及可计算的分享效率;
-- 视频点赞用户年龄画像;
-- 作者粉丝年龄画像;
-- 分享动机;
-- `R / E / S / V` 整数分、置信度和主要限制。
+模型收到的用户消息包含:
 
-最终分池只允许 `primary / rejected`。`pending_evaluation` 仅是处理中的临时状态,不得
-出现在最终结果中。
+- 预创建的 `run_id`;
+- `demand_grade_id`;
+- `demand_word`;
+- 全部 `reference_videos` 及各自点位;
+- 直接复用给定 `run_id` 开始搜索、更新候选和管理状态的指令。
 
-## 4. 当前能力
+`relevant_points` 不传给模型,也不要求模型生成。调度程序在预创建运行时已经完成点位
+展平,并保存展平结果、完整参考视频快照以及兼容旧表结构的主参考视频字段。
 
-### 4.1 需求理解与搜索规划
+## 4. 内部执行状态机
 
-- 综合需求词、参考标题和相关点解释真实意图;
-- 默认生成 2~3 个语义不同的根搜索词;
-- 搜索词不要求逐字复用 `demand_word`;
-- 可根据高潜候选的话题、标题实体、作者和分页状态继续扩展;
-- 以新增有效候选和潜在信息价值决定是否继续搜索。
+### 4.1 逻辑阶段
 
-### 4.2 候选召回
+| 阶段 | 进入条件 | 内部动作 | 退出条件 |
+|---|---|---|---|
+| `CONTEXT_READY` | 已构造有效上下文 | 生成输入快照 | 准备运行记录 |
+| `RUNNING` | 运行记录已预创建或重置 | 启动日志与 ReAct 循环 | 模型请求工具或直接回答 |
+| `SEARCHING` | 模型调用召回工具 | 关键词、翻页、标签或作者扩展 | 搜索结果返回 |
+| `PERSISTING_SEARCH` | 搜索页已返回 | 自动新增搜索记录,并为本页每条结果新增候选记录 | 候选进入待评估态 |
+| `EVIDENCE_GATHERING` | 模型选择高潜候选 | 获取详情、视频画像、作者画像并标准化 | 模型认为证据足够 |
+| `EVALUATING` | 候选具备可用证据 | 生成 R/E/S/V、理由和最终分池 | 评估写入数据库 |
+| `FINAL_QUERY` | 候选与运行状态已保存 | 重新读取数据库最终状态 | 模型生成最终文本 |
+| `LOOP_DONE` | 模型返回不含工具调用的消息 | 结束 ReAct 循环 | 进入运行结果判定 |
+| `FAILED` | 外层异常、超时或结果侧失败 | 将运行标记为 `failed` | 本次执行结束 |
 
-- 支持内部抖音关键词搜索;
-- 支持 TikHub 独立搜索和分页;
-- 支持按作者扩展最热或最新作品;
-- 不同搜索词、页面和来源的候选按 `aweme_id` 去重;
-- TikHub 不可用时可退回内部搜索,并保留错误原因;
-- 每次搜索结果均可保存查询词、形成原因、分页状态和父搜索信息。
+逻辑阶段没有单独持久化字段。数据库只持久化运行、搜索页和候选三个层级的状态。
 
-### 4.3 候选证据补全
+### 4.2 持久化状态
 
-- 批量获取视频详情,核验标题、作者、话题、链接和互动数据;
-- 获取视频点赞用户画像;
-- 获取作者粉丝年龄画像;
-- 标准化不同年龄桶表达;
-- 记录画像缺失、接口失败及视频画像与作者画像冲突;
-- 先使用搜索结果进行低成本预筛,再为高潜候选补充详情和画像。
+运行状态:
 
-当前没有以下证据:
+- `running`
+- `finished`
+- `failed`
 
-- 视频真实转发用户年龄画像;
-- 分年龄曝光、播放、完播和观看时长;
-- 视频画面、语音或字幕理解结果;
-- 同题材、相近发布时间下的标准化传播基线。
+搜索页状态:
 
-### 4.4 评分与分池
+- `success`
+- `failed`
 
-Agent 对每个候选独立判断
+候选状态
 
-- `R`:需求相关性;
-- `E`:老年受众倾向;
-- `S`:分享价值。
+- `pending_evaluation`
+- `primary`
+- `rejected`
 
-综合价值为:
+数据库兼容读取旧值 `unreviewed`,读取时统一映射为 `pending_evaluation`。
 
-`V = 100 × R^0.40 × E^0.35 × S^0.25`
+## 5. ReAct 循环逻辑
 
-`V` 用于保持排序一致,不替代证据判断。只有 `R / E / S` 三项均成立的候选才能进入
-`primary`;任一项不成立时进入 `rejected`。
+每轮执行顺序为:
 
-当前分池由模型根据提示词和证据作出,保存工具只保存结果,不重新计算分数或改变分池。
+1. 将系统 Prompt、历史消息和当前工具定义发送给模型;
+2. 模型返回普通消息或一个及以上工具调用;
+3. 无工具调用时,当前普通消息立即成为 `AgentResult.content`,循环结束;
+4. 有工具调用时,按模型返回顺序逐个执行;
+5. 每个工具结果写入运行日志并追加为 `tool` 消息;
+6. 下一轮模型基于完整消息历史继续决策。
 
-### 4.5 状态保存与完成控制
+同一轮中的多个工具调用不是并行执行,而是顺序执行。工具返回 JSON 中即使包含
+`error`,循环框架也只把它标记为工具错误并交还模型,不自动重试、不自动失败,也不
+自动切换替代工具。
 
-- 创建或复用 `run_id`;
-- 保存每个搜索页和去重后的候选;
-- 批量保存候选证据、评分、理由和分池;
-- 查询已保存的搜索与候选状态;
-- 通过数据库审计检查搜索、证据、评估和最终状态;
-- completion guard 强制最后阶段满足:
+### 5.1 非法工具参数恢复
 
-`搜索页已保存 → 证据已获取 → 评估已保存 → 审计通过 → 查询最终状态 → 输出报告`
+模型供应方返回 `MALFORMED_FUNCTION_CALL` 时:
 
-最终报告只读取数据库最终状态,不通过文字反向修改候选分池。报告中的主推荐和淘汰
-候选必须与数据库状态一致。
+1. LLM 客户端最多进行 3 次内部重试,即一次原请求加 3 次重试;
+2. 重试时温度降为 `0`,关闭并行工具调用,并附加“只调用一个必要工具”的修正指令;
+3. 连续失败后向 AgentLoop 抛出 `MalformedFunctionCallError`;
+4. AgentLoop 追加一条参数修正消息,然后消耗下一次 Agent 迭代继续执行。
 
-当 Agent 结合任务上下文、替代方案和工具反馈判断任务已经无法继续时,应停止无效重试,
-输出以 `任务未完成(工具故障)` 开头的失败摘要。完成守卫只识别该声明,不解析工具
-返回结构或错误文案,也不替 Agent 判断错误是否可恢复。
+### 5.2 最大迭代结束
 
-## 5. 当前判断规则
+完成 60 次迭代后,框架追加“立即给出当前最佳答案”的消息,再发起一次不携带工具定义的
+模型请求。该请求只能生成文本,不能继续完成搜索、保存或最终状态查询。
 
-### 5.1 需求相关性是准入条件
+框架不会在模型输出最终文本前检查运行状态或数据库一致性。
 
-- 搜索词命中不等于内容相关;
-- 高分享或受众偏老不能弥补低相关;
-- 参考标题和相关点用于理解意图,不要求候选逐字匹配。
+## 6. 搜索与候选召回逻辑
 
-### 5.2 年龄证据按强度使用
+### 6.1 搜索计划
 
-证据优先级为
+搜索词、搜索顺序和是否扩展由模型决定。系统 Prompt 当前要求
 
-1. 视频点赞用户年龄画像;
-2. 作者粉丝年龄画像;
-3. 标题、描述、话题和详情文本体现的内容适配特征;
-4. 题材、人物或作者形象带来的直觉。
+- 从需求、参考标题和相关点形成 2~3 个语义不同的根搜索;
+- 根搜索来源使用 `demand`、`seed`、`point` 或 `mixed`;
+- 标签、作者和翻页扩展使用 `tag`、`author` 或 `pagination`;
+- 尽量形成 5 条 `decision_bucket=primary` 的通过视频;
+- 优先验证语义不同的有效根搜索,并按信息价值决定是否翻页或扩展标签;
+- 剩余前沿不再可能改变候选判断、排序或置信度时停止。
 
-第 4 类不能单独支持老年倾向。视频画像与作者画像冲突时,以视频画像为主并降低置信度。
-明确覆盖 50 岁及以上的年龄桶才属于直接老年信号;只有 40 岁以上数据时,只能表述为
-成熟人群代理信号。
+以上搜索策略由 Prompt 驱动,AgentLoop 不包含固定搜索计划器。
 
-### 5.3 分享证据与年龄证据不能互相替代
+### 6.2 搜索来源
 
-- `share_count` 说明传播规模,不说明分享者年龄;
-- 点赞用户或作者粉丝年龄画像说明受众倾向,不说明已经发生转发;
-- 当前只能推断“老年人可能愿意分享”,不能声称已观察到老年分享者;
-- 分享价值同时参考分享规模、分享效率和内容动机。
+| 来源 | 当前行为 |
+|---|---|
+| `douyin_search` | 内部关键词搜索;返回标题、作者、点赞、评论、分享和分页游标 |
+| `douyin_search_tikhub` | TikHub 独立搜索;额外返回话题、收藏、播放、时长及完整分页状态 |
+| `douyin_user_videos` | 按作者 `sec_uid` 获取最热或最新作品 |
 
-### 5.4 缺失不是负证据
+内部关键词搜索和作者作品接口在进程内分别执行至少 `10.1` 秒的调用间隔控制;TikHub
+搜索执行至少 `1` 秒的调用间隔控制。
 
-- 数据缺失或接口失败应标记为未知;
-- 未知会降低置信度,但不自动计为零分;
-- 强反证优先于多个弱正向线索;
-- 不得为了达到推荐数量而降低准入标准。
+TikHub 翻页需要原样复用上一页的 `next_cursor`、`search_id`、`backtrace`。三个来源的
+游标彼此独立。
 
-### 5.5 推荐集合需要新增价值
+### 6.3 搜索页持久化
 
-- 高度重复的视频只保留证据更强的一条;
-- 价值接近时优先覆盖不同需求点和分享动机;
-- 合理搜索后允许少于 5 条或返回空结果。
+搜索工具在外部接口返回后自动完成持久化。当前处理规则为:
 
-## 6. 当前工具
+1. 每次搜索、每一页都新增一条 `video_discovery_search`,相同参数重复执行也不会覆盖;
+2. `page_no > 1` 时强制将来源类型改为 `pagination`;
+3. 根来源自动清空 `parent_search_id`;
+4. 搜索失败也新增搜索记录,保存 `failed` 和原始错误,不新增候选;
+5. 搜索成功后,本页每条有效结果都新增一条 `video_discovery_candidate`;
+6. 候选通过 `search_id` 直接关联本次搜索;
+7. 同一 `aweme_id` 被不同搜索命中时允许重复插入,每条记录拥有独立 `candidate_id`;
+8. 新候选状态统一为 `pending_evaluation`;
+9. 搜索工具返回视频基础信息,并把数据库生成的 `search_id/candidate_id` 拼入结果。
 
-| 工具 | 当前用途 | 关键限制 |
-|---|---|---|
-| `douyin_search` | 内部关键词召回 | 搜索结果不是最终事实 |
-| `douyin_search_tikhub` | TikHub 搜索与分页 | 翻页必须复用供应方分页参数 |
-| `douyin_user_videos` | 扩展作者作品 | 作品仍需逐条补证和判断 |
-| `douyin_detail` | 批量核验视频详情 | 单次最多 8 条 |
-| `get_content_fans_portrait` | 获取单条视频点赞用户画像 | 不是分享用户画像 |
-| `get_account_fans_portrait` | 获取作者粉丝画像 | 只代表账号受众先验 |
-| `batch_fetch_portraits` | 批量获取视频和作者画像 | 单次最多 8 条 |
-| `normalize_age_portraits` | 标准化年龄桶 | 不负责业务评分 |
-| `create_video_discovery_run` | 创建或复用运行状态 | 传入 `run_id` 时必须复用 |
-| `record_video_search_page` | 保存搜索页并合并候选 | 每次搜索后均需调用 |
-| `batch_save_video_candidate_evaluations` | 保存证据、评分和分池 | 只接受 `primary / rejected` |
-| `audit_video_discovery_run` | 审计 Agent 最终状态 | `can_finish=true` 才能完成 |
-| `query_video_discovery_state` | 恢复或读取最终状态 | 最终查询必须晚于成功审计 |
+## 7. 证据获取逻辑
 
-`qwen_video_analyze` 未注册,不属于当前 Agent 能力。
+### 7.1 详情核验
 
-## 7. 当前运行约束
+`douyin_detail` 单次最多处理 8 个视频 ID,用于更新:
 
-| 项目 | 当前设置 |
-|---|---|
-| 模型 | 固定为 `google/gemini-3-flash-preview` |
-| 最大迭代轮次 | 60 |
-| 温度 | 0.2 |
-| 单批详情/画像候选数 | 最多 8 条 |
-
-`create_find_agent(..., model=...)` 和 `run_find_agent(..., model=...)` 虽然暴露了模型参数,
-当前工厂仍固定使用上述模型,传入参数不会生效。
-
-## 8. 当前实现评价
-
-| 能力 | 状态 | 当前判断 |
-|---|---|---|
-| 需求理解 | 可用 | 能结合需求词、参考标题和相关点形成搜索假设 |
-| 多源搜索 | 可用 | 内部搜索、TikHub 和作者作品均已注册 |
-| 候选去重 | 可用 | 支持跨词、跨页和跨来源按 `aweme_id` 合并 |
-| 详情与画像 | 可用但有缺口 | 支持详情、双侧画像和年龄标准化,画像可能缺失 |
-| 内容理解 | 受限 | 仅使用文本、互动和画像,不使用视频理解 |
-| 评分与分池 | 部分可用 | 规则完整,但主要依赖模型遵守长提示词 |
-| 状态保存 | 可用 | 可保存运行、搜索页和候选状态 |
-| 完成审计 | 业务侧 | 由工具 `audit_video_discovery_run` + `run_outcome` 判定;框架无完成守卫 |
-| 故障终止 | 已接入 | Agent 明确声明工具故障时允许失败结束 |
-| 最终一致性 | 部分可用 | 报告只读并接受校验,尚无单一事务性最终化入口 |
-| 运行恢复 | 部分可用 | 能查询旧状态,但缺少执行代次和自动恢复机制 |
-| 可观测性 | 不完整 | 有日志和基础计数,缺少 Agent 级质量与成本指标 |
-
-## 9. 需要优化的点
-
-### 9.1 P0:提高 Agent 的确定性与一致性
-
-1. **增加事务性最终化**
-   - 新增单一 `finalize_video_discovery_run` 能力;
-   - 仅允许最终化当前运行已召回且已评估的候选;
-   - 在一个事务内校验并写入候选分池、计数、完成状态和最终摘要;
-   - 最终化失败必须显式返回错误并支持安全重试。
-
-2. **将关键约束从长提示词下沉到程序**
-   - 用结构化节点控制搜索、补证、评估、审计和最终输出;
-   - 对必需证据、合法状态和工具调用顺序做程序校验;
-   - 保留 LLM 对意图、搜索词、语义相关性和解释的判断空间。
-
-3. **结构化模型输出**
-   - 为搜索计划、候选评估和最终结果定义 JSON Schema;
-   - 校验 `R / E / S` 取值、必填证据、置信度和分池;
-   - 由程序计算 `V`,避免模型计算漂移;
-   - 禁止模型输出未召回的候选 ID。
-
-4. **修复模型配置**
-   - 让显式传入的 `model` 参数真正生效;
-   - 保存实际使用的模型、Prompt 和评分策略版本;
-   - 避免固定依赖预览模型。
-
-5. **补齐 Agent 契约测试**
-   - 覆盖未保存搜索页、证据晚于评估、审计失败、旧状态查询和报告分池不一致;
-   - 覆盖幻觉候选 ID、非法分池、缺失证据;
-   - 用固定样例验证相关性闸门、年龄证据层级和未知数据处理。
-
-### 9.2 P1:提高搜索与判断质量
-
-1. **优化搜索前沿选择**
-   - 结构化记录每个搜索假设的预期价值、实际新增候选和耗时;
-   - 根据边际新增率决定翻页、标签扩展和作者扩展;
-   - 减少同义词重复搜索及低价值工具调用。
-
-2. **优化候选预筛与补证**
-   - 将低成本预筛规则结构化;
-   - 优先为可能改变分池的候选补充详情和画像;
-   - 对重复详情和画像增加短期缓存;
-   - 记录每项缺失证据对置信度的具体影响。
-
-3. **建立评分评测集**
-   - 建立覆盖不同题材的人工标注样例;
-   - 分别评估 `R / E / S`,避免只看最终分池;
-   - 统计 `primary / rejected` 混淆情况;
-   - 按模型、Prompt 和策略版本回放对比。
-
-4. **增强 Agent 可观测性**
-   - 记录搜索新增率、候选漏斗、证据缺失率、工具失败率和单次调用成本;
-   - 区分模型判断失败、外部接口失败、持久化失败和完成校验失败;
-   - 输出本次使用的模型、Prompt、策略和数据源版本。
-
-5. **改进运行恢复**
-   - 为同一 `run_id` 增加执行代次;
-   - 恢复时明确区分已完成步骤、可复用证据和需要重试的失败项;
-   - 避免新一轮执行混用已过期的搜索或候选判断。
-
-### 9.3 P2:升级 Agent 可用证据
-
-1. 接入真实转发用户年龄分布和样本量;
-2. 接入分年龄曝光、有效播放、完播率和观看时长;
-3. 建立同题材、同发布时间窗口的传播基线;
-4. 增加评论中的提醒、家庭沟通、收藏和求链接等结构化意图信号;
-5. 在获得明确授权和完整验证前,继续保持“不使用视频理解”的能力边界;
-6. 将新证据的计算规则、置信度上限和冲突处理策略版本化。
-
-## 10. Agent 验收标准
-
-一次 `find_agent` 运行满足以下条件,才视为 Agent 自身完成:
-
-1. 已创建或复用唯一 `run_id`;
-2. 实际调用的搜索页均已保存;
-3. 候选已按 `aweme_id` 去重;
-4. 主推荐已尝试获取详情和双侧年龄画像;
-5. 年龄画像已标准化,缺失和冲突已披露;
-6. 每个已评估候选具有合法分池及对应理由;
-7. 最终不存在 `pending_evaluation` 或其他非法分池;
-8. 数据库审计返回 `can_finish=true`;
-9. 审计后已重新查询最终状态;
-10. 最终报告与数据库 `primary / rejected` 完全一致;
-11. 不使用未注册的视频理解能力或编造缺失证据;
-12. 推荐不足 5 条时不降低质量标准。
-
-若工具故障导致任务无法继续,则不适用上述成功条件,但必须满足:
-
-1. Agent 已根据上下文判断继续调用无法产生有效进展;
-2. Agent 已停止重复调用失败工具;
-3. 最终摘要明确标记 `任务未完成(工具故障)`;
-4. 摘要包含失败工具、原始错误、已完成内容和未完成内容;
-5. 不输出看似有效的推荐结果。
+- 标题和描述;
+- 作者信息;
+- 话题标签;
+- 页面链接;
+- 播放、点赞、评论、收藏和分享数据;
+- 发布时间和视频时长。
+
+详情工具返回证据,不直接修改候选数据库。模型需要再次调用候选评估保存工具才能写入。
+
+### 7.2 年龄画像
+
+年龄证据包含两个独立来源:
+
+- 视频点赞用户画像:内容侧直接证据;
+- 作者粉丝画像:账号侧先验。
+
+`batch_fetch_portraits` 单次最多处理 8 个候选,按输入顺序逐条请求内容画像;设置
+`fetch_account_portrait=true` 时同时请求作者画像。每条结果自动生成标准化年龄结果。
+
+单条画像工具只返回原始画像,需要额外调用 `normalize_age_portraits`。
+
+### 7.3 年龄标准化
+
+年龄标准化把画像桶归为:
+
+- `older`:明确覆盖 50 岁及以上;
+- `mature`:覆盖 40 岁以上但不能作为直接老年桶;
+- `younger`;
+- `unknown`。
+
+每侧画像根据老年占比、老年 TGI 和成熟人群占比确定:
+
+- `strong`
+- `moderate`
+- `weak`
+- `missing`
+
+双侧结果再生成:
+
+- `aligned`:两侧强弱方向一致;
+- `conflict`:两侧强弱方向冲突;
+- `content_only`;
+- `account_only`;
+- `missing`。
+
+标准化工具还返回 `elder_score_cap`:双侧、仅内容侧为 `1.0`,仅账号侧为 `0.65`,两侧
+均缺失为 `0.35`。该上限只作为返回给模型的决策信息,保存工具不会执行分数上限校验。
+
+## 8. 候选判断逻辑
+
+### 8.1 三项命题
+
+模型分别判断:
+
+- `R`:候选是否满足需求真实意图;
+- `E`:视频点赞用户和作者粉丝画像是否支持较高年龄受众倾向;
+- `S`:候选是否同时具备传播行为信号和可解释分享动机。
+
+最终目标为 `R ∩ E ∩ S`。相关性是准入闸门,分享规模和年龄倾向不能弥补低相关。
+
+### 8.2 证据使用顺序
+
+当前 Prompt 要求:
+
+1. 先用搜索标题、描述、话题和互动数据进行低成本预筛;
+2. 默认最多选 8 条高潜候选进入详情和双画像阶段;
+3. 视频画像优先于作者画像;
+4. 双侧一致时增强置信度,冲突时优先视频画像并降低置信度;
+5. 数据缺失视为未知,不作为负证据;
+6. 高度重复候选只保留证据更强的一条;
+7. 价值相近时优先覆盖不同需求点或分享动机。
+
+### 8.3 评分与分池
+
+Prompt 定义联合价值关系:
+
+`V = R^0.40 × E^0.35 × S^0.25`
+
+数据库中的 `R/E/S/V` 都使用 `0~1` 小数。当前保存实现不会计算 `V`、不会校验分数
+范围,也不会根据分数调整分池。`R/E/S` 最多保留 6 位小数,`V` 最多保留 2 位小数;
+无法转换为数字的值按缺失处理。
+
+最终分池只接受:
+
+- `primary`
+- `rejected`
+
+分池完全由模型决定。更新层仅校验候选 ID 和枚举合法性,不校验:
+
+- `R/E/S/V` 是否齐全或在 `0~1` 范围;
+- `V` 是否符合公式;
+- `primary` 是否满足证据条件;
+- 最终理由是否齐全。
+
+候选更新必须使用搜索工具返回的 `candidate_id`。不存在或不属于当前 `run_id` 时整批
+失败,不允许补建候选,也不允许用 `aweme_id` 更新同视频的其他搜索记录。候选更新工具
+只修改 `video_discovery_candidate`;运行状态由独立工具修改。
+
+## 9. 结束与输出流程
+
+Prompt 规定的正常结束流程为:
+
+`停止搜索 → 获取必要证据 → 按 candidate_id 更新已作出的候选判断
+→ 将运行设为 finished → 输出`
+
+`query_video_discovery_state` 保留为按需恢复和查看已持久化状态的工具,不是正常结束的
+强制步骤。AgentLoop 没有完成守卫:模型任何一轮只要返回不含工具调用的普通消息,就会
+立即结束。
+
+## 10. 结束、超时与失败状态
+
+### 11.1 模型循环结束
+
+模型返回普通消息后生成 `AgentResult`,其中记录:
+
+- 最终文本;
+- 完整运行消息;
+- 已执行迭代数;
+- 工具调用数;
+- 已加载 Skill。
+
+随后关闭异步 LLM 客户端并清理未完成异步任务。
+
+运行日志和可视化产物在核心循环结束后发布。发布等待设置为 120 秒;超时或异常只记录
+日志,不改变 `AgentResult`。当前使用线程池上下文执行发布,超时被捕获后退出线程池时
+仍可能继续等待发布线程真正结束,因此 120 秒不是完整调用链的硬停止时间。
+
+### 11.2 超时
+
+超过单次运行总超时后:
+
+1. 取消 Agent 核心协程;
+2. 关闭异步客户端;
+3. 最多等待 5 秒清理后台异步任务,仍未完成的任务被取消;
+4. 抛出 `TimeoutError`;
+5. 调度包装层将对应运行标记为 `failed`,保存超时原因。
+
+### 11.3 异常
+
+Agent 运行或结果判定期间出现未处理异常时,调度包装层把运行状态标记为 `failed`,
+保存异常文本并继续向上抛出。
+
+工具返回的结构化错误不是未处理异常,不会自动将运行标记为 `failed`。是否停止、切换
+工具或输出“任务未完成(工具故障)”由模型判断。
+
+### 11.4 当前成功判定
+
+模型循环返回后,调度侧的当前成功判定只检查:
+
+`该 run_id 下是否存在至少一条候选记录`
+
+只要存在任意候选,即判定本次执行成功。该候选可以是:
+
+- `pending_evaluation`;
+- `primary`;
+- `rejected`;
+- 仅由搜索页自动创建、尚未补证的候选。
+
+成功判定不检查:
+
+- 运行状态是否为 `finished`;
+- 是否存在 `primary`;
+- 最终文本是否符合输出契约。
+
+如果一条候选都不存在,调度侧将运行标记为 `failed`,失败原因为 `no_candidates`。模型
+最终文本只作为失败原因预览附加保存,不参与成功判定。
+
+## 11. 当前约束归属
+
+| 约束 | Prompt 驱动 | 代码硬约束 |
+|---|---:|---:|
+| 所有存储和状态工具复用预创建 `run_id` | 是 | 是,相关工具均要求 `run_id` |
+| 形成 2~3 个根搜索词 | 是 | 否 |
+| 每次搜索新增并保存搜索页 | 否 | 是 |
+| 每条搜索结果新增独立候选并返回 ID | 否 | 是 |
+| 页码大于 1 自动归为翻页 | 否 | 是 |
+| 根搜索不保留父搜索 ID | 否 | 是 |
+| 详情单批最多 8 条 | 否 | 是 |
+| 画像单批最多 8 条 | 否 | 是 |
+| 不使用视频理解 | 是 | 是,工具未注册 |
+| 评分使用 `0~1` | 是 | 否 |
+| `V` 按公式计算 | 是 | 否 |
+| 最终分池仅 `primary/rejected` | 是 | 是 |
+| `primary` 具备足够详情和双画像证据 | 是 | 否 |
+| 运行最大 60 次模型迭代 | 否 | 是 |
+| 单次运行总超时 | 否 | 是 |
+| 调度成功必须存在候选 | 否 | 是 |
+
+## 12. 当前执行伪代码
+
+```text
+load contexts
+  -> 过滤无有效点位或无有效参考视频的需求
+  -> 按 score、grade、名称、ID 排序
+
+for each context:
+  run_id = prepare_or_reuse_run(status="running")
+  if finished or has_any_candidate and not force:
+      skip
+
+  user_input = build_agent_input(context, run_id)
+  agent = create_find_agent(model, temperature=0.2, max_iterations=60)
+
+  try:
+      repeat up to 60 iterations:
+          response = LLM(system_prompt + message_history + tool_schemas)
+          if response has no tool_calls:
+              agent_result = response
+              break
+
+          for tool_call in response.tool_calls:
+              tool_result = await execute(tool_call)
+              append tool_result to message_history
+
+      if 60 iterations exhausted:
+          agent_result = LLM("provide best answer", tools=None)
+
+      success = database.has_any_candidate(run_id)
+      if not success:
+          mark_run_failed("no_candidates")
+  except timeout or exception:
+      mark_run_failed(error)
+      raise
+  finally:
+      close async resources
+
+  publish run logs
+    -> report timeout after 120 seconds
+    -> thread-pool shutdown may still wait for the publisher to exit
+```

+ 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` 报告。

+ 2 - 0
agents/find_agent/__init__.py

@@ -24,6 +24,7 @@ _PUBLISH_TIMEOUT_SECONDS = 120.0
 def run_find_agent(
     user_input: str,
     *,
+    run_id: str,
     settings: Settings | None = None,
     model: str | None = None,
 ) -> AgentResult:
@@ -37,6 +38,7 @@ def run_find_agent(
         arun_find_agent(
             agent,
             user_input,
+            run_id=run_id,
             timeout_seconds=find_agent_timeout_seconds(),
         )
     )

+ 5 - 0
agents/find_agent/async_runner.py

@@ -5,6 +5,9 @@ import asyncio
 import logging
 import threading
 
+from agents.find_agent.completion_guard import (
+    configure_find_agent_completion_guard,
+)
 from supply_agent.agent.core import Agent
 from supply_agent.types import AgentResult
 
@@ -68,9 +71,11 @@ async def arun_find_agent(
     agent: Agent,
     user_input: str,
     *,
+    run_id: str,
     timeout_seconds: float,
 ) -> AgentResult:
     """在单个事件循环内运行 find_agent 核心循环并确保资源释放(不含 OSS 发布)。"""
+    configure_find_agent_completion_guard(agent, run_id)
     try:
         return await asyncio.wait_for(
             agent.arun_core(user_input),

+ 60 - 0
agents/find_agent/completion_guard.py

@@ -0,0 +1,60 @@
+"""find_agent 基于持久化运行状态的结束守卫。"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import TYPE_CHECKING
+
+from supply_agent.types import CompletionGuard, Message
+from supply_infra.services.video_discovery_service import (
+    get_video_discovery_service,
+)
+
+if TYPE_CHECKING:
+    from supply_agent.agent.core import Agent
+
+_TERMINAL_STATUSES = {"finished", "failed"}
+
+
+def create_find_completion_guard(run_id: str) -> CompletionGuard:
+    """创建只允许已进入 finished / failed 终态的 find_agent 结束守卫。"""
+    normalized_run_id = str(run_id or "").strip()[:64]
+
+    def guard(_response: Message, _messages: Sequence[Message]) -> str | None:
+        if not normalized_run_id:
+            return (
+                "当前用户消息中缺少 run_id,无法确认运行状态。"
+                "请不要直接输出最终结果。"
+            )
+
+        run = get_video_discovery_service().lookup_run(normalized_run_id)
+        if run is None:
+            return (
+                f"run_id={normalized_run_id} 不存在,无法确认运行状态。"
+                "请不要直接输出最终结果。"
+            )
+
+        status = str(run.get("status") or "").strip()
+        if status in _TERMINAL_STATUSES:
+            return None
+
+        return (
+            f"当前 run.status 仍为 {status or 'unknown'}。"
+            "请继续处理;正常完成后先调用 "
+            "update_video_discovery_run_status 将状态更新为 finished,"
+            "无法完成时将状态更新为 failed,再输出最终结果。"
+        )
+
+    return guard
+
+
+def configure_find_agent_completion_guard(agent: Agent, run_id: str) -> None:
+    """在 find_agent 循环创建前按需注入状态结束守卫。"""
+    if getattr(agent, "completion_guard", None) is not None:
+        return
+    agent.completion_guard = create_find_completion_guard(run_id)
+
+
+__all__ = [
+    "configure_find_agent_completion_guard",
+    "create_find_completion_guard",
+]

+ 26 - 14
agents/find_agent/demand_run.py

@@ -10,6 +10,7 @@ from datetime import datetime
 from typing import Any
 from zoneinfo import ZoneInfo
 
+from supply_infra.video_discovery_gates import build_rule_snapshot
 from supply_agent.types import AgentResult
 from supply_infra.config import get_infra_settings
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
@@ -64,7 +65,7 @@ class FindDemandContext:
 
 @dataclass
 class FindDemandExecutionResult:
-    """单条需求找执行结果。"""
+    """单条需求找视频执行结果。"""
 
     run_id: str | None = None
     skipped: bool = False
@@ -273,6 +274,7 @@ def prepare_video_discovery_run(
 ) -> tuple[str | None, str | None]:
     """执行 Agent 前预创建 video_discovery_run,返回 (run_id, skip_reason)。"""
     payload = build_run_input_payload(ctx)
+    rule_snapshot = build_rule_snapshot()
     primary = ctx.primary_video
     values = {
         "run_id": uuid.uuid4().hex,
@@ -285,6 +287,8 @@ def prepare_video_discovery_run(
         "intent_summary": None,
         "status": "running",
         "stop_reason": None,
+        "rule_version": rule_snapshot["rule_version"],
+        "rule_config_json": json.dumps(rule_snapshot, ensure_ascii=False),
     }
     run_id, skip_reason = get_video_discovery_service().prepare_scheduled_run(
         biz_dt=ctx.biz_dt,
@@ -338,25 +342,28 @@ def _flatten_relevant_points(ctx: FindDemandContext) -> list[dict[str, Any]]:
     return relevant_points
 
 
-def build_find_agent_user_input(ctx: FindDemandContext, run_id: str) -> str:
+def build_find_agent_user_input(
+    ctx: FindDemandContext,
+    run_id: str,
+    *,
+    rule_snapshot: dict[str, Any] | None = None,
+) -> str:
     """构建传给 find_agent 的用户消息。"""
     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 ""
+    active_rules = rule_snapshot or build_rule_snapshot()
 
     return (
         f"run_id:{run_id}\n"
         f"demand_grade_id:{ctx.demand_grade_id}\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"current_datetime:{active_rules['current_datetime']}\n"
+        f"current_date:{active_rules['current_date']}\n"
+        f"timezone:{active_rules['timezone']}\n"
+        f"quality_gate_rules:{json.dumps(active_rules, ensure_ascii=False)}\n"
         f"reference_videos:{json.dumps(videos_payload, ensure_ascii=False)}\n"
-        "说明:video_discovery_run 已由系统预创建,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}。"
     )
 
 
@@ -381,11 +388,16 @@ def discover_videos_for_demand(
     if not run_id:
         raise RuntimeError("prepare_video_discovery_run 未返回 run_id")
 
-    user_input = build_find_agent_user_input(ctx, run_id)
+    run_snapshot = get_video_discovery_service().lookup_run(run_id) or {}
+    user_input = build_find_agent_user_input(
+        ctx,
+        run_id,
+        rule_snapshot=run_snapshot.get("rule_config"),
+    )
     try:
         from agents.find_agent.run_outcome import evaluate_find_agent_run
 
-        agent_result = run_find_agent(user_input)
+        agent_result = run_find_agent(user_input, run_id=run_id)
         outcome = evaluate_find_agent_run(run_id, agent_result)
         if not outcome.succeeded:
             stop_reason = outcome.failure_reason or "incomplete"

+ 122 - 117
agents/find_agent/prompt/system_prompt.md

@@ -1,10 +1,17 @@
 # 角色与唯一目标
 
-你是短视频供给发现 Agent。用户会给出:
+你是短视频供给发现 Agent。调度用户消息会给出:
 
-- `demand_word`:需求词,定义这次寻找的真实意图边界;
-- `seed_video_title`:已知相关视频的标题,是理解语境的证据;
-- `relevant_points`:该视频中与需求词相关的一个或多个点,是理解用户究竟关注什么的证据。
+- `run_id`:系统预创建的视频发现运行 ID,所有存储工具必须复用它;
+- `demand_grade_id` 和 `demand_word`:需求记录 ID 与需求词,定义这次寻找的真实意图边界;
+- `current_datetime / current_date / timezone`:本次判断使用的当前时间上下文;
+- `quality_gate_rules`:本次运行固化的时长、分享数、50+ 占比和时效规则快照;
+- `reference_videos`:全部参考视频。每项包含 `video_id`、`title` 和该视频对应的
+  `points`。
+
+必须综合全部 `reference_videos[].title` 和 `reference_videos[].points` 理解需求,
+不能只读取第一条参考视频。`video_discovery_run` 已由调度程序预创建;直接复用用户消息
+中的 `run_id` 执行搜索、候选更新和状态管理,不要自行创建运行。
 
 你的唯一目标是:从抖音搜索结果中找出一小组**与需求真正相关,并且老年受众更可能观看和转发**的视频。
 
@@ -20,9 +27,12 @@
 对候选视频 `v`,定义三个彼此独立的命题:
 
 - `R(v)`(需求相关性):视频是否满足 `demand_word` 在标题和相关点所限定的具体意图;
-- `E(v)`(老年受众倾向):视频点赞用户画像与作者粉丝画像是否支持受众偏向较高年龄段
+- `E(v)`(内容侧老年受众倾向):视频点赞用户画像中的 50+ 占比是否支持受众偏老
 - `S(v)`(分享价值):视频是否已经表现出值得转发的行为信号和内容理由。
 
+作者粉丝画像单独记为账号先验 `A(v)`,不得与 `E(v)` 相加或平均。`A(v)` 可以增强或
+降低结论置信度,但不能替代视频侧画像,也不能使视频侧 50+ 门槛失败的候选通过。
+
 最终寻找的是联合事件:
 
 `G(v) = R(v) ∩ E(v) ∩ S(v)`
@@ -35,6 +45,24 @@
 `pending_evaluation` 只是搜索召回后的过程状态,不是最终等级。不存在 `backup`、
 补充推荐或人工备选等级。
 
+# P0 程序硬门槛
+
+用户消息中的 `quality_gate_rules` 是本次运行唯一有效的阈值快照。候选进入 `primary`
+前必须同时满足:
+
+1. 真实发布时间可用,且基础日期、节日、时段和内容新鲜度判断为有效;
+2. `duration_seconds >= min_duration_seconds`;
+3. 最新详情中的 `share_count >= min_share_count`;
+4. 视频点赞用户画像中的
+   `content_50_plus_ratio >= min_content_50_plus_ratio`。
+
+搜索工具会过滤已知时长不足 30 秒的结果;未知时长仍可能进入待补证候选,但必须通过
+`douyin_detail` 补齐后才能保存为 `primary`。分享数、发布时间或视频侧 50+ 占比缺失
+均属于证据不足,不能进入 `primary`。
+
+`batch_update_video_discovery_candidates` 会在保存层重新执行硬门槛。不得通过提高
+`R/E/S/V` 分数、使用账号画像或在理由中声称“综合表现较好”绕过门槛。
+
 # 决策公理与定理
 
 ## 1. 需求闸门公理
@@ -42,7 +70,8 @@
 相关性是**主推荐池**的准入条件,不是加分项。一个高分享、老年粉丝很多但没有
 回答本次需求的视频不能保留,最终必须进入 `rejected`。
 
-`seed_video_title` 和 `relevant_points` 用来消除需求词的歧义、提炼事件/人物/场景/用途及同义表达;它们不是必须逐字匹配的搜索条件。搜索词只是召回假设,不能成为候选合格的证据。
+`reference_videos` 中的标题和点位用来消除需求词的歧义、提炼事件/人物/场景/用途及
+同义表达;它们不是必须逐字匹配的搜索条件。搜索词只是召回假设,不能成为候选合格的证据。
 
 ## 2. 搜索词自主权公理
 
@@ -67,9 +96,8 @@
   `parent_search_id`;`tag / author / pagination` 表示扩展分支,才设置父搜索。
   保存工具会按这一语义自动规范根节点和翻页节点。
 
-在保留候选不足 5 条时,优先验证不同的根搜索假设,并对产生新增候选的页面做翻页或
-标签扩展。达到 5 条后,未完成但价值较低的根搜索、翻页或标签前沿只作为 warning。
-若合理搜索前沿已经耗尽,少于 5 条也允许结束,不能为了数量扩大到明显低质内容。
+优先验证语义不同的根搜索假设,并对能够产生有效新增信息的页面做翻页或标签扩展。
+当剩余搜索前沿不再可能改变候选判断、排序或置信度时即可停止。
 
 ## 4. 分享—年龄不可替代定理
 
@@ -89,13 +117,17 @@
    适配特征;
 4. 题材或作者形象带来的直觉。
 
-第 4 层不得单独形成老年倾向结论。视频画像代表“这条内容吸引了谁”,作者画像代表“这个账号通常触达谁”;前者是直接内容证据,后者是账号先验。两者一致时增强置信度;冲突时优先视频画像并显式降置信度,不得静默平均。
+第 4 层不得单独形成老年倾向结论。视频画像代表“这条内容吸引了谁”,作者画像代表
+“这个账号通常触达谁”;两侧必须分别给出 `content_portrait_status` 和
+`account_portrait_status`。两者一致时增强置信度;冲突时保留 `portrait_conflict`,
+但始终以视频侧门槛为准,不得静默平均。
 
 年龄桶中,明确覆盖 `50岁及以上` 的桶才是直接老年信号;接口实际可能用 `50-`
 表示“50岁以上”,必须用 `normalize_age_portraits` 标准化后再判断。只提供
 `40岁及以上` 时只能称为“成熟人群代理信号”。同时观察占比和偏好度/TGI:占比回答
 “人多不多”,偏好度回答“相对平台基线是否更偏爱”。若接口没有给出中性基线,不得
-臆造阈值。
+臆造阈值。`40~49` 或 `41~50` 只能作为成熟人群辅助信息,不得计入
+`content_50_plus_ratio`。
 
 ## 6. 相对传播定理
 
@@ -111,19 +143,14 @@
 
 对 `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` 是帮助你保持判断一致的参考量,**不是程序校验线**。候选最终进入
-`primary / rejected` 完全由你根据全部证据判断。`batch_save_video_candidate_evaluations`
-会 **原样保存** 你给出的 `0~1` 分数,不会重算、换算或改写 `decision_bucket`。
-`audit_video_discovery_run` **不校验分数**,只审计证据完备性、分池合法性和搜索覆盖。
-
-优先目标是保留至少 5 条质量可靠的 `primary`,可以超过 5 条。5 条是搜索和筛选的
-优先目标,不是硬性准入线:
-在合理搜索、翻页和扩展后确实没有更多好视频时,允许少于 5 条,禁止为凑数保留明显
-低质、低相关或缺乏基本证据的候选。
+`R/E/S/V` 用于语义判断和排序,不能替代程序硬门槛。候选是否语义相关仍由你判断;
+当你请求保存为 `primary` 时,保存层会校验时效、时长、分享数和视频侧 50+ 占比。
+`R/E/S` 最多保留 6 位小数,`V` 最多保留 2 位小数,必须自行确保所有分数在 `0~1`
+内。
 
 低相关候选即使原始分享规模、分享效率或老年倾向很强,也不能进入 `primary`。
 
@@ -132,6 +159,9 @@
 一个强反证比多个弱正向线索更重要。实际内容若围绕青少年校园、年轻圈层黑话、需要特定年轻文化背景,或画像明显偏年轻,应降低老年倾向;但剪辑快、使用网络表达等单个风格特征不能直接证明老年人不喜欢。
 
 缺失数据不是负证据,接口失败也不是零分。应标为“未知”并降低置信度,绝不能把未知写成不适合。
+但 `primary` 要求 `R / E / S` 共同成立;关键年龄证据缺失、导致 `E` 只能判为未知时,
+候选不得进入 `primary`。此时应以“证据不足以进入主推荐”归入 `rejected`,而不是声称
+画像证明其不适合老年受众。
 
 ## 9. 多样性边际定理
 
@@ -143,15 +173,28 @@
 
 # 工具的证据含义
 
-- `douyin_search`:用于召回候选并取得初始互动量。搜索结果不是最终事实,重复候选按 `aweme_id` 去重。
+- `batch_search_and_record`:默认搜索入口。一次提交多个关键词及形成原因,每个任务可
+  搜索 1~2 页;工具在每页返回后创建搜索记录并把本页结果逐条写入候选表。优先用它完成
+  2~3 个根搜索。每次搜索和每条候选都会生成新的数据库记录,返回结果中的
+  `search_id / candidate_id` 是后续更新依据;同一 `aweme_id` 在不同搜索中对应不同
+  `candidate_id`。所有搜索源的客户端最短时长固定不低于 30 秒;对于强时效需求,
+  必须依据 `current_datetime` 选择“一天内/一周内/半年内”等发布时间参数。
+- `douyin_search`:用于单次内部关键词搜索和特殊场景回退。必须传入 `run_id`、搜索
+  原因和来源;工具返回前自动保存本页搜索轨迹及候选。
 - `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`:当候选作者的粉丝画像偏老、或其视频具有较高主推荐
   潜力时,按最热或最新扩展作者作品。作者作品属于 `author` 搜索分支,仍需逐条判断
-  相关性、老年倾向和分享价值,不能因作者优秀就直接推荐。
-- `douyin_detail`:用于核验候选的最新互动数据、作者、页面链接和标题/描述等文本证据。
+  相关性、老年倾向和分享价值,不能因作者优秀就直接推荐。工具会自动保存作者搜索页
+  和候选。
+- `douyin_detail`:用于核验候选的真实发布时间、时长、最新分享数与其他互动数据,
+  以及作者、页面链接和标题/描述等文本证据。最终候选更新必须把
+  `publish_at / duration_seconds / share_count` 一并写回。
 - `batch_fetch_portraits`:用于批量取得视频点赞画像;对正式候选应设置
   `fetch_account_portrait=true`,同时取得作者粉丝画像。批量结果会自动附带
   `age_normalization`,无需为同一候选再单独调用标准化工具。
@@ -159,112 +202,74 @@
 - `normalize_age_portraits`:把视频与作者画像中的 `50- / 50+ / 50岁以上 / 41-50`
   等年龄桶统一为直接老年比例、TGI、成熟代理比例和证据强度;取得画像后必须调用,
   不得自行猜测 `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`。更新正式候选时必须
+  提供最新的 `publish_at / duration_seconds / share_count`、双侧画像及
+  `age_normalization`。工具会从标准化结果拆出视频侧和账号侧 50+ 指标,并在保存
+  `primary` 前执行程序硬门槛;失败时根据返回的原因码补证或改为 `rejected`。工具不会
+  新增候选、修改搜索记录或修改运行状态。`decision_reason` 必须分别覆盖相关性、视频
+  侧 50+、账号侧画像、分享价值、时间有效性和主要限制。
+  同一 `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页,除非第二页仍显著
-  提升主推荐质量;
-- 搜索结果先按需求相关性、分享规模/效率和主推荐潜力做廉价预筛,默认最多选择8条进入
-  `douyin_detail` 和双画像阶段;
-- 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
-  不得依赖或声称使用了视频画面、语音、字幕解析;
+- 根搜索默认形成 2~3 个语义不同的词;每个生产性首页最多继续 1 页,除非第二页仍能
+  显著提升判断质量;
+- 搜索结果先按需求相关性、已知时长、分享规模/效率、时间有效性和主推荐潜力做廉价
+  预筛,然后进入 `douyin_detail` 和双画像阶段;
+- 内容相关性与分享动机主要依据标题、描述、`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 判断;时效、时长、分享数和视频侧 50+ 占比由
+程序按本次规则快照做不可绕过的准入校验。
 
-禁止输出没有证据支撑的年龄结论,禁止把“内容讲老人”写成“观看者是老人”,禁止为了满足数量而推荐低相关视频。
+禁止输出没有证据支撑的年龄结论,禁止把“内容讲老人”写成“观看者是老人”,禁止推荐
+低相关视频。

+ 4 - 1
agents/find_agent/run.py

@@ -12,13 +12,16 @@ def main() -> None:
 
     while True:
         try:
+            run_id = input("run_id> ").strip()
+            if not run_id or run_id.lower() in ("exit", "quit", "q"):
+                break
             user_input = input("You> ").strip()
         except (EOFError, KeyboardInterrupt):
             print("\nBye.")
             break
         if not user_input or user_input.lower() in ("exit", "quit", "q"):
             break
-        result = run_find_agent(user_input)
+        result = run_find_agent(user_input, run_id=run_id)
         print(f"\nAgent> {result.content}\n")
 
 

+ 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
 
 import json
 import re
 from typing import Any
 
-from supply_agent.tools import tool
-
-_ROOT_SOURCE_TYPES = {"demand", "seed", "point", "mixed"}
-
 
 def _number(value: Any) -> float | None:
     if value is None or isinstance(value, bool):
@@ -187,7 +183,6 @@ def normalize_age_portrait_pair(
     }
 
 
-@tool
 def normalize_age_portraits(
     content_portrait: dict[str, Any],
     account_portrait: dict[str, Any] | None = None,
@@ -220,166 +215,3 @@ def normalize_age_portraits(
         ),
     }
     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)

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

@@ -0,0 +1,375 @@
+"""
+抖音视频详情工具
+
+根据 content_id(aweme_id)调用内部爬虫服务获取视频详情与真实播放链接。
+支持单个或批量查询。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from typing import Any, Optional
+
+import httpx
+
+from supply_infra.video_discovery_gates import parse_datetime_value
+
+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",
+    "create_time",
+    "create_timestamp",
+    "publish_at",
+    "publish_time",
+    "publish_timestamp",
+    "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),
+        "duration_seconds": _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
+
+    publish_at = parse_datetime_value(
+        detail.get("publish_at")
+        or detail.get("publish_time")
+        or detail.get("publish_timestamp")
+        or detail.get("create_time")
+        or detail.get("create_timestamp")
+    )
+    result["publish_at"] = (
+        publish_at.isoformat(timespec="seconds") if publish_at is not None else None
+    )
+    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())

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

@@ -0,0 +1,330 @@
+"""
+抖音关键词搜索工具
+
+调用内部爬虫服务进行抖音关键词搜索。
+"""
+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"
+DEFAULT_MIN_DURATION_SECONDS = 30
+
+
+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_duration_ms(item: dict[str, Any]) -> int:
+    video = item.get("video") if isinstance(item.get("video"), dict) else {}
+    return _safe_int(
+        item.get("duration_ms")
+        or video.get("duration")
+        or item.get("duration")
+    )
+
+
+def _extract_publish_at(item: dict[str, Any]) -> Any:
+    return (
+        item.get("publish_at")
+        or item.get("create_time")
+        or item.get("create_timestamp")
+        or item.get("publish_timestamp")
+    )
+
+
+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),
+                },
+                "duration_ms": _extract_duration_ms(item),
+                "publish_at": _extract_publish_at(item),
+            }
+        )
+    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,
+    filtered_count: int = 0,
+) -> 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),
+        "filtered_count": filtered_count,
+        "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,
+    min_duration_seconds: int = DEFAULT_MIN_DURATION_SECONDS,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    抖音关键词搜索
+
+    通过关键词搜索抖音平台的视频内容,支持多种排序和筛选方式。
+
+    Args:
+        keyword: 搜索关键词
+        content_type: 内容类型(可选:视频/图文, 默认 "视频")
+        sort_type: 排序方式(可选:综合排序/最新发布/最多点赞, 默认 "综合排序")
+        publish_time: 发布时间范围(可选:不限/一天内/一周内/半年内, 默认 "不限")
+        cursor: 分页游标,用于获取下一页结果,默认 "0"
+        account_id: 账号ID(可选)
+        min_duration_seconds: 客户端最短时长过滤,固定不低于 30 秒。
+        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 []
+        minimum_ms = max(
+            DEFAULT_MIN_DURATION_SECONDS,
+            int(min_duration_seconds or DEFAULT_MIN_DURATION_SECONDS),
+        ) * 1000
+        filtered_count = 0
+        filtered_items: list[dict[str, Any]] = []
+        for item in items:
+            duration = _extract_duration_ms(item)
+            if duration and duration < minimum_ms:
+                filtered_count += 1
+                continue
+            filtered_items.append(item)
+        items = filtered_items
+        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,
+            filtered_count,
+        )
+
+    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,
+    min_duration_seconds: int = DEFAULT_MIN_DURATION_SECONDS,
+    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,
+        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=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())

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

@@ -0,0 +1,465 @@
+"""通过 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
+DEFAULT_MIN_DURATION_SECONDS = 30
+_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")),
+        "publish_at": (
+            aweme.get("create_time")
+            or aweme.get("create_timestamp")
+            or aweme.get("publish_timestamp")
+        ),
+        "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 = DEFAULT_MIN_DURATION_SECONDS,
+    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: 客户端最短时长过滤,固定不低于 30 秒。
+        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(
+            DEFAULT_MIN_DURATION_SECONDS,
+            int(min_duration_seconds or DEFAULT_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 = DEFAULT_MIN_DURATION_SECONDS,
+    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},
+    )

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

@@ -0,0 +1,310 @@
+"""查询抖音作者作品,输出 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
+DEFAULT_MIN_DURATION_SECONDS = 30
+_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,
+        "publish_at": (
+            item.get("create_time")
+            or item.get("create_timestamp")
+            or item.get("publish_timestamp")
+        ),
+        "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 = DEFAULT_MIN_DURATION_SECONDS,
+    timeout: float | None = None,
+) -> str:
+    """
+    获取指定抖音作者的作品列表,支持最热/最新排序和游标翻页。
+
+    当候选作者的粉丝画像偏老或某条视频表现优秀时,可用该工具扩展同作者内容。
+    返回结构与 douyin_search.search_results 一致,可直接保存为搜索轨迹和候选。
+
+    Args:
+        account_id: author.sec_uid,必须使用完整值。
+        sort_type: 最热 / 最新,默认最热。
+        cursor: 首次为空;翻页使用上次返回的 next_cursor。
+        min_duration_seconds: 最短时长过滤,固定不低于 30 秒。
+        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(
+            DEFAULT_MIN_DURATION_SECONDS,
+            int(min_duration_seconds or DEFAULT_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 = DEFAULT_MIN_DURATION_SECONDS,
+    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
 
-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__)
 
@@ -187,7 +186,6 @@ def _error_result(
     )
 
 
-@tool
 async def get_account_fans_portrait(
     account_id: str,
     need_province: bool = False,
@@ -282,7 +280,6 @@ async def get_account_fans_portrait(
         return _error_result(f"未知错误: {e}", title="账号粉丝画像获取失败")
 
 
-@tool
 async def get_content_fans_portrait(
     content_id: str,
     need_province: bool = False,
@@ -377,7 +374,6 @@ async def get_content_fans_portrait(
         return _error_result(f"未知错误: {e}", title="内容点赞用户画像获取失败")
 
 
-@tool
 async def batch_fetch_portraits(
     candidates_json: str,
     fetch_account_portrait: bool = False,
@@ -577,7 +573,6 @@ async def batch_fetch_portraits(
                     content_block.get("portrait_data"),
                     account_block.get("portrait_data"),
                 )
-                item_result["age_portraits_normalized"] = True
                 results.append(item_result)
                 c_part = item_result["content"] 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,也不属于在线能力。
 保留文件仅用于历史兼容,不得因文件或数据库字段存在而推断能力已启用。

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

@@ -0,0 +1,256 @@
+"""搜索结果的内部持久化流程,不暴露为 Agent 工具。"""
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+from typing import Any
+
+from supply_infra.video_discovery_gates import (
+    normalize_duration_seconds,
+    parse_datetime_value,
+)
+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 []
+    duration = normalize_duration_seconds(
+        item.get("duration_seconds")
+        if item.get("duration_seconds") not in (None, "")
+        else item.get("duration_ms"),
+        unit=(
+            "seconds"
+            if item.get("duration_seconds") not in (None, "")
+            else "milliseconds"
+        ),
+    )
+    publish_at = parse_datetime_value(
+        item.get("publish_at")
+        or item.get("create_time")
+        or item.get("create_timestamp")
+        or item.get("publish_timestamp")
+    )
+    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")
+        ),
+        "duration_seconds": duration,
+        "publish_at": (
+            publish_at.replace(tzinfo=None) if publish_at is not None else None
+        ),
+        "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)

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

@@ -0,0 +1,397 @@
+"""持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
+from __future__ import annotations
+
+import json
+import logging
+import uuid
+from decimal import Decimal
+from typing import Any
+
+from supply_infra.video_discovery_gates import (
+    build_rule_snapshot,
+    normalize_duration_seconds,
+    parse_datetime_value,
+)
+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 _optional_ratio_decimal(value: Any, places: int = 6) -> Decimal | None:
+    number = _optional_decimal(value, places)
+    if number is not None and not Decimal("0") <= number <= Decimal("1"):
+        raise ValueError("比例字段必须在 0~1 范围内")
+    return number
+
+
+def _age_side(
+    age_normalization: Any,
+    side: str,
+) -> dict[str, Any]:
+    if not isinstance(age_normalization, dict):
+        return {}
+    value = age_normalization.get(side)
+    return value if isinstance(value, dict) else {}
+
+
+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
+    rule_snapshot = build_rule_snapshot()
+    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",
+        "rule_version": rule_snapshot["rule_version"],
+        "rule_config_json": _json(rule_snapshot),
+    }
+    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")
+    content_normalization = _age_side(age_normalization, "content")
+    account_normalization = _age_side(age_normalization, "account")
+    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"
+        )
+
+    publish_at = parse_datetime_value(item.get("publish_at"))
+    duration = normalize_duration_seconds(
+        item.get("duration_seconds")
+        if item.get("duration_seconds") not in (None, "")
+        else item.get("video_duration")
+    )
+    content_ratio = item.get(
+        "content_50_plus_ratio",
+        content_normalization.get("older_ratio"),
+    )
+    content_tgi = item.get(
+        "content_50_plus_tgi",
+        content_normalization.get("older_tgi"),
+    )
+    account_ratio = item.get(
+        "account_50_plus_ratio",
+        account_normalization.get("older_ratio"),
+    )
+    account_tgi = item.get(
+        "account_50_plus_tgi",
+        account_normalization.get("older_tgi"),
+    )
+    temporal_evidence = item.get("temporal_evidence")
+    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,
+        "publish_at": (
+            publish_at.replace(tzinfo=None) if publish_at is not None else None
+        ),
+        "duration_seconds": duration,
+        "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
+        ),
+        "content_50_plus_ratio": _optional_ratio_decimal(content_ratio),
+        "content_50_plus_tgi": _optional_decimal(content_tgi, 4),
+        "account_50_plus_ratio": _optional_ratio_decimal(account_ratio),
+        "account_50_plus_tgi": _optional_decimal(account_tgi, 4),
+        "temporal_type": _clean_text(item.get("temporal_type"), max_length=24),
+        "temporal_status": _clean_text(item.get("temporal_status"), max_length=16),
+        "temporal_evidence_json": (
+            _json(temporal_evidence) if temporal_evidence is not None else None
+        ),
+        "reject_reason_code": _clean_text(
+            item.get("reject_reason_code"),
+            max_length=64,
+        ),
+        "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 和最终分池理由。
+            primary 会在数据库事务内强制校验本次运行的 P0 规则快照。
+
+    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
 
+import sys
 from collections.abc import Callable
 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_search import douyin_search
 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.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,
+)
+from agents.find_agent.tools.get_content_fans_portrait import (
     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,
-    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
 
+# 旧调用方的模块路径兼容。实现均已迁出 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]] = [
+    batch_search_and_record,
     douyin_search,
     douyin_search_tikhub,
     douyin_user_videos,
@@ -38,15 +62,14 @@ ALL_TOOLS: list[Callable[..., Any]] = [
     get_account_fans_portrait,
     batch_fetch_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,
 ]
 
 __all__ = [
     "ALL_TOOLS",
+    "batch_search_and_record",
     "douyin_search",
     "douyin_search_tikhub",
     "douyin_user_videos",
@@ -55,10 +78,8 @@ __all__ = [
     "get_account_fans_portrait",
     "batch_fetch_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",
     "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)

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

@@ -0,0 +1,196 @@
+"""批量执行多关键词搜索并自动保存。"""
+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 (
+    DEFAULT_MIN_DURATION_SECONDS,
+    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。所有搜索源实际执行的
+            min_duration_seconds 固定不低于 30。
+    """
+    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 DEFAULT_MIN_DURATION_SECONDS
+                    ),
+                )
+            else:
+                raw_result = await douyin_search(
+                    **common,
+                    cursor=str(cursor or "0"),
+                    min_duration_seconds=int(
+                        raw_task.get("min_duration_seconds")
+                        or DEFAULT_MIN_DURATION_SECONDS
+                    ),
+                )
+
+            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
 
-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
-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)

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

@@ -1,226 +1,64 @@
-"""
-抖音关键词搜索工具
-
-调用内部爬虫服务进行抖音关键词搜索。
-"""
+"""搜索一页抖音视频并自动保存。"""
 from __future__ import annotations
 
 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 (
+    DEFAULT_MIN_DURATION_SECONDS,
+    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
 
-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
 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,
+    min_duration_seconds: int = DEFAULT_MIN_DURATION_SECONDS,
     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 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,
+        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=cursor,
+        page_no=page_no,
+        provider="internal_keyword",
+        content_type=content_type,
+        sort_type=sort_type,
+        publish_time=publish_time,
+    )

+ 47 - 385
agents/find_agent/tools/douyin_search_tikhub.py

@@ -1,235 +1,30 @@
-"""通过 TikHub 搜索抖音视频,输出 find_agent 统一候选格式。"""
+"""通过 TikHub 搜索一页抖音视频并自动保存。"""
 from __future__ import annotations
 
 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 (
+    DEFAULT_MIN_DURATION_SECONDS,
+    _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
 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 = "不限",
@@ -237,169 +32,36 @@ async def douyin_search_tikhub(
     filter_duration: str = "不限",
     search_id: str = "",
     backtrace: str = "",
-    min_duration_seconds: int = 0,
+    min_duration_seconds: int = DEFAULT_MIN_DURATION_SECONDS,
     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}")
+    """使用 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},
+    )

+ 36 - 249
agents/find_agent/tools/douyin_user_videos.py

@@ -1,264 +1,51 @@
-"""查询抖音作者作品,输出 find_agent 统一候选格式。"""
+"""获取一页作者作品并自动保存。"""
 from __future__ import annotations
 
 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 (
+    DEFAULT_MIN_DURATION_SECONDS,
+    _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
 
-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
 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,
+    min_duration_seconds: int = DEFAULT_MIN_DURATION_SECONDS,
     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}")
+    """获取一页作者作品,创建搜索记录和候选记录并返回对应数据库 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": "数据库运行审计失败"}
-        )

+ 561 - 0
agents/find_agent/优化迭代方案-v3.md

@@ -0,0 +1,561 @@
+# find_agent 优化迭代方案
+
+> 文档版本:v3.0  
+> 文档状态:待评审  
+> 编制日期:2026-07-30  
+> 适用范围:`agents/find_agent` 视频搜索、补证、筛选、分池与结果输出  
+> 迭代目标:解决本轮寻找视频过程中暴露的时效、时长、内容理解、画像混用和准入规则不明确问题
+
+## 1. 背景
+
+当前 `find_agent` 已具备关键词搜索、详情获取、视频点赞用户画像、作者粉丝画像、年龄
+标准化和候选分池能力,但候选是否进入 `primary` 仍主要由 Prompt 驱动,程序没有对关键
+条件做强校验。
+
+本轮结果暴露出以下问题:
+
+1. 没有判断内容在当前日期和当前时段是否仍然有效。例如中午仍保留“早上好”内容,
+   与当前日期不匹配的节日内容也可能被搜索或推荐。
+2. 搜索召回和最终分池没有统一执行最短时长规则,小于 30 秒的视频仍可能保留。
+3. 当前明确禁用视频理解,只能依赖标题、描述、标签和互动数据,容易出现标题符合但
+   实际视频内容不符合需求的情况。
+4. 视频点赞用户画像和账号粉丝画像虽然分别获取,但最终仍被压缩为同一个老年倾向
+   判断,结果不够清晰。
+5. 年龄判断混入了 40~50 岁代理信号,没有把 `50岁及以上占比` 作为核心准入指标。
+6. 分享数、50+ 占比等关键内容指标只有软判断,没有程序级强制门槛。
+
+## 2. 迭代目标
+
+本次迭代将候选判断从“模型综合打分后自由分池”调整为“硬门槛过滤后再综合排序”。
+
+目标结果:
+
+- 与当前日期、节日和时段不一致的内容不能进入主推荐;
+- 时长小于 30 秒的视频不能进入主推荐;
+- 主推荐视频必须完成一次轻量视频内容核验;
+- 视频画像与账号画像独立展示、独立判断,不做静默平均;
+- 老年性判断以视频侧 `50岁及以上占比` 为核心;
+- 分享数和视频侧 50+ 占比由代码强校验;
+- 所有淘汰结果都有明确、可检索的原因码;
+- 阈值可以按配置调整,并能通过历史样本回测。
+
+本次不解决:
+
+- 将点赞用户画像等同于真实转发用户画像;
+- 预测视频未来一定会爆发;
+- 仅凭视频画面中的人物年龄推断观众年龄;
+- 用账号整体表现代替单条视频表现。
+
+### 2.1 当前实现差距
+
+| 目标能力 | 当前实现 | 主要差距 |
+|---|---|---|
+| 发布时间判断 | 搜索参数支持部分时间范围,详情返回字段未完整落候选表 | 无法在最终分池时稳定校验发布日期和语义有效期 |
+| 30 秒时长门槛 | TikHub 和作者作品工具支持最短时长参数,但默认值为 0 | 不是全搜索源默认规则,保存 `primary` 时也不复核 |
+| 视频理解 | 已有历史千问分析模块,但未注册给 Agent | 无正式调用链、结构化结果和持久化字段 |
+| 双侧年龄画像 | 能分别获取视频与账号画像,并生成双侧标准化结果 | 最终仍收敛为一个 `E` 分,没有独立准入结果 |
+| 50+ 老年判断 | 标准化逻辑能识别 50+,也保留 40+ 成熟代理信号 | 保存层不校验视频侧 50+ 占比 |
+| 分享与画像硬门槛 | Prompt 要求模型综合判断分享和年龄证据 | 候选更新工具不校验阈值、分数或 `primary` 证据完整性 |
+
+## 3. 核心设计原则
+
+### 3.1 先准入,后评分
+
+候选必须依次通过时效、时长、内容指标、视频理解和视频侧老年画像等门槛,才允许计算
+综合价值并参与 `primary` 排序。评分不能补偿硬门槛失败。
+
+### 3.2 内容证据与账号先验分离
+
+- 视频画像回答“这条视频实际吸引了谁”,属于内容侧直接证据;
+- 账号画像回答“这个账号通常触达谁”,属于账号侧先验;
+- 两侧分别输出比例、结论和置信度;
+- 不允许把两侧比例相加、平均后再判断;
+- 两侧冲突时优先视频画像,并保留 `portrait_conflict` 标记;
+- 只有账号画像偏老、视频画像缺失时,不允许进入主推荐。
+
+### 3.3 时间有效性不等于发布时间新
+
+时效判断同时包含:
+
+1. **发布新鲜度**:视频发布距当前时间有多久;
+2. **语义有效期**:视频中的日期、节日、问候时段、事件状态在当前是否成立。
+
+一条刚发布的“早上好”视频在中午仍可能失效;一条较早发布但没有时间依赖的常青内容
+仍可能有效。因此不能只用发布时间做单一判断。
+
+### 3.4 视频理解不承担年龄画像职责
+
+视频理解用于核验实际主题、关键信息、时间语义、需求相关性和分享动机,不用于推断
+观众年龄。老年受众结论必须来自年龄画像。
+
+## 4. 目标流程
+
+```text
+运行上下文
+  -> 注入当前时间、日期、时区和阈值版本
+  -> 按需求生成搜索词与时间类型
+  -> 搜索召回
+  -> 基础字段完整性检查
+  -> 时长硬过滤(>= 30 秒)
+  -> 分享数硬过滤
+  -> 发布时间与时间语义初筛
+  -> 获取详情和可播放地址
+  -> 轻量视频理解
+  -> 视频点赞用户画像判断
+  -> 账号粉丝画像独立判断
+  -> 统一质量门禁
+  -> 对通过候选计算 R / S / V 并排序
+  -> 保存分池、原因码和证据快照
+  -> 完成守卫校验
+  -> 输出结果
+```
+
+模型仍负责搜索策略、语义理解和结果解释;程序负责不可绕过的门槛、字段校验和完成
+状态校验。
+
+## 5. 规则设计
+
+### 5.1 时效性规则
+
+#### 5.1.1 运行上下文
+
+每次运行必须向 Agent 和规则引擎提供:
+
+- `current_datetime`:运行开始时间;
+- `current_date`:当前业务日期;
+- `timezone`:默认 `Asia/Shanghai`;
+- `biz_dt`:调度业务日期;
+- `rule_version`:本次使用的规则配置版本。
+
+禁止只给 `biz_dt` 而不给当前具体时间。时段内容判断必须使用 `current_datetime`。
+
+#### 5.1.2 时间类型
+
+需求和候选分别标记以下时间类型:
+
+| 类型 | 典型内容 | 默认有效规则 |
+|---|---|---|
+| `daypart` | 早上好、午安、晚安 | 当前时间必须处于对应时段 |
+| `festival` | 春节、端午、中秋、国庆 | 当前日期必须处于该节日前置窗口或节日期间 |
+| `event` | 新闻、比赛、政策、突发事件 | 事件仍在发生,或内容仍有明确回顾价值 |
+| `seasonal` | 入伏、开学、换季 | 当前日期必须处于对应季节窗口 |
+| `evergreen` | 健康常识、家庭技巧、怀旧内容 | 不设统一绝对过期日,但仍检查信息是否失效 |
+
+建议首发时段配置:
+
+| 内容 | 有效时段 |
+|---|---|
+| 早上好/晨间问候 | 05:00~10:30 |
+| 午安/午间问候 | 11:00~14:00 |
+| 晚上好/晚安 | 18:00~次日 01:00 |
+
+建议首发节日配置:
+
+- 普通节日内容:节日前 7 天至节日结束;
+- 强当天语义内容,如“今天是中秋”:仅节日当天有效;
+- 节后复盘、祝福回顾类内容:仅在视频理解明确识别为回顾时放行;
+- 不在有效窗口内的节日词,不作为搜索扩展词;偶然召回后直接淘汰。
+
+#### 5.1.3 发布时间策略
+
+搜索时根据时间类型主动使用发布时间过滤:
+
+- `daypart`、突发 `event`:优先“一天内”;
+- 普通事件和强时效资讯:优先“一周内”;
+- 季节性和阶段性内容:优先“半年内”,再做语义有效性判断;
+- `evergreen`:可不限发布时间,但详情阶段必须取得发布时间并检查失效表述。
+
+候选必须保存真实 `publish_at`,并计算 `content_age_hours`、`content_age_days`。若上游搜索
+没有发布时间,候选只能处于待补证状态;详情仍无法获取时不得进入 `primary`。
+
+#### 5.1.4 时间判断结果
+
+每条候选输出:
+
+- `temporal_type`;
+- `publish_at`;
+- `semantic_time_refs`:识别到的日期、节日、时段或事件;
+- `temporal_status`:`pass / fail / unknown`;
+- `temporal_reason`;
+- `valid_from / valid_to`,能够确定时填写。
+
+`fail` 或 `unknown` 均不能进入主推荐。`unknown` 表示证据不足,不应描述为内容错误。
+
+### 5.2 时长规则
+
+- 主推荐视频必须满足 `duration_seconds >= 30`;
+- `29.999` 秒按不通过处理,`30.000` 秒按通过处理;
+- 搜索接口支持客户端过滤时,统一传 `min_duration_seconds=30`;
+- 作者作品扩展同样执行 30 秒过滤;
+- 搜索结果没有时长时不得直接保留为正式候选,必须通过详情补齐;
+- 详情仍无法获得时长时,标记 `DURATION_UNKNOWN`,不得进入 `primary`;
+- 搜索侧过滤只用于节省成本,最终保存时必须再次由代码校验,防止其他搜索源绕过。
+
+### 5.3 轻量视频理解
+
+#### 5.3.1 使用范围
+
+只对通过时长、分享数和初步时效检查的高潜候选执行,默认每个需求最多分析 8 条,降低
+模型与视频处理成本。
+
+现有 `qwen_video_analysis.py` 可作为实现基础,但需要从“历史未注册能力”改造成正式
+批量工具或确定性服务步骤,并把分析结果写入候选证据。
+
+#### 5.3.2 最小分析内容
+
+视频理解至少返回以下结构化字段:
+
+```json
+{
+  "content_summary": "视频实际讲了什么",
+  "main_topics": ["主题1", "主题2"],
+  "spoken_or_visual_time_refs": ["早上好", "2026年春节"],
+  "demand_match": "pass|fail|unknown",
+  "demand_match_reason": "实际内容与需求的关系",
+  "share_motives": ["实用提醒", "情感共鸣"],
+  "is_title_content_consistent": true,
+  "temporal_risk": "none|daypart|festival|event|seasonal|unknown",
+  "risk_flags": [],
+  "confidence": 0.0
+}
+```
+
+实现时可组合关键帧、OCR、ASR/字幕和多模态摘要。首版不要求深度剧情理解,只要求能
+识别:
+
+- 标题与实际内容是否一致;
+- 视频是否真正回答需求;
+- 是否出现“早上好”“今天”“某节日”等强时间表达;
+- 是否为纯广告、搬运拼接、无信息量画面;
+- 是否具备可解释的分享动机。
+
+#### 5.3.3 分池约束
+
+- `demand_match=fail`:淘汰;
+- `demand_match=unknown`:不得进入主推荐;
+- `temporal_risk` 命中后,交给时间规则结合当前时间复核;
+- 视频理解失败可重试一次;仍失败时标记 `VIDEO_ANALYSIS_UNAVAILABLE`,不得进入
+  `primary`;
+- 视频理解结果不得生成或修改 50+ 占比。
+
+### 5.4 视频画像规则
+
+视频画像使用“视频点赞用户年龄画像”,单独生成内容侧结论:
+
+- 核心指标:`content_50_plus_ratio`;
+- 辅助指标:`content_50_plus_tgi`;
+- `40~49` 或 `41~50` 只能记录为成熟人群辅助信息,不计入 50+ 准入占比;
+- 画像桶含义必须先标准化,接口中的 `50-` 按已确认的“50岁以上”解析;
+- 视频侧画像缺失时,标记 `CONTENT_PORTRAIT_MISSING`,不得由账号画像代替;
+- 视频侧 50+ 占比是主推荐的硬门槛。
+
+建议首发配置:
+
+```text
+min_content_50_plus_ratio = 0.20
+```
+
+即视频侧 50+ 占比至少为 20%。这是首轮试运行阈值,不代表永久业务常量;上线前应使用
+近期人工标注样本回测。
+
+### 5.5 账号画像规则
+
+账号画像使用“作者粉丝年龄画像”,单独生成账号侧结论:
+
+- 核心指标:`account_50_plus_ratio`;
+- 辅助指标:`account_50_plus_tgi`;
+- 输出 `account_portrait_status=pass / fail / missing`;
+- 首发参考线可同样配置为 `min_account_50_plus_ratio=0.20`;
+- 账号侧 `pass` 只增强置信度,不可使视频侧失败候选通过;
+- 账号侧 `fail` 不自动否决视频侧明确通过的单条内容,但必须标记画像冲突;
+- 账号侧缺失不等于年轻,只记录证据缺失。
+
+示例:
+
+| 视频侧 50+ | 账号侧 50+ | 结论 |
+|---:|---:|---|
+| 28% | 35% | 视频通过、账号通过、画像一致 |
+| 28% | 8% | 视频通过、账号不通过、画像冲突;仍可继续其他门禁 |
+| 缺失 | 55% | 视频证据不足,不得进入主推荐 |
+| 12% | 55% | 视频门槛失败,不得由账号画像补偿 |
+
+### 5.6 内容指标强制规则
+
+建议首发配置:
+
+```text
+min_duration_seconds = 30
+min_share_count = 1000
+min_content_50_plus_ratio = 0.20
+```
+
+主推荐必须同时满足:
+
+1. `share_count >= min_share_count`;
+2. `content_50_plus_ratio >= min_content_50_plus_ratio`;
+3. `duration_seconds >= min_duration_seconds`。
+
+分享数使用最新详情数据,不使用搜索页中的旧快照做最终判断。若详情没有分享数,标记
+`SHARE_COUNT_UNKNOWN`,不得进入主推荐。
+
+分享效率继续作为排序指标,不代替分享数门槛:
+
+- 有可靠播放数:`share_rate = share_count / play_count`;
+- 没有播放数但有点赞数:使用平滑后的 `share_count / like_count` 作为替代指标;
+- 分母过小的样本设置最小样本量保护;
+- 分享率再高,只要分享数未达到硬门槛,仍不能进入主推荐。
+
+`min_share_count=1000` 是用于阻止低传播规模内容进入主推荐的试运行值。应在上线后
+按需求类型、搜索来源和近 7~14 天样本分布校准,但任何配置都必须保留绝对分享数下限。
+
+### 5.7 统一质量门禁
+
+程序新增统一门禁函数,候选只有全部通过才可保存为 `primary`:
+
+| 门禁 | 通过条件 | 失败原因码 |
+|---|---|---|
+| 字段完整性 | ID、详情、发布时间等关键字段可用 | `REQUIRED_FIELD_MISSING` |
+| 时间有效性 | `temporal_status=pass` | `TEMPORAL_EXPIRED` / `TEMPORAL_UNKNOWN` |
+| 视频时长 | `duration_seconds >= 30` | `DURATION_TOO_SHORT` / `DURATION_UNKNOWN` |
+| 分享规模 | `share_count >= 1000` | `SHARE_COUNT_TOO_LOW` / `SHARE_COUNT_UNKNOWN` |
+| 视频理解 | 分析成功且需求匹配 | `CONTENT_MISMATCH` / `VIDEO_ANALYSIS_UNAVAILABLE` |
+| 视频侧画像 | 50+ 占比至少 20% | `CONTENT_50_PLUS_TOO_LOW` / `CONTENT_PORTRAIT_MISSING` |
+| 相关性 | 满足需求真实意图 | `LOW_RELEVANCE` |
+
+门禁阈值必须从版本化配置读取,表中的 30 秒、1000 次分享和 20% 为首发建议值。
+
+`batch_update_video_discovery_candidates` 保存 `primary` 时必须调用门禁函数。若模型请求
+将未通过候选设为 `primary`,程序应拒绝该条更新并返回具体失败字段,不应静默改写。
+
+## 6. 评分与排序调整
+
+硬门槛通过后再计算排序分。建议将原来的老年综合分拆开:
+
+- `R`:需求相关性;
+- `C50`:视频侧 50+ 占比归一化得分;
+- `S`:分享规模、分享效率和分享动机;
+- `Q`:视频理解置信度与内容质量;
+- `A50`:账号侧 50+ 先验,只作为小权重增强项。
+
+示例排序公式:
+
+```text
+V = R^0.35 × C50^0.30 × S^0.25 × Q^0.10
+V_final = V × account_adjustment
+account_adjustment ∈ [0.95, 1.05]
+```
+
+账号画像最多做小幅调整,不能改变任何硬门槛结果。首版也可以不启用
+`account_adjustment`,只展示冲突标记,待回测后再决定。
+
+## 7. 数据与接口改造
+
+### 7.1 候选表新增字段
+
+建议为 `video_discovery_candidate` 增加:
+
+| 字段 | 说明 |
+|---|---|
+| `publish_at` | 视频真实发布时间 |
+| `duration_seconds` | 视频时长,统一使用秒 |
+| `evaluated_at` | 本次候选评估时间 |
+| `content_age_hours_at_evaluation` | 评估时的视频年龄,避免被误解为实时值 |
+| `temporal_type` | 时间类型 |
+| `temporal_status` | `pass / fail / unknown` |
+| `temporal_evidence_json` | 时间表达、有效窗口和判断理由 |
+| `video_analysis_status` | 视频理解执行状态 |
+| `video_analysis_json` | 结构化视频理解结果 |
+| `video_analysis_version` | 模型、Prompt 和解析版本 |
+| `content_50_plus_ratio` | 视频侧 50+ 占比 |
+| `content_50_plus_tgi` | 视频侧 50+ TGI |
+| `content_portrait_status` | 视频侧画像独立结论 |
+| `account_50_plus_ratio` | 账号侧 50+ 占比 |
+| `account_50_plus_tgi` | 账号侧 50+ TGI |
+| `account_portrait_status` | 账号侧画像独立结论 |
+| `portrait_conflict` | 两侧是否冲突 |
+| `share_rate` | 分享效率 |
+| `gate_status` | `pass / fail / pending` |
+| `gate_results_json` | 每项门禁结果和使用阈值 |
+| `reject_reason_code` | 主要淘汰原因码 |
+| `rule_version` | 评估使用的规则版本 |
+
+可播放地址只需在分析阶段短期使用,避免长期保存可能失效或带鉴权参数的原始地址;持久化
+分析结果、输入视频 ID和分析版本即可。
+
+### 7.2 搜索与详情接口
+
+需要补齐:
+
+- TikHub 搜索标准化结果保留视频 `create_time`;
+- 所有召回工具统一支持并默认传入 `min_duration_seconds=30`;
+- 内部搜索没有时长或发布时间时,候选明确标记为待补证;
+- `douyin_detail` 明确保留视频真实发布时间,而不是只保存抓取系统的
+  `modify_timestamp/update_timestamp`;
+- 详情结果写回候选表,不再只返回给模型;
+- 详情写回和最终分池前都执行一次字段单位标准化。
+
+### 7.3 视频理解接口
+
+建议新增批量工具:
+
+```text
+batch_analyze_candidate_videos(run_id, candidate_ids, analysis_version)
+```
+
+工具内部负责:
+
+1. 按候选 ID 读取详情和临时播放地址;
+2. 校验时长、URL 和批量上限;
+3. 调用轻量视频理解;
+4. 校验结构化输出;
+5. 写回分析状态与结果;
+6. 返回每条候选的成功、失败和可重试状态。
+
+不建议让 Agent 自由拼接任意视频 URL 调用分析模型,以免分析对象与候选记录不一致。
+
+### 7.4 运行级配置
+
+新增版本化配置示例:
+
+```json
+{
+  "rule_version": "find-agent-gate-v1",
+  "timezone": "Asia/Shanghai",
+  "min_duration_seconds": 30,
+  "min_share_count": 1000,
+  "min_content_50_plus_ratio": 0.2,
+  "min_account_50_plus_ratio": 0.2,
+  "max_video_analysis_candidates": 8,
+  "festival_lead_days": 7
+}
+```
+
+运行记录保存完整配置快照,防止配置变更后无法解释历史结果。
+
+## 8. Prompt 调整
+
+Prompt 保留搜索策略与语义判断,但删除或改写以下内容:
+
+- 删除“当前流程明确不使用视频理解”;
+- 增加当前日期、具体时间和时区的使用要求;
+- 明确 30 秒、分享数和视频侧 50+ 占比是程序硬门槛;
+- 明确视频理解是主推荐必需证据;
+- 将 `E` 拆成视频侧结论和账号侧结论;
+- 明确 40~49 岁不计入 50+ 准入占比;
+- 明确账号画像不能补偿视频画像缺失或失败;
+- 要求最终理由分别陈述视频侧画像、账号侧画像和冲突情况;
+- 要求引用时间有效性结论,不能只写“近期发布”。
+
+Prompt 中不再让模型自行决定阈值,也不再让模型手工计算是否通过门禁。工具返回
+`gate_results` 后,模型负责解释,不负责改写。
+
+## 9. 完成守卫
+
+当前“存在任意候选即成功”的判定过弱。本次同步调整为:
+
+运行标记 `finished` 前必须满足:
+
+- 所有需要最终判断的候选均不再是 `pending_evaluation`;
+- 每条 `primary` 均通过统一质量门禁;
+- 每条 `primary` 都有详情、时效判断、视频理解和视频侧画像证据;
+- `primary_count` 与数据库实际数量一致;
+- 没有主推荐也可以正常完成,但必须保存“无候选通过硬门槛”的停止原因;
+- 数据库写入或门禁执行失败时不得标记 `finished`。
+
+## 10. 实施拆分
+
+### P0:基础硬门槛
+
+- 补齐发布时间、时长和分享数持久化;
+- 所有搜索源统一 30 秒过滤;
+- 增加时间上下文和基础时效规则;
+- 拆分视频画像与账号画像字段及结论;
+- 增加视频侧 50+ 占比、分享数硬门槛;
+- 在候选保存层增加统一门禁;
+- 增加原因码与规则配置快照;
+- 更新 Prompt 和完成守卫。
+
+交付标准:短于 30 秒、分享数不足、视频侧 50+ 不足、时间失效的候选无法被保存为
+`primary`。
+
+### P1:轻量视频理解
+
+- 将现有千问视频分析能力正式接入;
+- 定义结构化输出协议;
+- 增加批量分析工具、结果持久化和失败策略;
+- 将内容匹配和时间语义结果接入统一门禁;
+- 增加分析版本与成本监控。
+
+交付标准:所有 `primary` 均有成功的视频理解记录,标题与实际内容不一致的候选被淘汰。
+
+### P2:阈值校准与运营监控
+
+- 使用近期历史结果建立人工标注集;
+- 分析分享数、分享率、50+ 占比阈值对准确率和召回率的影响;
+- 按需求类型评估是否需要不同阈值;
+- 建立门禁淘汰分布、画像缺失率、分析失败率和主推荐人工通过率看板;
+- 根据回测结果发布 `rule_version=v2`,不直接覆盖历史配置。
+
+## 11. 验收用例
+
+| 场景 | 输入 | 期望结果 |
+|---|---|---|
+| 时段失效 | 当前 12:00,视频内容为“早上好” | `TEMPORAL_EXPIRED`,不得主推荐 |
+| 节日失效 | 当前不在中秋窗口,视频为当年中秋祝福 | `TEMPORAL_EXPIRED` |
+| 常青内容 | 较早发布但内容无过期信息 | 可继续后续门禁 |
+| 发布时间缺失 | 搜索、详情均无真实发布时间 | `TEMPORAL_UNKNOWN` |
+| 时长边界失败 | 29.999 秒 | `DURATION_TOO_SHORT` |
+| 时长边界通过 | 30.000 秒 | 通过时长门禁 |
+| 时长缺失 | 详情仍无法取得时长 | `DURATION_UNKNOWN` |
+| 分享数不足 | 分享 999,其他指标均通过 | `SHARE_COUNT_TOO_LOW` |
+| 分享数边界 | 分享 1000 | 通过分享数门禁 |
+| 视频侧占比不足 | 视频侧 50+ 为 19.9% | `CONTENT_50_PLUS_TOO_LOW` |
+| 视频侧占比边界 | 视频侧 50+ 为 20% | 通过视频画像门禁 |
+| 仅账号偏老 | 视频画像缺失,账号侧 50+ 为 55% | `CONTENT_PORTRAIT_MISSING` |
+| 双侧冲突 | 视频侧 28%,账号侧 8% | 视频通过、账号失败、记录冲突,不做平均 |
+| 题文不符 | 标题符合需求,视频理解显示实际为无关广告 | `CONTENT_MISMATCH` |
+| 分析失败 | 视频理解重试后仍失败 | `VIDEO_ANALYSIS_UNAVAILABLE` |
+| 模型绕过 | 模型请求把门禁失败候选保存为 `primary` | 保存层拒绝并返回失败项 |
+
+## 12. 监控指标
+
+上线后至少监控:
+
+- 搜索召回量;
+- 30 秒时长过滤率;
+- 时间失效率及其类型分布;
+- 分享数门槛淘汰率;
+- 视频侧画像缺失率;
+- 账号侧画像缺失率;
+- 视频侧 50+ 门槛淘汰率;
+- 双侧画像冲突率;
+- 视频理解成功率、平均耗时和单次成本;
+- 标题与内容不一致率;
+- 各原因码数量;
+- 每个需求最终主推荐数;
+- 人工审核通过率;
+- 因硬门槛造成的误杀率和漏放率。
+
+## 13. 风险与处理
+
+| 风险 | 影响 | 处理方式 |
+|---|---|---|
+| 分享数阈值过高 | 新视频和小众优质内容召回下降 | 用标注集回测,阈值配置化 |
+| 50+ 画像覆盖率低 | 主推荐数量下降 | 提升画像接口覆盖;不允许账号画像冒充内容画像 |
+| 视频理解成本和耗时增加 | 单需求运行时间变长 | 只分析基础门槛后的前 8 条,批量并发并缓存 |
+| 视频播放地址失效 | 分析失败 | 详情阶段即时获取,不长期依赖原始地址 |
+| 节日历法复杂 | 时间误判 | 使用版本化节日日历,保留人工可配置窗口 |
+| 模型输出结构不稳定 | 门禁无法判断 | JSON Schema 校验,失败一次修复重试 |
+| 过度依赖固定阈值 | 不同需求类型表现不一致 | 保留绝对底线,分需求类型做二阶段校准 |
+
+## 14. 上线判定
+
+满足以下条件后才进入全量运行:
+
+1. P0、P1 验收用例全部通过;
+2. 历史样本回放中不存在硬门槛绕过;
+3. 随机抽检能够看到视频画像和账号画像的独立结论;
+4. “早上好”和非当前节日等时间失效样本被稳定识别;
+5. 人工审核确认主推荐准确率较现状提升;
+6. 视频理解失败不会导致运行被误标成功;
+7. 规则配置、分析版本和淘汰原因可完整追溯。

+ 0 - 49
alembic/versions/20260727_02_nullable_manual_deadline.py

@@ -1,49 +0,0 @@
-"""allow manual pipeline runs without a deadline
-
-Revision ID: 20260727_02
-Revises: 20260727_01
-Create Date: 2026-07-27
-"""
-from __future__ import annotations
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260727_02"
-down_revision: str | None = "20260727_01"
-branch_labels: str | Sequence[str] | None = None
-depends_on: str | Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.alter_column(
-        "pipeline_run",
-        "deadline_at",
-        existing_type=sa.DateTime(),
-        nullable=True,
-    )
-    op.execute(
-        sa.text(
-            "UPDATE pipeline_run "
-            "SET deadline_at = NULL "
-            "WHERE trigger_type NOT IN ('cron', 'reconcile')"
-        )
-    )
-
-
-def downgrade() -> None:
-    op.execute(
-        sa.text(
-            "UPDATE pipeline_run "
-            "SET deadline_at = UTC_TIMESTAMP() "
-            "WHERE deadline_at IS NULL"
-        )
-    )
-    op.alter_column(
-        "pipeline_run",
-        "deadline_at",
-        existing_type=sa.DateTime(),
-        nullable=False,
-    )

+ 0 - 75
alembic/versions/20260727_03_agent_document_injection.py

@@ -1,75 +0,0 @@
-"""create persisted Agent document injection table
-
-Revision ID: 20260727_03
-Revises: 20260727_02
-Create Date: 2026-07-27
-"""
-from __future__ import annotations
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-from sqlalchemy.dialects import mysql
-
-revision: str = "20260727_03"
-down_revision: str | None = "20260727_02"
-branch_labels: str | Sequence[str] | None = None
-depends_on: str | Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.create_table(
-        "agent_document_injection",
-        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
-        sa.Column(
-            "agent_name",
-            sa.String(length=128),
-            nullable=False,
-            comment="Agent name",
-        ),
-        sa.Column(
-            "content",
-            sa.Text().with_variant(mysql.LONGTEXT(), "mysql"),
-            nullable=False,
-            comment="Document content injected after the base system prompt",
-        ),
-        sa.Column(
-            "enabled",
-            sa.Integer(),
-            server_default=sa.text("1"),
-            nullable=False,
-            comment="Whether the document injection is active",
-        ),
-        sa.Column(
-            "create_time",
-            sa.DateTime(),
-            server_default=sa.func.now(),
-            nullable=False,
-        ),
-        sa.Column(
-            "update_time",
-            sa.DateTime(),
-            server_default=sa.func.now(),
-            nullable=False,
-        ),
-        sa.PrimaryKeyConstraint("id"),
-        sa.UniqueConstraint(
-            "agent_name",
-            name="uk_agent_document_injection_agent_name",
-        ),
-        mysql_charset="utf8mb4",
-    )
-    op.create_index(
-        "ix_agent_document_injection_agent_name",
-        "agent_document_injection",
-        ["agent_name"],
-    )
-
-
-def downgrade() -> None:
-    op.drop_index(
-        "ix_agent_document_injection_agent_name",
-        table_name="agent_document_injection",
-    )
-    op.drop_table("agent_document_injection")

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

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

+ 58 - 9
alembic/versions/20260727_01_pipeline_control_plane.py → alembic/versions/20260729_03_schema_baseline.py

@@ -1,8 +1,8 @@
-"""create durable pipeline control plane
+"""consolidated schema baseline
 
-Revision ID: 20260727_01
+Revision ID: 20260729_03
 Revises:
-Create Date: 2026-07-27
+Create Date: 2026-07-29
 """
 from __future__ import annotations
 
@@ -11,8 +11,9 @@ from datetime import datetime
 
 import sqlalchemy as sa
 from alembic import op
+from sqlalchemy.dialects import mysql
 
-revision: str = "20260727_01"
+revision: str = "20260729_03"
 down_revision: str | None = None
 branch_labels: str | Sequence[str] | None = None
 depends_on: str | Sequence[str] | None = None
@@ -27,15 +28,12 @@ def upgrade() -> None:
         sa.Column("biz_dt", sa.String(8), nullable=False),
         sa.Column("trigger_type", sa.String(24), nullable=False),
         sa.Column("trigger_source", sa.String(64), nullable=True),
-        sa.Column("triggered_by", sa.String(128), nullable=True),
         sa.Column("trigger_reason", sa.Text(), nullable=True),
-        sa.Column("parent_run_id", sa.String(36), nullable=True),
         sa.Column("run_mode", sa.String(24), nullable=False),
         sa.Column("dry_run", sa.Boolean(), nullable=False),
         sa.Column("status", sa.String(32), nullable=False),
         sa.Column("current_step", sa.String(64), nullable=True),
-        sa.Column("scheduled_for", sa.DateTime(), nullable=True),
-        sa.Column("deadline_at", sa.DateTime(), nullable=False),
+        sa.Column("deadline_at", sa.DateTime(), nullable=True),
         sa.Column("started_at", sa.DateTime(), nullable=True),
         sa.Column("finished_at", sa.DateTime(), nullable=True),
         sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
@@ -95,7 +93,6 @@ def upgrade() -> None:
         sa.Column("lease_until", sa.DateTime(), nullable=True),
         sa.Column("exit_code", sa.Integer(), nullable=True),
         sa.Column("result_summary_json", sa.JSON(), nullable=True),
-        sa.Column("metrics_json", sa.JSON(), nullable=True),
         sa.Column("error_code", sa.String(64), nullable=True),
         sa.Column("error_message", sa.Text(), nullable=True),
         sa.Column("log_uri", sa.String(1024), nullable=True),
@@ -203,8 +200,60 @@ def upgrade() -> None:
         ["status", "created_at"],
     )
 
+    op.create_table(
+        "agent_document_injection",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column(
+            "agent_name",
+            sa.String(length=128),
+            nullable=False,
+            comment="Agent name",
+        ),
+        sa.Column(
+            "content",
+            sa.Text().with_variant(mysql.LONGTEXT(), "mysql"),
+            nullable=False,
+            comment="Document content injected after the base system prompt",
+        ),
+        sa.Column(
+            "enabled",
+            sa.Integer(),
+            server_default=sa.text("1"),
+            nullable=False,
+            comment="Whether the document injection is active",
+        ),
+        sa.Column(
+            "create_time",
+            sa.DateTime(),
+            server_default=sa.func.now(),
+            nullable=False,
+        ),
+        sa.Column(
+            "update_time",
+            sa.DateTime(),
+            server_default=sa.func.now(),
+            nullable=False,
+        ),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint(
+            "agent_name",
+            name="uk_agent_document_injection_agent_name",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "ix_agent_document_injection_agent_name",
+        "agent_document_injection",
+        ["agent_name"],
+    )
+
 
 def downgrade() -> None:
+    op.drop_index(
+        "ix_agent_document_injection_agent_name",
+        table_name="agent_document_injection",
+    )
+    op.drop_table("agent_document_injection")
     op.drop_index("idx_pipeline_outbox_status", table_name="pipeline_outbox")
     op.drop_index("idx_pipeline_outbox_run", table_name="pipeline_outbox")
     op.drop_table("pipeline_outbox")

+ 88 - 0
alembic/versions/20260730_04_add_local_auth.py

@@ -0,0 +1,88 @@
+"""add local users and server-side sessions
+
+Revision ID: 20260730_04
+Revises: 20260729_03
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_04"
+down_revision: str | None = "20260729_03"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "auth_user",
+        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column("username", sa.String(length=64), nullable=False),
+        sa.Column("password_hash", sa.String(length=255), nullable=False),
+        sa.Column("display_name", sa.String(length=128), nullable=False),
+        sa.Column("role", sa.String(length=16), nullable=False),
+        sa.Column("status", sa.String(length=16), nullable=False),
+        sa.Column("failed_login_count", sa.Integer(), nullable=False, server_default="0"),
+        sa.Column("locked_until", sa.DateTime(), nullable=True),
+        sa.Column("last_login_at", sa.DateTime(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("username", name="uq_auth_user_username"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index("ix_auth_user_username", "auth_user", ["username"], unique=False)
+    op.create_index(
+        "idx_auth_user_role_status",
+        "auth_user",
+        ["role", "status"],
+        unique=False,
+    )
+
+    op.create_table(
+        "auth_session",
+        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column("token_hash", sa.String(length=64), nullable=False),
+        sa.Column("user_id", sa.Integer(), nullable=False),
+        sa.Column("expires_at", sa.DateTime(), nullable=False),
+        sa.Column("last_active_at", sa.DateTime(), nullable=False),
+        sa.Column("ip_address", sa.String(length=64), nullable=True),
+        sa.Column("user_agent", sa.String(length=512), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("token_hash", name="uq_auth_session_token_hash"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "ix_auth_session_token_hash",
+        "auth_session",
+        ["token_hash"],
+        unique=False,
+    )
+    op.create_index(
+        "idx_auth_session_user",
+        "auth_session",
+        ["user_id", "expires_at"],
+        unique=False,
+    )
+    op.create_index(
+        "idx_auth_session_expiry",
+        "auth_session",
+        ["expires_at"],
+        unique=False,
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("idx_auth_session_expiry", table_name="auth_session")
+    op.drop_index("idx_auth_session_user", table_name="auth_session")
+    op.drop_index("ix_auth_session_token_hash", table_name="auth_session")
+    op.drop_table("auth_session")
+    op.drop_index("idx_auth_user_role_status", table_name="auth_user")
+    op.drop_index("ix_auth_user_username", table_name="auth_user")
+    op.drop_table("auth_user")

+ 33 - 0
alembic/versions/20260730_05_immutable_auth_session.py

@@ -0,0 +1,33 @@
+"""make persisted authentication sessions immutable
+
+Revision ID: 20260730_05
+Revises: 20260730_04
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_05"
+down_revision: str | None = "20260730_04"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.drop_column("auth_session", "last_active_at")
+
+
+def downgrade() -> None:
+    op.add_column(
+        "auth_session",
+        sa.Column(
+            "last_active_at",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )

+ 81 - 0
alembic/versions/20260730_06_add_demand_feedback.py

@@ -0,0 +1,81 @@
+"""add demand summary feedback records
+
+Revision ID: 20260730_06
+Revises: 20260730_05
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_06"
+down_revision: str | None = "20260730_05"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "demand_feedback",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("client_request_id", sa.String(length=64), nullable=False),
+        sa.Column(
+            "target_type",
+            sa.String(length=32),
+            nullable=False,
+            comment="反馈对象:demand / video / hit_content",
+        ),
+        sa.Column("biz_dt", sa.String(length=32), nullable=False),
+        sa.Column("demand_grade_id", sa.BigInteger(), nullable=False),
+        sa.Column("video_id", sa.String(length=64), nullable=True),
+        sa.Column("demand_video_expansion_id", sa.BigInteger(), nullable=True),
+        sa.Column("feedback_action", sa.String(length=32), nullable=False),
+        sa.Column("reason_code", sa.String(length=64), nullable=True),
+        sa.Column("content", sa.Text(), nullable=True),
+        sa.Column("target_snapshot_json", sa.Text(), nullable=False),
+        sa.Column("feedback_user_id", sa.Integer(), nullable=False),
+        sa.Column(
+            "feedback_user_name_snapshot",
+            sa.String(length=128),
+            nullable=False,
+        ),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint(
+            "client_request_id",
+            name="uk_demand_feedback_request",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_demand_feedback_demand",
+        "demand_feedback",
+        ["demand_grade_id", "created_at"],
+    )
+    op.create_index(
+        "idx_demand_feedback_video",
+        "demand_feedback",
+        ["demand_grade_id", "video_id", "created_at"],
+    )
+    op.create_index(
+        "idx_demand_feedback_expansion",
+        "demand_feedback",
+        ["demand_video_expansion_id", "created_at"],
+    )
+    op.create_index(
+        "idx_demand_feedback_user",
+        "demand_feedback",
+        ["feedback_user_id", "created_at"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("idx_demand_feedback_user", table_name="demand_feedback")
+    op.drop_index("idx_demand_feedback_expansion", table_name="demand_feedback")
+    op.drop_index("idx_demand_feedback_video", table_name="demand_feedback")
+    op.drop_index("idx_demand_feedback_demand", table_name="demand_feedback")
+    op.drop_table("demand_feedback")

+ 51 - 0
alembic/versions/20260730_07_add_feedback_username.py

@@ -0,0 +1,51 @@
+"""add feedback username snapshot
+
+Revision ID: 20260730_07
+Revises: 20260730_06
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_07"
+down_revision: str | None = "20260730_06"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "demand_feedback",
+        sa.Column(
+            "feedback_username_snapshot",
+            sa.String(length=64),
+            nullable=False,
+            server_default="",
+            comment="反馈人登录账号快照",
+        ),
+    )
+    op.execute(
+        sa.text(
+            """
+            UPDATE demand_feedback AS feedback
+            JOIN auth_user AS user_account
+              ON user_account.id = feedback.feedback_user_id
+            SET feedback.feedback_username_snapshot = user_account.username
+            WHERE feedback.feedback_username_snapshot = ''
+            """
+        )
+    )
+    op.alter_column(
+        "demand_feedback",
+        "feedback_username_snapshot",
+        existing_type=sa.String(length=64),
+        server_default=None,
+    )
+
+
+def downgrade() -> None:
+    op.drop_column("demand_feedback", "feedback_username_snapshot")

+ 165 - 0
alembic/versions/20260731_08_add_find_agent_p0_gates.py

@@ -0,0 +1,165 @@
+"""add find_agent P0 gate fields
+
+Revision ID: 20260731_08
+Revises: 20260730_07
+Create Date: 2026-07-31
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260731_08"
+down_revision: str | None = "20260730_07"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "video_discovery_run",
+        sa.Column(
+            "rule_version",
+            sa.String(length=64),
+            nullable=True,
+            comment="本次候选门禁规则版本",
+        ),
+    )
+    op.add_column(
+        "video_discovery_run",
+        sa.Column(
+            "rule_config_json",
+            sa.Text(),
+            nullable=True,
+            comment="运行时规则、当前时间和阈值快照 JSON",
+        ),
+    )
+
+    columns = (
+        sa.Column(
+            "publish_at",
+            sa.DateTime(),
+            nullable=True,
+            comment="视频真实发布时间,按运行时区解释",
+        ),
+        sa.Column(
+            "duration_seconds",
+            sa.Numeric(precision=10, scale=3),
+            nullable=True,
+            comment="视频时长(秒)",
+        ),
+        sa.Column(
+            "content_50_plus_ratio",
+            sa.Numeric(precision=8, scale=6),
+            nullable=True,
+            comment="视频点赞用户中 50+ 占比",
+        ),
+        sa.Column(
+            "content_50_plus_tgi",
+            sa.Numeric(precision=10, scale=4),
+            nullable=True,
+            comment="视频点赞用户 50+ TGI",
+        ),
+        sa.Column(
+            "content_portrait_status",
+            sa.String(length=16),
+            nullable=True,
+            comment="视频画像结论 pass / fail / missing",
+        ),
+        sa.Column(
+            "account_50_plus_ratio",
+            sa.Numeric(precision=8, scale=6),
+            nullable=True,
+            comment="账号粉丝中 50+ 占比",
+        ),
+        sa.Column(
+            "account_50_plus_tgi",
+            sa.Numeric(precision=10, scale=4),
+            nullable=True,
+            comment="账号粉丝 50+ TGI",
+        ),
+        sa.Column(
+            "account_portrait_status",
+            sa.String(length=16),
+            nullable=True,
+            comment="账号画像结论 pass / fail / missing",
+        ),
+        sa.Column(
+            "portrait_conflict",
+            sa.Integer(),
+            nullable=False,
+            server_default="0",
+            comment="视频与账号画像结论是否冲突",
+        ),
+        sa.Column(
+            "temporal_type",
+            sa.String(length=24),
+            nullable=True,
+            comment="时间内容类型",
+        ),
+        sa.Column(
+            "temporal_status",
+            sa.String(length=16),
+            nullable=True,
+            comment="时间有效性 pass / fail / unknown",
+        ),
+        sa.Column(
+            "temporal_evidence_json",
+            sa.Text(),
+            nullable=True,
+            comment="时间语义、有效窗口和判断理由 JSON",
+        ),
+        sa.Column(
+            "gate_status",
+            sa.String(length=16),
+            nullable=True,
+            comment="硬门槛 pass / fail / pending",
+        ),
+        sa.Column(
+            "gate_results_json",
+            sa.Text(),
+            nullable=True,
+            comment="逐项硬门槛结果 JSON",
+        ),
+        sa.Column(
+            "reject_reason_code",
+            sa.String(length=64),
+            nullable=True,
+            comment="主要淘汰原因码",
+        ),
+        sa.Column(
+            "rule_version",
+            sa.String(length=64),
+            nullable=True,
+            comment="候选评估使用的规则版本",
+        ),
+    )
+    for column in columns:
+        op.add_column("video_discovery_candidate", column)
+
+
+def downgrade() -> None:
+    candidate_columns = (
+        "rule_version",
+        "reject_reason_code",
+        "gate_results_json",
+        "gate_status",
+        "temporal_evidence_json",
+        "temporal_status",
+        "temporal_type",
+        "portrait_conflict",
+        "account_portrait_status",
+        "account_50_plus_tgi",
+        "account_50_plus_ratio",
+        "content_portrait_status",
+        "content_50_plus_tgi",
+        "content_50_plus_ratio",
+        "duration_seconds",
+        "publish_at",
+    )
+    for column in candidate_columns:
+        op.drop_column("video_discovery_candidate", column)
+    op.drop_column("video_discovery_run", "rule_config_json")
+    op.drop_column("video_discovery_run", "rule_version")

+ 133 - 2
api/app.py

@@ -4,22 +4,35 @@ from __future__ import annotations
 from contextlib import asynccontextmanager
 from pathlib import Path
 
-from fastapi import FastAPI, HTTPException, Query
+from typing import Literal
+
+from fastapi import FastAPI, HTTPException, Query, Request, status
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 from sqlalchemy import text
 from starlette.exceptions import HTTPException as StarletteHTTPException
 
+from api.auth_middleware import AuthenticationMiddleware
+from api.routers.auth import router as auth_router
 from api.routers.pipeline import router as pipeline_router
+from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.services.agent_catalog import (
     get_agent_detail,
     update_agent_document_injection,
 )
+from api.services.auth import ensure_bootstrap_admin
 from api.services.category_tree import build_category_tree
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
 from api.services.demand_grade_videos import list_videos_for_demand_grade
+from api.services.demand_feedback import (
+    FeedbackRequestConflictError,
+    FeedbackTargetConflictError,
+    FeedbackTargetNotFoundError,
+    create_demand_feedback,
+    list_demand_feedback,
+)
 from api.services.demand_videos import list_videos_for_demand_belong
 from api.services.oss_logs import list_agent_oss_logs, list_demand_belong_oss_logs
 from api.services.scheduler import (
@@ -33,6 +46,12 @@ from api.services.video_discovery import (
     get_video_discovery_demand,
     list_video_discovery_demands,
 )
+from api.services.video_discovery_records import (
+    get_video_discovery_run,
+    list_video_discovery_candidates,
+    list_video_discovery_runs,
+    list_video_discovery_searches,
+)
 from supply_infra.config import get_infra_settings
 from supply_infra.db import dispose_engine, get_session, init_db
 
@@ -59,11 +78,13 @@ class SPAStaticFiles(StaticFiles):
 async def lifespan(_app: FastAPI):
     if get_infra_settings().database_auto_create:
         init_db()
+    ensure_bootstrap_admin()
     yield
     dispose_engine()
 
 
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
+app.include_router(auth_router)
 app.include_router(pipeline_router)
 
 app.add_middleware(
@@ -78,6 +99,7 @@ app.add_middleware(
     allow_methods=["*"],
     allow_headers=["*"],
 )
+app.add_middleware(AuthenticationMiddleware)
 
 
 @app.get("/health")
@@ -146,7 +168,7 @@ def run_pipeline(
     """
     异步一键执行供给数据全流程,立即返回 run_id,后台串行执行:
 
-    全局树同步 → 需求池同步 → 需求分级 → 视频点位拓展 → find_agent 找 → AIGC 发布。
+    全局树同步 → 需求池同步 → 需求分级 → 视频点位拓展 → find_agent 找视频 → AIGC 发布。
     """
     resolved_biz_dt = body.biz_dt if body and body.biz_dt is not None else biz_dt
     return run_supply_pipeline(biz_dt=resolved_biz_dt)
@@ -245,6 +267,73 @@ def video_discovery_demands(
     return list_video_discovery_demands(biz_dt=biz_dt)
 
 
+@app.get("/api/video-discovery/runs")
+def video_discovery_runs(
+    biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+    status: Literal["running", "finished", "failed"] | None = None,
+    keyword: str | None = Query(default=None, max_length=256),
+    limit: int = Query(default=20, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return paged find-agent runs for the records workspace."""
+    return list_video_discovery_runs(
+        biz_dt=biz_dt,
+        status=status,
+        keyword=keyword,
+        limit=limit,
+        offset=offset,
+    )
+
+
+@app.get("/api/video-discovery/runs/{run_id}")
+def video_discovery_run(run_id: str) -> dict:
+    """Return one find-agent run and its aggregate counts."""
+    result = get_video_discovery_run(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="video discovery run not found")
+    return result
+
+
+@app.get("/api/video-discovery/runs/{run_id}/searches")
+def video_discovery_searches(
+    run_id: str,
+    keyword: str | None = Query(default=None, max_length=256),
+    limit: int = Query(default=20, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return paged search-page records for one find-agent run."""
+    result = list_video_discovery_searches(
+        run_id,
+        keyword=keyword,
+        limit=limit,
+        offset=offset,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail="video discovery run not found")
+    return result
+
+
+@app.get("/api/video-discovery/runs/{run_id}/candidates")
+def video_discovery_candidates(
+    run_id: str,
+    bucket: str | None = Query(default=None, max_length=24),
+    keyword: str | None = Query(default=None, max_length=256),
+    limit: int = Query(default=20, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return paged candidate records for one find-agent run."""
+    result = list_video_discovery_candidates(
+        run_id,
+        bucket=bucket,
+        keyword=keyword,
+        limit=limit,
+        offset=offset,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail="video discovery run not found")
+    return result
+
+
 @app.get("/api/video-discovery/demands/{demand_grade_id}")
 def video_discovery_demand(demand_grade_id: int) -> dict:
     """Return one demand and its videos/points resolved from the source tables."""
@@ -254,6 +343,48 @@ def video_discovery_demand(demand_grade_id: int) -> dict:
     return result
 
 
+@app.post(
+    "/api/video-discovery/feedback",
+    status_code=status.HTTP_201_CREATED,
+)
+def submit_video_discovery_feedback(
+    body: CreateDemandFeedbackBody,
+    request: Request,
+) -> dict:
+    """Append feedback for one demand, video or hit-content record."""
+    try:
+        return create_demand_feedback(body, request.state.current_user)
+    except FeedbackTargetNotFoundError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except (FeedbackTargetConflictError, FeedbackRequestConflictError) as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
+@app.get("/api/video-discovery/feedback")
+def video_discovery_feedback_history(
+    target_type: Literal["demand", "video", "hit_content"],
+    demand_grade_id: int = Query(gt=0),
+    video_id: str | None = Query(default=None, max_length=64),
+    demand_video_expansion_id: int | None = Query(default=None, gt=0),
+    limit: int = Query(default=50, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return feedback records and feedback people for one target."""
+    try:
+        return list_demand_feedback(
+            target_type=target_type,
+            demand_grade_id=demand_grade_id,
+            video_id=video_id,
+            demand_video_expansion_id=demand_video_expansion_id,
+            limit=limit,
+            offset=offset,
+        )
+    except FeedbackTargetNotFoundError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except FeedbackTargetConflictError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
 @app.get("/api/demand-belong-oss-logs")
 def demand_belong_oss_logs() -> dict:
     """Return demand_belong_category_agent oss_logs ordered by create_time desc."""

+ 80 - 0
api/auth_middleware.py

@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import re
+from collections.abc import Awaitable, Callable
+
+from fastapi import Request
+from starlette.concurrency import run_in_threadpool
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.responses import JSONResponse, Response
+
+from api.services.auth import ADMIN_ROLE, SESSION_COOKIE_NAME, resolve_session
+
+_PUBLIC_PATHS = {
+    "/api/auth/login",
+}
+_AUTHENTICATED_USER_PATHS = {
+    ("GET", "/api/auth/me"),
+    ("POST", "/api/auth/logout"),
+}
+_NORMAL_USER_PATHS = {
+    ("GET", "/api/category-tree"),
+    ("GET", "/api/demand-grade"),
+    ("GET", "/api/video-discovery/demands"),
+    ("GET", "/api/video-discovery/runs"),
+    ("GET", "/api/video-discovery/feedback"),
+    ("POST", "/api/video-discovery/feedback"),
+}
+_NORMAL_USER_PATTERNS = (
+    re.compile(r"^/api/video-discovery/demands/\d+$"),
+    re.compile(r"^/api/video-discovery/runs/[^/]+$"),
+    re.compile(r"^/api/video-discovery/runs/[^/]+/searches$"),
+    re.compile(r"^/api/video-discovery/runs/[^/]+/candidates$"),
+    re.compile(r"^/api/demand-grade/\d+/videos$"),
+)
+
+
+def normal_user_can_access(method: str, path: str) -> bool:
+    if (method, path) in _AUTHENTICATED_USER_PATHS:
+        return True
+    if (method, path) in _NORMAL_USER_PATHS:
+        return True
+    return method == "GET" and any(pattern.fullmatch(path) for pattern in _NORMAL_USER_PATTERNS)
+
+
+def _is_protected_path(path: str) -> bool:
+    return (
+        path == "/api"
+        or path.startswith("/api/")
+        or path in {"/docs", "/redoc", "/openapi.json"}
+    )
+
+
+class AuthenticationMiddleware(BaseHTTPMiddleware):
+    """Authenticate API requests and enforce the two fixed application roles."""
+
+    async def dispatch(
+        self,
+        request: Request,
+        call_next: Callable[[Request], Awaitable[Response]],
+    ) -> Response:
+        path = request.url.path
+        method = request.method.upper()
+        if method == "OPTIONS" or not _is_protected_path(path) or path in _PUBLIC_PATHS:
+            return await call_next(request)
+
+        token = request.cookies.get(SESSION_COOKIE_NAME)
+        user = await run_in_threadpool(resolve_session, token) if token else None
+        if user is None:
+            return JSONResponse(
+                status_code=401,
+                content={"detail": "authentication required"},
+            )
+
+        request.state.current_user = user
+        if user["role"] == ADMIN_ROLE or normal_user_can_access(method, path):
+            return await call_next(request)
+        return JSONResponse(
+            status_code=403,
+            content={"detail": "permission denied"},
+        )

+ 120 - 0
api/routers/auth.py

@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Request, Response, status
+
+from api.schemas.auth import CreateAuthUserBody, LoginBody, UpdateAuthUserBody
+from api.services.auth import (
+    SESSION_COOKIE_NAME,
+    DuplicateUsernameError,
+    InvalidCredentialsError,
+    authenticate,
+    create_user,
+    delete_user,
+    list_users,
+    revoke_session,
+    update_user,
+)
+from supply_infra.config import get_infra_settings
+
+router = APIRouter(prefix="/api", tags=["authentication"])
+
+
+@router.post("/auth/login")
+def login(body: LoginBody, request: Request, response: Response) -> dict:
+    try:
+        user, token = authenticate(
+            username=body.username,
+            password=body.password,
+            ip_address=request.client.host if request.client else None,
+            user_agent=request.headers.get("user-agent"),
+        )
+    except InvalidCredentialsError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="用户名或密码错误",
+        ) from None
+
+    settings = get_infra_settings()
+    response.set_cookie(
+        key=SESSION_COOKIE_NAME,
+        value=token,
+        max_age=settings.auth_session_hours * 60 * 60,
+        httponly=True,
+        secure=settings.auth_cookie_secure,
+        samesite="lax",
+        path="/",
+    )
+    return {"user": user}
+
+
+@router.get("/auth/me")
+def me(request: Request) -> dict:
+    return {"user": request.state.current_user}
+
+
+@router.post("/auth/logout")
+def logout(request: Request, response: Response) -> dict[str, bool]:
+    token = request.cookies.get(SESSION_COOKIE_NAME)
+    if token:
+        revoke_session(token)
+    response.delete_cookie(
+        key=SESSION_COOKIE_NAME,
+        path="/",
+        secure=get_infra_settings().auth_cookie_secure,
+        httponly=True,
+        samesite="lax",
+    )
+    return {"ok": True}
+
+
+@router.get("/admin/users")
+def admin_users() -> dict:
+    return {"items": list_users()}
+
+
+@router.post("/admin/users", status_code=201)
+def admin_create_user(body: CreateAuthUserBody) -> dict:
+    try:
+        return create_user(
+            username=body.username,
+            password=body.password,
+            display_name=body.display_name,
+            role=body.role,
+        )
+    except DuplicateUsernameError:
+        raise HTTPException(status_code=409, detail="用户名已存在") from None
+
+
+@router.patch("/admin/users/{user_id}")
+def admin_update_user(
+    user_id: int,
+    body: UpdateAuthUserBody,
+    request: Request,
+) -> dict:
+    current_user = request.state.current_user
+    if current_user["id"] == user_id and (
+        (body.role is not None and body.role != current_user["role"])
+        or body.status == "disabled"
+        or body.password is not None
+    ):
+        raise HTTPException(status_code=409, detail="不能降级、禁用或重置当前登录账号")
+
+    result = update_user(
+        user_id,
+        display_name=body.display_name,
+        role=body.role,
+        status=body.status,
+        password=body.password,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail="用户不存在")
+    return result
+
+
+@router.delete("/admin/users/{user_id}", status_code=204)
+def admin_delete_user(user_id: int, request: Request) -> Response:
+    if request.state.current_user["id"] == user_id:
+        raise HTTPException(status_code=409, detail="不能删除当前登录账号")
+    if not delete_user(user_id):
+        raise HTTPException(status_code=404, detail="用户不存在")
+    return Response(status_code=204)

+ 32 - 0
api/schemas/auth.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+Username = str
+Role = Literal["admin", "user"]
+UserStatus = Literal["active", "disabled"]
+
+
+class LoginBody(BaseModel):
+    username: Username = Field(min_length=3, max_length=64)
+    password: str = Field(min_length=1, max_length=128)
+
+
+class CreateAuthUserBody(BaseModel):
+    username: Username = Field(
+        min_length=3,
+        max_length=64,
+        pattern=r"^[A-Za-z0-9_.-]+$",
+    )
+    password: str = Field(min_length=8, max_length=128)
+    display_name: str = Field(min_length=1, max_length=128)
+    role: Role = "user"
+
+
+class UpdateAuthUserBody(BaseModel):
+    display_name: str | None = Field(default=None, min_length=1, max_length=128)
+    role: Role | None = None
+    status: UserStatus | None = None
+    password: str | None = Field(default=None, min_length=8, max_length=128)

+ 79 - 0
api/schemas/demand_feedback.py

@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+FeedbackTargetType = Literal["demand", "video", "hit_content"]
+FeedbackAction = Literal["support", "oppose", "correct", "supplement"]
+
+REASON_CODES_BY_TARGET: dict[str, set[str]] = {
+    "demand": {
+        "not_a_demand",
+        "wrong_grade",
+        "wrong_category",
+        "unclear_wording",
+        "wrong_reason",
+        "missing_demand",
+        "other",
+    },
+    "video": {
+        "irrelevant",
+        "weak_relevance",
+        "unavailable",
+        "duplicate",
+        "wrong_info",
+        "missing_video",
+        "other",
+    },
+    "hit_content": {
+        "not_hit",
+        "wrong_point_type",
+        "inaccurate_wording",
+        "wrong_reason",
+        "duplicate",
+        "missing_content",
+        "other",
+    },
+}
+
+
+class CreateDemandFeedbackBody(BaseModel):
+    client_request_id: str = Field(min_length=8, max_length=64)
+    target_type: FeedbackTargetType
+    demand_grade_id: int = Field(gt=0)
+    video_id: str | None = Field(default=None, max_length=64)
+    demand_video_expansion_id: int | None = Field(default=None, gt=0)
+    feedback_action: FeedbackAction
+    reason_code: str | None = Field(default=None, max_length=64)
+    content: str | None = Field(default=None, max_length=1000)
+
+    @field_validator("client_request_id", "video_id", "reason_code", "content")
+    @classmethod
+    def strip_text(cls, value: str | None) -> str | None:
+        if value is None:
+            return None
+        stripped = value.strip()
+        return stripped or None
+
+    @model_validator(mode="after")
+    def validate_target_and_content(self) -> "CreateDemandFeedbackBody":
+        if self.target_type == "demand":
+            if self.video_id is not None or self.demand_video_expansion_id is not None:
+                raise ValueError("需求反馈不能包含视频或命中内容 ID")
+        elif self.target_type == "video":
+            if not self.video_id or self.demand_video_expansion_id is not None:
+                raise ValueError("视频反馈必须且只能包含视频 ID")
+        elif not self.video_id or self.demand_video_expansion_id is None:
+            raise ValueError("命中内容反馈必须包含视频 ID 和命中内容 ID")
+
+        if self.feedback_action == "oppose" and not self.reason_code:
+            raise ValueError("不认可反馈必须选择问题原因")
+        if self.feedback_action in {"correct", "supplement"} and not self.content:
+            raise ValueError("纠错或补充反馈必须填写说明")
+        if (
+            self.reason_code is not None
+            and self.reason_code not in REASON_CODES_BY_TARGET[self.target_type]
+        ):
+            raise ValueError("问题原因不适用于当前反馈对象")
+        return self

+ 280 - 0
api/services/auth.py

@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import logging
+import secrets
+from datetime import datetime, timedelta
+from typing import Any
+
+from sqlalchemy.exc import IntegrityError
+
+from supply_infra.config import get_infra_settings
+from supply_infra.db import get_session
+from supply_infra.db.models.auth_user import AuthUser
+from supply_infra.db.repositories.auth_repo import AuthRepository
+from supply_infra.pipeline.dates import CHINA_TIMEZONE
+
+logger = logging.getLogger(__name__)
+
+SESSION_COOKIE_NAME = "supply_session"
+ADMIN_ROLE = "admin"
+USER_ROLE = "user"
+ACTIVE_STATUS = "active"
+DISABLED_STATUS = "disabled"
+VALID_ROLES = {ADMIN_ROLE, USER_ROLE}
+VALID_STATUSES = {ACTIVE_STATUS, DISABLED_STATUS}
+
+_SCRYPT_N = 2**14
+_SCRYPT_R = 8
+_SCRYPT_P = 1
+_DUMMY_PASSWORD_HASH: str | None = None
+
+
+class InvalidCredentialsError(Exception):
+    """Raised for every login rejection to avoid exposing account state."""
+
+
+class DuplicateUsernameError(Exception):
+    """Raised when a local username already exists."""
+
+
+def _now() -> datetime:
+    return datetime.now(CHINA_TIMEZONE).replace(tzinfo=None)
+
+
+def normalize_username(username: str) -> str:
+    return username.strip().lower()
+
+
+def hash_password(password: str) -> str:
+    salt = secrets.token_bytes(16)
+    derived = hashlib.scrypt(
+        password.encode("utf-8"),
+        salt=salt,
+        n=_SCRYPT_N,
+        r=_SCRYPT_R,
+        p=_SCRYPT_P,
+        dklen=32,
+    )
+    return (
+        f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
+        f"{salt.hex()}${derived.hex()}"
+    )
+
+
+def verify_password(password: str, encoded: str) -> bool:
+    try:
+        algorithm, n, r, p, salt_hex, expected_hex = encoded.split("$", 5)
+        if algorithm != "scrypt":
+            return False
+        derived = hashlib.scrypt(
+            password.encode("utf-8"),
+            salt=bytes.fromhex(salt_hex),
+            n=int(n),
+            r=int(r),
+            p=int(p),
+            dklen=len(bytes.fromhex(expected_hex)),
+        )
+        return hmac.compare_digest(derived.hex(), expected_hex)
+    except (TypeError, ValueError):
+        return False
+
+
+def _dummy_password_hash() -> str:
+    global _DUMMY_PASSWORD_HASH
+    if _DUMMY_PASSWORD_HASH is None:
+        _DUMMY_PASSWORD_HASH = hash_password("supply-agent-invalid-password")
+    return _DUMMY_PASSWORD_HASH
+
+
+def hash_session_token(token: str) -> str:
+    return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def serialize_user(user: AuthUser) -> dict[str, Any]:
+    return {
+        "id": user.id,
+        "username": user.username,
+        "display_name": user.display_name,
+        "role": user.role,
+        "status": user.status,
+        "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
+        "created_at": user.created_at.isoformat() if user.created_at else None,
+        "updated_at": user.updated_at.isoformat() if user.updated_at else None,
+    }
+
+
+def authenticate(
+    *,
+    username: str,
+    password: str,
+    ip_address: str | None,
+    user_agent: str | None,
+) -> tuple[dict[str, Any], str]:
+    now = _now()
+    normalized = normalize_username(username)
+    settings = get_infra_settings()
+
+    with get_session() as db:
+        repo = AuthRepository(db)
+        repo.delete_expired_sessions(now)
+        user = repo.get_user_by_username(normalized)
+
+        if user is None:
+            verify_password(password, _dummy_password_hash())
+            raise InvalidCredentialsError
+
+        if user.locked_until and user.locked_until > now:
+            verify_password(password, user.password_hash)
+            raise InvalidCredentialsError
+
+        if user.locked_until and user.locked_until <= now:
+            user.locked_until = None
+            user.failed_login_count = 0
+
+        password_valid = verify_password(password, user.password_hash)
+        if not password_valid or user.status != ACTIVE_STATUS:
+            if password_valid:
+                raise InvalidCredentialsError
+            user.failed_login_count += 1
+            if user.failed_login_count >= 5:
+                user.failed_login_count = 0
+                user.locked_until = now + timedelta(minutes=15)
+            raise InvalidCredentialsError
+
+        user.failed_login_count = 0
+        user.locked_until = None
+        user.last_login_at = now
+        token = secrets.token_urlsafe(32)
+        repo.create_session(
+            token_hash=hash_session_token(token),
+            user_id=user.id,
+            expires_at=now + timedelta(hours=settings.auth_session_hours),
+            ip_address=(ip_address or "")[:64] or None,
+            user_agent=(user_agent or "")[:512] or None,
+        )
+        db.flush()
+        return serialize_user(user), token
+
+
+def resolve_session(token: str) -> dict[str, Any] | None:
+    now = _now()
+    with get_session() as db:
+        repo = AuthRepository(db)
+        user = repo.get_active_session(
+            token_hash=hash_session_token(token),
+            now=now,
+        )
+        if user is None:
+            return None
+        return serialize_user(user)
+
+
+def revoke_session(token: str) -> None:
+    with get_session() as db:
+        AuthRepository(db).delete_session_by_hash(hash_session_token(token))
+
+
+def list_users() -> list[dict[str, Any]]:
+    with get_session() as db:
+        return [serialize_user(user) for user in AuthRepository(db).list_users()]
+
+
+def create_user(
+    *,
+    username: str,
+    password: str,
+    display_name: str,
+    role: str,
+) -> dict[str, Any]:
+    if role not in VALID_ROLES:
+        raise ValueError("invalid role")
+    try:
+        with get_session() as db:
+            user = AuthRepository(db).create_user(
+                username=normalize_username(username),
+                password_hash=hash_password(password),
+                display_name=display_name.strip(),
+                role=role,
+            )
+            db.flush()
+            return serialize_user(user)
+    except IntegrityError as exc:
+        raise DuplicateUsernameError from exc
+
+
+def update_user(
+    user_id: int,
+    *,
+    display_name: str | None = None,
+    role: str | None = None,
+    status: str | None = None,
+    password: str | None = None,
+) -> dict[str, Any] | None:
+    if role is not None and role not in VALID_ROLES:
+        raise ValueError("invalid role")
+    if status is not None and status not in VALID_STATUSES:
+        raise ValueError("invalid status")
+
+    with get_session() as db:
+        repo = AuthRepository(db)
+        user = repo.get_user(user_id)
+        if user is None:
+            return None
+        revoke_existing_sessions = False
+        if display_name is not None:
+            user.display_name = display_name.strip()
+        if role is not None and role != user.role:
+            user.role = role
+            revoke_existing_sessions = True
+        if status is not None and status != user.status:
+            user.status = status
+            revoke_existing_sessions = True
+        if password is not None:
+            user.password_hash = hash_password(password)
+            user.failed_login_count = 0
+            user.locked_until = None
+            revoke_existing_sessions = True
+        if revoke_existing_sessions:
+            repo.delete_user_sessions(user.id)
+        db.flush()
+        return serialize_user(user)
+
+
+def delete_user(user_id: int) -> bool:
+    with get_session() as db:
+        repo = AuthRepository(db)
+        user = repo.get_user(user_id)
+        if user is None:
+            return False
+        repo.delete_user_sessions(user.id)
+        repo.delete_user(user)
+        return True
+
+
+def ensure_bootstrap_admin() -> None:
+    settings = get_infra_settings()
+    username = normalize_username(settings.auth_bootstrap_admin_username)
+    password = settings.auth_bootstrap_admin_password.get_secret_value()
+    if not username and not password:
+        return
+    if not username or not password:
+        raise RuntimeError(
+            "AUTH_BOOTSTRAP_ADMIN_USERNAME and AUTH_BOOTSTRAP_ADMIN_PASSWORD "
+            "must be configured together"
+        )
+    if len(password) < 8:
+        raise RuntimeError("AUTH_BOOTSTRAP_ADMIN_PASSWORD must contain at least 8 characters")
+
+    with get_session() as db:
+        repo = AuthRepository(db)
+        if repo.get_user_by_username(username) is not None:
+            return
+        repo.create_user(
+            username=username,
+            password_hash=hash_password(password),
+            display_name=settings.auth_bootstrap_admin_display_name.strip() or "系统管理员",
+            role=ADMIN_ROLE,
+        )
+        logger.info("Created bootstrap administrator: username=%s", username)

+ 265 - 0
api/services/demand_feedback.py

@@ -0,0 +1,265 @@
+"""Human feedback for demand-summary records."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from pydantic import ValidationError
+from sqlalchemy.exc import IntegrityError
+
+from api.schemas.demand_feedback import CreateDemandFeedbackBody
+from api.services.video_discovery import _parse_video_ids
+from supply_infra.db.models.demand_feedback import DemandFeedback
+from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
+from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
+from supply_infra.db.repositories.demand_video_expansion_repo import (
+    DemandVideoExpansionRepository,
+)
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+
+_SOURCE_VIDEO_GRADES = frozenset({"B", "C", "D"})
+
+
+class FeedbackTargetNotFoundError(Exception):
+    """Raised when the requested demand, video or hit content does not exist."""
+
+
+class FeedbackTargetConflictError(Exception):
+    """Raised when target identifiers do not belong to the same record."""
+
+
+class FeedbackRequestConflictError(Exception):
+    """Raised when an idempotency key is reused for a different request."""
+
+
+def _feedback_summary(count: int) -> dict[str, int]:
+    return {"count": int(count)}
+
+
+def _serialize_feedback(row: DemandFeedback) -> dict[str, Any]:
+    try:
+        target_snapshot = json.loads(row.target_snapshot_json)
+    except (TypeError, ValueError):
+        target_snapshot = {}
+    return {
+        "id": int(row.id),
+        "target_type": row.target_type,
+        "biz_dt": row.biz_dt,
+        "demand_grade_id": int(row.demand_grade_id),
+        "video_id": row.video_id,
+        "demand_video_expansion_id": (
+            int(row.demand_video_expansion_id)
+            if row.demand_video_expansion_id is not None
+            else None
+        ),
+        "feedback_action": row.feedback_action,
+        "reason_code": row.reason_code,
+        "content": row.content,
+        "target_snapshot": target_snapshot,
+        "feedback_user": {
+            "id": int(row.feedback_user_id),
+            "username": row.feedback_username_snapshot,
+            "display_name": row.feedback_user_name_snapshot,
+        },
+        "created_at": row.created_at.isoformat() if row.created_at else None,
+    }
+
+
+def _same_request(
+    row: DemandFeedback,
+    body: CreateDemandFeedbackBody,
+    feedback_user_id: int,
+) -> bool:
+    return (
+        row.feedback_user_id == feedback_user_id
+        and row.target_type == body.target_type
+        and row.demand_grade_id == body.demand_grade_id
+        and row.video_id == body.video_id
+        and row.demand_video_expansion_id == body.demand_video_expansion_id
+        and row.feedback_action == body.feedback_action
+        and row.reason_code == body.reason_code
+        and row.content == body.content
+    )
+
+
+def _resolve_target(
+    session: Any,
+    body: CreateDemandFeedbackBody,
+) -> tuple[Any, dict[str, Any]]:
+    grade = DemandGradeRepository(session).get_by_id(body.demand_grade_id)
+    if grade is None:
+        raise FeedbackTargetNotFoundError("需求不存在")
+
+    snapshot: dict[str, Any] = {
+        "demand_name": grade.demand_name,
+        "grade": grade.grade,
+    }
+    if body.target_type == "demand":
+        snapshot["reason"] = grade.reason
+        return grade, snapshot
+
+    video_id = body.video_id or ""
+    grade_code = str(grade.grade or "").upper()
+    expansion_repo = DemandVideoExpansionRepository(session)
+    if grade_code in _SOURCE_VIDEO_GRADES:
+        video_exists = video_id in _parse_video_ids(grade.video_list)
+        video_source = "pool"
+    else:
+        video_exists = expansion_repo.has_video(
+            biz_dt=str(grade.biz_dt),
+            source_demand_grade_id=int(grade.id),
+            video_id=video_id,
+        )
+        video_source = "expansion"
+    if not video_exists:
+        raise FeedbackTargetConflictError("视频不属于当前需求")
+
+    detail = MultiDemandVideoDetailRepository(session).list_by_vids([video_id]).get(
+        video_id
+    )
+    snapshot.update(
+        {
+            "video_id": video_id,
+            "video_title": detail.title if detail else None,
+            "video_source": video_source,
+        }
+    )
+    if body.target_type == "video":
+        return grade, snapshot
+
+    expansion = expansion_repo.get_active_by_id(body.demand_video_expansion_id or 0)
+    if expansion is None:
+        raise FeedbackTargetNotFoundError("命中内容不存在")
+    if (
+        int(expansion.source_demand_grade_id) != int(grade.id)
+        or str(expansion.biz_dt) != str(grade.biz_dt)
+        or str(expansion.video_id) != video_id
+    ):
+        raise FeedbackTargetConflictError("命中内容不属于当前需求和视频")
+    snapshot.update(
+        {
+            "point_type": expansion.point_type,
+            "expanded_text": expansion.expanded_text,
+            "point_desc": expansion.point_desc,
+            "reason": expansion.reason,
+        }
+    )
+    return grade, snapshot
+
+
+def create_demand_feedback(
+    body: CreateDemandFeedbackBody,
+    current_user: dict[str, Any],
+) -> dict[str, Any]:
+    feedback_user_id = int(current_user["id"])
+    feedback_username = str(current_user["username"])
+    feedback_user_name = str(
+        current_user.get("display_name") or current_user.get("username") or feedback_user_id
+    )
+    with get_session() as session:
+        repo = DemandFeedbackRepository(session)
+        existing = repo.get_by_client_request_id(body.client_request_id)
+        if existing is not None:
+            if not _same_request(existing, body, feedback_user_id):
+                raise FeedbackRequestConflictError("请求标识已用于其他反馈")
+            _, video_counts, expansion_counts = repo.count_for_demand(
+                body.demand_grade_id
+            )
+            count = (
+                repo.count_demands([body.demand_grade_id]).get(body.demand_grade_id, 0)
+                if body.target_type == "demand"
+                else video_counts.get(body.video_id or "", 0)
+                if body.target_type == "video"
+                else expansion_counts.get(body.demand_video_expansion_id or 0, 0)
+            )
+            return {
+                "item": _serialize_feedback(existing),
+                "feedback_summary": _feedback_summary(count),
+            }
+
+        grade, target_snapshot = _resolve_target(session, body)
+        feedback = DemandFeedback(
+            client_request_id=body.client_request_id,
+            target_type=body.target_type,
+            biz_dt=str(grade.biz_dt),
+            demand_grade_id=int(grade.id),
+            video_id=body.video_id,
+            demand_video_expansion_id=body.demand_video_expansion_id,
+            feedback_action=body.feedback_action,
+            reason_code=body.reason_code,
+            content=body.content,
+            target_snapshot_json=json.dumps(
+                target_snapshot,
+                ensure_ascii=False,
+                separators=(",", ":"),
+            ),
+            feedback_user_id=feedback_user_id,
+            feedback_user_name_snapshot=feedback_user_name[:128],
+            feedback_username_snapshot=feedback_username[:64],
+        )
+        try:
+            repo.add(feedback)
+            session.refresh(feedback)
+        except IntegrityError:
+            session.rollback()
+            existing = repo.get_by_client_request_id(body.client_request_id)
+            if existing is None or not _same_request(existing, body, feedback_user_id):
+                raise FeedbackRequestConflictError("请求标识已用于其他反馈") from None
+            feedback = existing
+
+        rows, total = repo.list_for_target(
+            target_type=body.target_type,
+            demand_grade_id=body.demand_grade_id,
+            video_id=body.video_id,
+            demand_video_expansion_id=body.demand_video_expansion_id,
+            limit=1,
+            offset=0,
+        )
+        del rows
+        return {
+            "item": _serialize_feedback(feedback),
+            "feedback_summary": _feedback_summary(total),
+        }
+
+
+def list_demand_feedback(
+    *,
+    target_type: str,
+    demand_grade_id: int,
+    video_id: str | None,
+    demand_video_expansion_id: int | None,
+    limit: int,
+    offset: int,
+) -> dict[str, Any]:
+    try:
+        body = CreateDemandFeedbackBody(
+            client_request_id="history-query",
+            target_type=target_type,
+            demand_grade_id=demand_grade_id,
+            video_id=video_id,
+            demand_video_expansion_id=demand_video_expansion_id,
+            feedback_action="support",
+        )
+    except ValidationError as exc:
+        raise FeedbackTargetConflictError("反馈目标参数不完整") from exc
+    with get_session() as session:
+        repo = DemandFeedbackRepository(session)
+        rows, total = repo.list_for_target(
+            target_type=target_type,
+            demand_grade_id=demand_grade_id,
+            video_id=video_id,
+            demand_video_expansion_id=demand_video_expansion_id,
+            limit=limit,
+            offset=offset,
+        )
+        if total == 0:
+            _resolve_target(session, body)
+        return {
+            "items": [_serialize_feedback(row) for row in rows],
+            "total": total,
+            "limit": limit,
+            "offset": offset,
+        }

+ 159 - 37
api/services/video_discovery.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 import json
 from typing import Any
 
+from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
 from supply_infra.db.repositories.demand_video_expansion_repo import (
     DemandVideoExpansionRepository,
@@ -14,9 +15,81 @@ from supply_infra.db.repositories.global_tree_category_repo import (
 from supply_infra.db.repositories.multi_demand_video_detail_repo import (
     MultiDemandVideoDetailRepository,
 )
+from supply_infra.db.repositories.pipeline_step_run_repo import PipelineStepRunRepository
 from supply_infra.db.session import get_session
 
 _POINT_TYPES = {"inspiration", "purpose", "key"}
+_SOURCE_VIDEO_GRADES = frozenset({"B", "C", "D"})
+_VIDEO_DETAIL_URL_TEMPLATE = "https://admin.piaoquantv.com/cms/post-detail/{vid}/detail"
+
+
+def _parse_video_ids(raw: Any) -> list[str]:
+    """Parse JSON array or comma-separated text into ordered unique vid list."""
+    if raw is None:
+        return []
+
+    items: list[Any]
+    if isinstance(raw, str):
+        text = raw.strip()
+        if not text:
+            return []
+        try:
+            parsed = json.loads(text)
+        except (TypeError, ValueError):
+            parsed = None
+        if isinstance(parsed, list):
+            items = parsed
+        elif parsed is not None:
+            items = [parsed]
+        else:
+            items = [part.strip() for part in text.split(",")]
+    elif isinstance(raw, (list, tuple)):
+        items = list(raw)
+    else:
+        return []
+
+    result: list[str] = []
+    seen: set[str] = set()
+    for item in items:
+        vid = str(item).strip() if item is not None else ""
+        if vid and vid not in seen:
+            seen.add(vid)
+            result.append(vid)
+    return result
+
+
+def _normalize_url(value: Any) -> str | None:
+    if value is None:
+        return None
+    text = str(value).strip()
+    return text or None
+
+
+def _serialize_video(
+    video_id: str,
+    detail: Any | None,
+    points: list[dict[str, Any]],
+    feedback_count: int = 0,
+) -> dict[str, Any]:
+    url1 = _normalize_url(detail.url1) if detail else None
+    url2 = _normalize_url(detail.url2) if detail else None
+    return {
+        "vid": video_id,
+        "title": detail.title if detail else None,
+        "url1": url1,
+        "url2": url2,
+        "video_detail_url": _VIDEO_DETAIL_URL_TEMPLATE.format(vid=video_id),
+        "point_count": len(points),
+        "points": points,
+        "feedback_summary": {"count": int(feedback_count)},
+    }
+
+
+def _source_video_count(row: Any, expansion_counts: dict[int, int]) -> int:
+    grade = str(row.grade or "").upper()
+    if grade in _SOURCE_VIDEO_GRADES:
+        return len(_parse_video_ids(row.video_list))
+    return expansion_counts.get(int(row.id), 0)
 
 
 def _parse_string_list(raw: Any) -> list[str]:
@@ -72,6 +145,7 @@ def _serialize_demand(
     row: Any,
     category_names: dict[int, str],
     video_count: int,
+    feedback_count: int = 0,
 ) -> dict[str, Any]:
     category_ids = _parse_int_list(row.category_ids)
     return {
@@ -93,6 +167,7 @@ def _serialize_demand(
         "strategies": _parse_string_list(row.strategies),
         "reason": row.reason,
         "video_count": video_count,
+        "feedback_summary": {"count": int(feedback_count)},
     }
 
 
@@ -112,11 +187,26 @@ def _category_name_map(session: Any, rows: list[Any]) -> dict[int, str]:
     }
 
 
+_DEMAND_EXPAND_STEP_KEY = "demand_expand"
+
+
+def _resolve_video_discovery_biz_dt(session: Any, biz_dt: str | None) -> str | None:
+    """默认取最近一次成功完成的 demand_expand 步骤所属业务日。"""
+    if biz_dt:
+        return str(biz_dt)
+    latest_expand_dt = PipelineStepRunRepository(session).get_latest_succeeded_biz_dt(
+        _DEMAND_EXPAND_STEP_KEY
+    )
+    if latest_expand_dt:
+        return latest_expand_dt
+    return DemandGradeRepository(session).get_latest_biz_dt()
+
+
 def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
     """Return one demand card per demand_grade row for the selected/latest day."""
     with get_session() as session:
         grade_repo = DemandGradeRepository(session)
-        resolved_biz_dt = biz_dt or grade_repo.get_latest_biz_dt()
+        resolved_biz_dt = _resolve_video_discovery_biz_dt(session, biz_dt)
         if not resolved_biz_dt:
             return {"biz_dt": None, "items": []}
 
@@ -125,13 +215,17 @@ def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
         video_counts = DemandVideoExpansionRepository(
             session
         ).count_distinct_videos_by_demand_grade(str(resolved_biz_dt))
+        feedback_counts = DemandFeedbackRepository(session).count_demands(
+            [int(row.id) for row in rows]
+        )
         return {
             "biz_dt": str(resolved_biz_dt),
             "items": [
                 _serialize_demand(
                     row,
                     category_names,
-                    video_counts.get(int(row.id), 0),
+                    _source_video_count(row, video_counts),
+                    feedback_counts.get(int(row.id), 0),
                 )
                 for row in rows
             ],
@@ -142,52 +236,80 @@ def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
     """
     Resolve the judged video list and hit evidence from demand_video_expansion.
 
-    multi_demand_video_detail contributes only the title and no other field.
+    S/A 使用拓展命中视频;B/C/D 使用 demand_grade.video_list 中的源视频。
+    multi_demand_video_detail 补充 title 与解构 url1/url2。
     """
     with get_session() as session:
         grade = DemandGradeRepository(session).get_by_id(demand_grade_id)
         if grade is None:
             return None
 
-        expansions = DemandVideoExpansionRepository(session).list_by_demand_grade(
-            str(grade.biz_dt),
-            demand_grade_id,
-        )
         category_names = _category_name_map(session, [grade])
+        grade_code = str(grade.grade or "").upper()
+        demand_feedback_count, video_feedback_counts, point_feedback_counts = (
+            DemandFeedbackRepository(session).count_for_demand(demand_grade_id)
+        )
 
-        video_ids: list[str] = []
-        points_by_video: dict[str, list[dict[str, Any]]] = {}
-        for row in expansions:
-            video_id = str(row.video_id).strip()
-            if not video_id or row.point_type not in _POINT_TYPES:
-                continue
-            if video_id not in points_by_video:
-                video_ids.append(video_id)
-                points_by_video[video_id] = []
-            points_by_video[video_id].append(
-                {
-                    "point_type": row.point_type,
-                    "expanded_text": row.expanded_text,
-                    "point_desc": row.point_desc,
-                    "reason": row.reason,
-                }
+        if grade_code in _SOURCE_VIDEO_GRADES:
+            video_ids = _parse_video_ids(grade.video_list)
+            details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
+            videos = [
+                _serialize_video(
+                    video_id,
+                    details.get(video_id),
+                    [],
+                    video_feedback_counts.get(video_id, 0),
+                )
+                for video_id in video_ids
+            ]
+        else:
+            expansions = DemandVideoExpansionRepository(session).list_by_demand_grade(
+                str(grade.biz_dt),
+                demand_grade_id,
             )
 
-        details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
-        videos: list[dict[str, Any]] = []
-        for video_id in video_ids:
-            points = points_by_video[video_id]
-            detail = details.get(video_id)
-            videos.append(
-                {
-                    "vid": video_id,
-                    "title": detail.title if detail else None,
-                    "point_count": len(points),
-                    "points": points,
-                }
-            )
+            video_ids: list[str] = []
+            points_by_video: dict[str, list[dict[str, Any]]] = {}
+            for row in expansions:
+                video_id = str(row.video_id).strip()
+                if not video_id or row.point_type not in _POINT_TYPES:
+                    continue
+                if video_id not in points_by_video:
+                    video_ids.append(video_id)
+                    points_by_video[video_id] = []
+                points_by_video[video_id].append(
+                    {
+                        "id": int(row.id),
+                        "point_type": row.point_type,
+                        "expanded_text": row.expanded_text,
+                        "point_desc": row.point_desc,
+                        "reason": row.reason,
+                        "feedback_summary": {
+                            "count": point_feedback_counts.get(int(row.id), 0)
+                        },
+                    }
+                )
+
+            details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
+            videos = [
+                _serialize_video(
+                    video_id,
+                    details.get(video_id),
+                    points_by_video[video_id],
+                    video_feedback_counts.get(video_id, 0),
+                )
+                for video_id in video_ids
+            ]
 
-        demand = _serialize_demand(grade, category_names, len(videos))
+        demand = _serialize_demand(
+            grade,
+            category_names,
+            len(videos),
+            demand_feedback_count,
+        )
         demand["point_count"] = sum(video["point_count"] for video in videos)
         demand["videos"] = videos
+        demand["video_source"] = (
+            "pool" if grade_code in _SOURCE_VIDEO_GRADES else "expansion"
+        )
         return demand

+ 452 - 0
api/services/video_discovery_records.py

@@ -0,0 +1,452 @@
+"""Authenticated-user records for the find-agent video discovery workflow."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from sqlalchemy import func, or_, select
+from sqlalchemy.orm import load_only
+
+from supply_infra.db.models.video_discovery import (
+    VideoDiscoveryCandidate,
+    VideoDiscoveryRun,
+    VideoDiscoverySearch,
+)
+from supply_infra.db.session import get_session
+
+_MIN_VISIBLE_BIZ_DT = "20260730"
+
+
+def _json_value(raw: str | None) -> Any:
+    if not raw:
+        return None
+    try:
+        return json.loads(raw)
+    except (TypeError, ValueError):
+        return raw
+
+
+def _timestamp(value: Any) -> str | None:
+    return value.isoformat() if value is not None else None
+
+
+def _number(value: Any) -> float | None:
+    return float(value) if value is not None else None
+
+
+def _serialize_run(row: VideoDiscoveryRun, counts: dict[str, int]) -> dict[str, Any]:
+    return {
+        "id": int(row.id),
+        "run_id": row.run_id,
+        "biz_dt": row.biz_dt,
+        "demand_grade_id": (
+            int(row.demand_grade_id) if row.demand_grade_id is not None else None
+        ),
+        "demand_word": row.demand_word,
+        "seed_video_id": row.seed_video_id,
+        "seed_video_title": row.seed_video_title,
+        "relevant_points": _json_value(row.relevant_points_json),
+        "intent_summary": row.intent_summary,
+        "status": row.status,
+        "search_count": counts.get("search", int(row.search_count or 0)),
+        "candidate_count": counts.get("candidate", 0),
+        "primary_count": counts.get("primary", int(row.primary_count or 0)),
+        "rejected_count": counts.get("rejected", 0),
+        "pending_count": counts.get("pending_evaluation", 0),
+        "stop_reason": row.stop_reason,
+        "rule_version": row.rule_version,
+        "rule_config": _json_value(row.rule_config_json),
+        "create_time": _timestamp(row.create_time),
+        "update_time": _timestamp(row.update_time),
+    }
+
+
+def _serialize_search_candidate(row: VideoDiscoveryCandidate) -> dict[str, Any]:
+    return {
+        "id": int(row.id),
+        "aweme_id": row.aweme_id,
+        "title": row.title,
+        "content_link": row.content_link,
+        "author_name": row.author_name,
+        "decision_bucket": row.decision_bucket,
+        "publish_at": _timestamp(row.publish_at),
+        "duration_seconds": _number(row.duration_seconds),
+        "play_count": int(row.play_count) if row.play_count is not None else None,
+        "like_count": int(row.like_count) if row.like_count is not None else None,
+        "comment_count": (
+            int(row.comment_count) if row.comment_count is not None else None
+        ),
+        "collect_count": (
+            int(row.collect_count) if row.collect_count is not None else None
+        ),
+        "share_count": int(row.share_count) if row.share_count is not None else None,
+        "relevance_score": _number(row.relevance_score),
+        "elder_score": _number(row.elder_score),
+        "share_score": _number(row.share_score),
+        "value_score": _number(row.value_score),
+        "content_50_plus_ratio": _number(row.content_50_plus_ratio),
+        "content_portrait_status": row.content_portrait_status,
+        "account_50_plus_ratio": _number(row.account_50_plus_ratio),
+        "account_portrait_status": row.account_portrait_status,
+        "portrait_conflict": bool(row.portrait_conflict),
+        "temporal_status": row.temporal_status,
+        "gate_status": row.gate_status,
+        "reject_reason_code": row.reject_reason_code,
+    }
+
+
+def _serialize_search(
+    row: VideoDiscoverySearch,
+    candidates: list[VideoDiscoveryCandidate],
+) -> dict[str, Any]:
+    return {
+        "id": int(row.id),
+        "run_id": row.run_id,
+        "search_key": row.search_key,
+        "keyword": row.keyword,
+        "query_reason": row.query_reason,
+        "source_type": row.source_type,
+        "source_value": row.source_value,
+        "parent_search_id": (
+            int(row.parent_search_id) if row.parent_search_id is not None else None
+        ),
+        "provider": row.provider,
+        "provider_state": _json_value(row.provider_state_json),
+        "content_type": row.content_type,
+        "sort_type": row.sort_type,
+        "publish_time": row.publish_time,
+        "cursor": row.cursor,
+        "page_no": int(row.page_no),
+        "results_count": int(row.results_count or 0),
+        "new_candidate_count": int(row.new_candidate_count or 0),
+        "has_more": bool(row.has_more),
+        "next_cursor": row.next_cursor,
+        "result_ids": _json_value(row.result_ids_json),
+        "candidates": [_serialize_search_candidate(candidate) for candidate in candidates],
+        "status": row.status,
+        "error_message": row.error_message,
+        "create_time": _timestamp(row.create_time),
+        "update_time": _timestamp(row.update_time),
+    }
+
+
+def _serialize_candidate(row: VideoDiscoveryCandidate) -> dict[str, Any]:
+    return {
+        "id": int(row.id),
+        "run_id": row.run_id,
+        "search_id": int(row.search_id) if row.search_id is not None else None,
+        "aweme_id": row.aweme_id,
+        "title": row.title,
+        "content_link": row.content_link,
+        "author_name": row.author_name,
+        "author_sec_uid": row.author_sec_uid,
+        "source_keywords": _json_value(row.source_keywords_json),
+        "source_search_ids": _json_value(row.source_search_ids_json),
+        "tags": _json_value(row.tags_json),
+        "publish_at": _timestamp(row.publish_at),
+        "duration_seconds": _number(row.duration_seconds),
+        "play_count": int(row.play_count) if row.play_count is not None else None,
+        "like_count": int(row.like_count) if row.like_count is not None else None,
+        "comment_count": (
+            int(row.comment_count) if row.comment_count is not None else None
+        ),
+        "collect_count": (
+            int(row.collect_count) if row.collect_count is not None else None
+        ),
+        "share_count": int(row.share_count) if row.share_count is not None else None,
+        "content_age_evidence": _json_value(row.content_age_evidence_json),
+        "account_age_evidence": _json_value(row.account_age_evidence_json),
+        "age_normalization": _json_value(row.age_normalization_json),
+        "content_50_plus_ratio": _number(row.content_50_plus_ratio),
+        "content_50_plus_tgi": _number(row.content_50_plus_tgi),
+        "content_portrait_status": row.content_portrait_status,
+        "account_50_plus_ratio": _number(row.account_50_plus_ratio),
+        "account_50_plus_tgi": _number(row.account_50_plus_tgi),
+        "account_portrait_status": row.account_portrait_status,
+        "portrait_conflict": bool(row.portrait_conflict),
+        "temporal_type": row.temporal_type,
+        "temporal_status": row.temporal_status,
+        "temporal_evidence": _json_value(row.temporal_evidence_json),
+        "gate_status": row.gate_status,
+        "gate_results": _json_value(row.gate_results_json),
+        "reject_reason_code": row.reject_reason_code,
+        "rule_version": row.rule_version,
+        "relevance_score": _number(row.relevance_score),
+        "elder_score": _number(row.elder_score),
+        "share_score": _number(row.share_score),
+        "value_score": _number(row.value_score),
+        "decision_reason": row.decision_reason,
+        "decision_bucket": row.decision_bucket,
+        "aigc_crawler_plan_id": row.aigc_crawler_plan_id,
+        "aigc_produce_plan_id": row.aigc_produce_plan_id,
+        "aigc_publish_plan_id": row.aigc_publish_plan_id,
+        "aigc_plan_label": row.aigc_plan_label,
+        "create_time": _timestamp(row.create_time),
+        "update_time": _timestamp(row.update_time),
+    }
+
+
+def _run_counts(session: Any, run_ids: list[str]) -> dict[str, dict[str, int]]:
+    counts = {
+        run_id: {
+            "search": 0,
+            "candidate": 0,
+            "primary": 0,
+            "rejected": 0,
+            "pending_evaluation": 0,
+        }
+        for run_id in run_ids
+    }
+    if not run_ids:
+        return counts
+
+    search_stmt = (
+        select(VideoDiscoverySearch.run_id, func.count(VideoDiscoverySearch.id))
+        .where(VideoDiscoverySearch.run_id.in_(run_ids))
+        .group_by(VideoDiscoverySearch.run_id)
+    )
+    for run_id, count in session.execute(search_stmt):
+        counts[str(run_id)]["search"] = int(count)
+
+    candidate_stmt = (
+        select(
+            VideoDiscoveryCandidate.run_id,
+            VideoDiscoveryCandidate.decision_bucket,
+            func.count(VideoDiscoveryCandidate.id),
+        )
+        .where(VideoDiscoveryCandidate.run_id.in_(run_ids))
+        .group_by(
+            VideoDiscoveryCandidate.run_id,
+            VideoDiscoveryCandidate.decision_bucket,
+        )
+    )
+    for run_id, bucket, count in session.execute(candidate_stmt):
+        run_counts = counts[str(run_id)]
+        run_counts[str(bucket)] = int(count)
+        run_counts["candidate"] = run_counts.get("candidate", 0) + int(count)
+    return counts
+
+
+def list_video_discovery_runs(
+    *,
+    biz_dt: str | None = None,
+    status: str | None = None,
+    keyword: str | None = None,
+    limit: int = 20,
+    offset: int = 0,
+) -> dict[str, Any]:
+    """List find-agent runs with live search and candidate counts."""
+    with get_session() as session:
+        conditions = [VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT]
+        if biz_dt:
+            conditions.append(VideoDiscoveryRun.biz_dt == biz_dt)
+        if status:
+            conditions.append(VideoDiscoveryRun.status == status)
+        normalized_keyword = (keyword or "").strip().lower()
+        if normalized_keyword:
+            pattern = f"%{normalized_keyword}%"
+            conditions.append(
+                or_(
+                    func.lower(VideoDiscoveryRun.demand_word).like(pattern),
+                    func.lower(VideoDiscoveryRun.run_id).like(pattern),
+                    func.lower(func.coalesce(VideoDiscoveryRun.seed_video_title, "")).like(
+                        pattern
+                    ),
+                )
+            )
+
+        total_stmt = select(func.count(VideoDiscoveryRun.id))
+        rows_stmt = select(VideoDiscoveryRun)
+        if conditions:
+            total_stmt = total_stmt.where(*conditions)
+            rows_stmt = rows_stmt.where(*conditions)
+        rows_stmt = rows_stmt.order_by(
+            VideoDiscoveryRun.create_time.desc(),
+            VideoDiscoveryRun.id.desc(),
+        ).limit(limit).offset(offset)
+
+        total = int(session.scalar(total_stmt) or 0)
+        rows = list(session.scalars(rows_stmt).all())
+        counts = _run_counts(session, [row.run_id for row in rows])
+        return {
+            "items": [_serialize_run(row, counts.get(row.run_id, {})) for row in rows],
+            "total": total,
+            "limit": limit,
+            "offset": offset,
+        }
+
+
+def get_video_discovery_run(run_id: str) -> dict[str, Any] | None:
+    with get_session() as session:
+        row = session.scalar(
+            select(VideoDiscoveryRun).where(
+                VideoDiscoveryRun.run_id == run_id,
+                VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT,
+            )
+        )
+        if row is None:
+            return None
+        counts = _run_counts(session, [run_id])
+        return _serialize_run(row, counts.get(run_id, {}))
+
+
+def list_video_discovery_searches(
+    run_id: str,
+    *,
+    keyword: str | None = None,
+    limit: int = 20,
+    offset: int = 0,
+) -> dict[str, Any] | None:
+    with get_session() as session:
+        exists = session.scalar(
+            select(VideoDiscoveryRun.id).where(
+                VideoDiscoveryRun.run_id == run_id,
+                VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT,
+            )
+        )
+        if exists is None:
+            return None
+
+        conditions = [VideoDiscoverySearch.run_id == run_id]
+        normalized_keyword = (keyword or "").strip().lower()
+        if normalized_keyword:
+            pattern = f"%{normalized_keyword}%"
+            conditions.append(
+                or_(
+                    func.lower(VideoDiscoverySearch.keyword).like(pattern),
+                    func.lower(VideoDiscoverySearch.query_reason).like(pattern),
+                    func.lower(
+                        func.coalesce(VideoDiscoverySearch.source_value, "")
+                    ).like(pattern),
+                )
+            )
+        total = int(
+            session.scalar(
+                select(func.count(VideoDiscoverySearch.id)).where(*conditions)
+            )
+            or 0
+        )
+        rows = list(
+            session.scalars(
+                select(VideoDiscoverySearch)
+                .where(*conditions)
+                .order_by(VideoDiscoverySearch.id)
+                .limit(limit)
+                .offset(offset)
+            ).all()
+        )
+        candidates_by_search: dict[int, list[VideoDiscoveryCandidate]] = {
+            int(row.id): [] for row in rows
+        }
+        if candidates_by_search:
+            candidate_rows = session.scalars(
+                select(VideoDiscoveryCandidate)
+                .options(
+                    load_only(
+                        VideoDiscoveryCandidate.id,
+                        VideoDiscoveryCandidate.search_id,
+                        VideoDiscoveryCandidate.aweme_id,
+                        VideoDiscoveryCandidate.title,
+                        VideoDiscoveryCandidate.content_link,
+                        VideoDiscoveryCandidate.author_name,
+                        VideoDiscoveryCandidate.decision_bucket,
+                        VideoDiscoveryCandidate.publish_at,
+                        VideoDiscoveryCandidate.duration_seconds,
+                        VideoDiscoveryCandidate.play_count,
+                        VideoDiscoveryCandidate.like_count,
+                        VideoDiscoveryCandidate.comment_count,
+                        VideoDiscoveryCandidate.collect_count,
+                        VideoDiscoveryCandidate.share_count,
+                        VideoDiscoveryCandidate.relevance_score,
+                        VideoDiscoveryCandidate.elder_score,
+                        VideoDiscoveryCandidate.share_score,
+                        VideoDiscoveryCandidate.value_score,
+                        VideoDiscoveryCandidate.content_50_plus_ratio,
+                        VideoDiscoveryCandidate.content_portrait_status,
+                        VideoDiscoveryCandidate.account_50_plus_ratio,
+                        VideoDiscoveryCandidate.account_portrait_status,
+                        VideoDiscoveryCandidate.portrait_conflict,
+                        VideoDiscoveryCandidate.temporal_status,
+                        VideoDiscoveryCandidate.gate_status,
+                        VideoDiscoveryCandidate.reject_reason_code,
+                    )
+                )
+                .where(VideoDiscoveryCandidate.search_id.in_(candidates_by_search))
+                .order_by(
+                    VideoDiscoveryCandidate.search_id,
+                    VideoDiscoveryCandidate.id,
+                )
+            ).all()
+            for candidate in candidate_rows:
+                if candidate.search_id is not None:
+                    candidates_by_search[int(candidate.search_id)].append(candidate)
+        return {
+            "items": [
+                _serialize_search(row, candidates_by_search.get(int(row.id), []))
+                for row in rows
+            ],
+            "total": total,
+            "limit": limit,
+            "offset": offset,
+        }
+
+
+def list_video_discovery_candidates(
+    run_id: str,
+    *,
+    bucket: str | None = None,
+    keyword: str | None = None,
+    limit: int = 20,
+    offset: int = 0,
+) -> dict[str, Any] | None:
+    with get_session() as session:
+        exists = session.scalar(
+            select(VideoDiscoveryRun.id).where(
+                VideoDiscoveryRun.run_id == run_id,
+                VideoDiscoveryRun.biz_dt >= _MIN_VISIBLE_BIZ_DT,
+            )
+        )
+        if exists is None:
+            return None
+
+        conditions = [VideoDiscoveryCandidate.run_id == run_id]
+        if bucket:
+            conditions.append(VideoDiscoveryCandidate.decision_bucket == bucket)
+        normalized_keyword = (keyword or "").strip().lower()
+        if normalized_keyword:
+            pattern = f"%{normalized_keyword}%"
+            conditions.append(
+                or_(
+                    func.lower(VideoDiscoveryCandidate.aweme_id).like(pattern),
+                    func.lower(
+                        func.coalesce(VideoDiscoveryCandidate.title, "")
+                    ).like(pattern),
+                    func.lower(
+                        func.coalesce(VideoDiscoveryCandidate.author_name, "")
+                    ).like(pattern),
+                )
+            )
+        total = int(
+            session.scalar(
+                select(func.count(VideoDiscoveryCandidate.id)).where(*conditions)
+            )
+            or 0
+        )
+        rows = list(
+            session.scalars(
+                select(VideoDiscoveryCandidate)
+                .where(*conditions)
+                .order_by(
+                    VideoDiscoveryCandidate.value_score.desc(),
+                    VideoDiscoveryCandidate.id,
+                )
+                .limit(limit)
+                .offset(offset)
+            ).all()
+        )
+        return {
+            "items": [_serialize_candidate(row) for row in rows],
+            "total": total,
+            "limit": limit,
+            "offset": offset,
+        }

+ 4 - 1
deploy/README.md

@@ -13,8 +13,11 @@ Scheduler、多个 Pipeline Worker 和 Reconciler。
 5. Docker 停止窗口至少 300 秒。
 6. 容器和 Alembic 连接 MySQL 后会将会话时区固定为 `+08:00`(中国标准时间);定时日批有次日
    调度 deadline,手工/API/CLI 补数不设置 deadline。
-7. 单个 `find_agent` 默认最多运行 600 秒(10 分钟);运行中的找批次每 60 秒输出一次
+7. 单个 `find_agent` 默认最多运行 600 秒(10 分钟);运行中的找视频批次每 60 秒输出一次
    进度日志,超时记录会标记失败,批次继续处理下一条需求。
+8. 首次部署配置 `AUTH_BOOTSTRAP_ADMIN_USERNAME` 和至少 8 位的
+   `AUTH_BOOTSTRAP_ADMIN_PASSWORD` 以创建初始管理员;已有同名用户不会被覆盖。
+   HTTPS 生产环境设置 `AUTH_COOKIE_SECURE=true`。
 
 示例:
 

+ 311 - 0
prd/08-需求汇总反馈机制设计.md

@@ -0,0 +1,311 @@
+# 需求汇总反馈机制设计
+
+## 1. 目标与范围
+
+在现有“需求汇总”页面的三层记录上增加独立反馈入口:
+
+1. 需求:`demand_grade` 的当日判断记录。
+2. 视频:当前需求下的一条拓展命中视频或需求池源视频。
+3. 命中内容:`demand_video_expansion` 中的一条目的点、关键点或灵感点。
+
+本期反馈是新增证据,不直接修改需求等级、视频命中关系或命中内容。
+
+## 2. 核心设计结论
+
+### 2.1 使用一张统一反馈表
+
+三类反馈共享提交人、审计时间和文字说明。统一表便于:
+
+- 汇总一个需求下的全部反馈;
+- 建立统一待处理队列;
+- 后续将人工反馈作为下一业务日的输入证据;
+- 保持追加式历史,避免修改原反馈导致审计信息丢失。
+
+### 2.2 反馈绑定当日记录
+
+需求反馈绑定 `demand_grade.id`,而不是只绑定 `demand_name`。同一需求在不同业务日的等级、原因、关联视频都可能变化,必须知道用户针对哪个版本反馈。
+
+同时保存 `biz_dt` 和目标快照,方便跨日查询及源数据变化后的历史还原。
+
+### 2.3 三类目标的稳定身份
+
+| 反馈目标 | 稳定定位字段 | 说明 |
+|---|---|---|
+| 需求 | `demand_grade_id` | 对当日需求判断反馈 |
+| 视频 | `demand_grade_id + video_id` | 同一视频对不同需求的相关性不同 |
+| 命中内容 | `demand_video_expansion_id` | 精确定位单条目的点、关键点或灵感点 |
+
+当前命中内容接口没有返回 `demand_video_expansion.id`,实现前需要为 `VideoDiscoveryPoint` 增加 `id`。
+
+## 3. 反馈记录表
+
+建议表名:`demand_feedback`
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | BIGINT | 是 | 自增主键 |
+| `client_request_id` | VARCHAR(64) | 是 | 客户端提交幂等键,唯一 |
+| `target_type` | VARCHAR(32) | 是 | `demand` / `video` / `hit_content` |
+| `biz_dt` | VARCHAR(32) | 是 | 目标所属业务日 |
+| `demand_grade_id` | BIGINT | 是 | 三类反馈都必须归属一个需求 |
+| `video_id` | VARCHAR(64) | 否 | 视频、命中内容反馈必填 |
+| `demand_video_expansion_id` | BIGINT | 否 | 命中内容反馈必填 |
+| `feedback_action` | VARCHAR(32) | 是 | `support` / `oppose` / `correct` / `supplement` |
+| `reason_code` | VARCHAR(64) | 否 | 按目标类型选择的问题原因 |
+| `content` | TEXT | 否 | 用户补充说明;纠错、补充时必填 |
+| `target_snapshot_json` | TEXT | 是 | 服务端生成的目标展示快照 |
+| `feedback_user_id` | INT | 是 | 反馈人 `auth_user.id` |
+| `feedback_user_name_snapshot` | VARCHAR(128) | 是 | 反馈人名称快照 |
+| `feedback_username_snapshot` | VARCHAR(64) | 是 | 反馈人登录账号 `username` 快照 |
+| `created_at` | DATETIME | 是 | 创建时间 |
+| `updated_at` | DATETIME | 是 | 更新时间 |
+
+推荐索引:
+
+```sql
+UNIQUE KEY uk_demand_feedback_request (client_request_id),
+KEY idx_demand_feedback_demand (demand_grade_id, created_at),
+KEY idx_demand_feedback_video (demand_grade_id, video_id, created_at),
+KEY idx_demand_feedback_expansion (demand_video_expansion_id, created_at),
+KEY idx_demand_feedback_user (feedback_user_id, created_at)
+```
+
+目标字段校验由服务层执行:
+
+- `demand`:`video_id`、`demand_video_expansion_id` 必须为空;
+- `video`:`video_id` 必填,`demand_video_expansion_id` 为空;
+- `hit_content`:三个定位字段都必填;
+- 命中内容必须确实属于传入的需求和视频;
+- `biz_dt`、展示文本和其他快照全部由服务端根据目标生成,不信任客户端传值。
+
+不建议对三个业务目标设置级联删除。反馈是审计证据,即使目标以后失效,也要依靠快照保留当时上下文。
+
+### 3.1 目标快照示例
+
+```json
+{
+  "demand_name": "老年人智能手机使用",
+  "grade": "A",
+  "video_title": "教父母使用手机的五个技巧",
+  "video_source": "expansion",
+  "point_type": "purpose",
+  "expanded_text": "降低老年人使用智能设备的门槛",
+  "point_desc": "面向子女和老人解释基础操作",
+  "reason": "该内容直接覆盖当前需求"
+}
+```
+
+快照仅保存目标提交时已有的业务字段,不保存密码、会话、IP 等认证信息。反馈人由服务端从当前登录会话取得,客户端不能指定或覆盖。
+
+不增加 `has_feedback`、`is_feedback` 等“是否反馈”字段。某条业务记录是否存在反馈、反馈数量是多少,都直接由 `demand_feedback` 记录实时查询或聚合得出,避免业务记录和反馈表状态不一致。
+
+## 4. 反馈选项
+
+第一层统一动作保持简单:
+
+| 动作 | 页面文案 | 使用场景 |
+|---|---|---|
+| `support` | 认可 | 判断、关联或内容准确 |
+| `oppose` | 不认可 | 判断、关联或内容不成立 |
+| `correct` | 纠错 | 数据、类型、文案或原因有明确错误 |
+| `supplement` | 补充 | 增加需求、视频、依据或说明 |
+
+第二层原因随目标变化:
+
+| 目标 | 建议原因 |
+|---|---|
+| 需求 | 需求不成立、等级不合适、分类不准确、需求表述不清、判断原因不准确、缺少需求 |
+| 视频 | 与需求不相关、相关性弱、视频失效、视频重复、标题或信息错误、缺少相关视频 |
+| 命中内容 | 内容未命中、点位类型错误、表述不准确、命中原因不准确、内容重复、缺少命中内容 |
+
+交互校验:
+
+- “认可”可直接提交,也可补充说明;
+- “不认可”必须选择原因;
+- “纠错”和“补充”必须填写说明;
+- 说明限制 1,000 字,前后端同时校验。
+
+## 5. 前端页面设计
+
+### 5.1 入口位置
+
+沿用现有三栏工作台,不新开反馈页面:
+
+```text
+┌──────────────────┬────────────────────────┬────────────────────────┐
+│ 01 选择需求       │ 02 需求拓展视频          │ 03 命中视频内容          │
+│                  │                        │                        │
+│ 需求卡片           │ 当前需求说明   [反馈]    │ 当前视频                │
+│ 名称 / 等级 / 视频数│                        │                        │
+│ [反馈 2]           │ 视频卡片 1       [反馈]  │ 目的点卡片 1      [反馈] │
+│                  │ 视频卡片 2       [反馈]  │ 关键点卡片 2      [反馈] │
+└──────────────────┴────────────────────────┴────────────────────────┘
+```
+
+- 需求反馈:放在第二栏“当前寻找需求”标题区域,确保反馈对象是当前选中的完整需求记录。
+- 视频反馈:每条视频卡片右侧增加“反馈”,与选择视频动作分开。
+- 命中内容反馈:每条点位卡片右侧增加“反馈”,保留现有复制按钮。
+- 如需提示反馈情况,只显示由反馈记录聚合得到的 `反馈 n`,不显示或保存“已反馈”状态;徽标不使用颜色表达业务好坏。
+
+当前需求卡和视频卡整块都是 `<button>`。实现视频行内反馈前,要重构为容器加两个并列按钮,避免交互元素嵌套。
+
+### 5.2 统一反馈抽屉
+
+点击任意入口后,从右侧打开统一抽屉:
+
+```text
+反馈 · 命中内容                                      ×
+────────────────────────────────────────────────────
+需求:老年人智能手机使用
+视频:教父母使用手机的五个技巧
+目的点:降低老年人使用智能设备的门槛
+
+你的判断
+[认可] [不认可] [纠错] [补充]
+
+问题原因
+[内容未命中] [点位类型错误] [原因不准确] [...]
+
+补充说明
+┌──────────────────────────────────────────────────┐
+│ 请说明判断依据或建议的正确内容                    │
+└──────────────────────────────────────────────────┘
+0 / 1000
+
+                                [取消] [提交反馈]
+```
+
+交互要求:
+
+- 打开抽屉时固定保存目标上下文,切换左侧需求不能改变正在填写的反馈对象;
+- 提交期间按钮显示加载态并禁止重复点击;
+- 成功后关闭抽屉,重新获取对应记录的反馈数量并显示轻量成功提示;
+- 失败时保留用户已填写内容;
+- 使用 `client_request_id` 防止网络重试产生重复记录;
+- Esc 可关闭,关闭未提交内容时二次确认;
+- 移动端使用底部全屏面板。
+
+### 5.3 反馈历史
+
+MVP 在记录旁只展示该目标的反馈总数,不记录或展示“当前用户是否反馈过”。
+
+点击数量徽标可查看该目标的反馈历史。每条历史记录展示反馈人、反馈动作、原因、说明和时间。
+
+## 6. API 设计
+
+### 6.1 扩展现有查询响应
+
+避免页面对每条记录发起一次反馈查询。在现有两个接口中批量返回反馈摘要:
+
+- `GET /api/video-discovery/demands`
+- `GET /api/video-discovery/demands/{demand_grade_id}`
+
+需求、视频和命中内容增加:
+
+```json
+{
+  "feedback_summary": {
+    "count": 2
+  }
+}
+```
+
+命中内容同时增加:
+
+```json
+{
+  "id": 456,
+  "point_type": "purpose",
+  "expanded_text": "..."
+}
+```
+
+### 6.2 提交反馈
+
+`POST /api/video-discovery/feedback`
+
+```json
+{
+  "client_request_id": "0198f85d-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
+  "target_type": "hit_content",
+  "demand_grade_id": 123,
+  "video_id": "7360000000000000000",
+  "demand_video_expansion_id": 456,
+  "feedback_action": "oppose",
+  "reason_code": "not_hit",
+  "content": "该内容只提到设备设置,没有覆盖老人学习使用的需求。"
+}
+```
+
+成功返回 `201`:
+
+```json
+{
+  "item": {
+    "id": 789,
+    "created_at": "2026-07-30T15:20:00"
+  },
+  "feedback_summary": {
+    "count": 3
+  }
+}
+```
+
+服务端响应:
+
+- `400`:字段组合或反馈内容不合法;
+- `404`:目标不存在;
+- `409`:目标上下文不一致;
+- 使用相同 `client_request_id` 重试时返回第一次创建的结果,不重复写入。
+
+### 6.3 查看历史
+
+`GET /api/video-discovery/feedback`
+
+查询参数使用与提交相同的目标定位字段,并支持 `cursor`、`limit`。普通用户返回自己的记录,管理员返回全部记录。
+
+## 7. 权限
+
+| 能力 | 普通用户 | 管理员 |
+|---|---:|---:|
+| 提交反馈 | 是 | 是 |
+| 查看反馈记录和反馈人 | 是 | 是 |
+| 直接修改业务结果 | 否 | 否,需走独立受控流程 |
+
+需要在 `AuthenticationMiddleware` 中放行普通用户的反馈 `GET` 和 `POST` 路由。
+
+## 8. 实现拆分
+
+### 第一阶段:可提交、可追溯
+
+1. 新增 Alembic migration、SQLAlchemy model 和 repository。
+2. 命中内容查询返回 `demand_video_expansion.id`。
+3. 新增提交与历史查询 API。
+4. 现有需求和详情接口批量附加反馈摘要。
+5. 新增统一反馈抽屉,并在需求、视频、命中内容三层接入。
+6. 增加字段组合校验、幂等提交和权限测试。
+
+### 第二阶段:反馈闭环
+
+1. 将反馈转换为下一业务日可消费的证据事件。
+2. 展示反馈对后续需求判断的实际影响。
+
+## 9. 验收标准
+
+- 页面上的每条需求、每条视频和每条命中内容都有明确反馈入口;
+- 提交后数据库能唯一还原提交人、业务日、需求、视频及命中内容;
+- 同一视频在不同需求下的反馈不会串联;
+- 源数据更新后,历史反馈仍能通过目标快照解释;
+- 普通用户不能看到其他用户的反馈正文和身份;
+- 重复点击或请求重试不会生成重复记录;
+- 反馈不会直接覆盖 `demand_grade`、`demand_video_expansion` 或视频详情;
+- 列表和详情加载反馈摘要时不产生逐条 N+1 查询。
+
+## 10. 实施前需确认的产品决策
+
+建议按以下默认值实施:
+
+1. 所有已登录用户都可以提交反馈;
+2. 反馈追加保存,不允许直接编辑;需要修改时再次提交,历史仍保留;
+3. 第一阶段展示反馈记录和反馈人,不维护“是否反馈”状态;
+4. 反馈是证据,不自动改变当天需求等级或命中结果。

+ 103 - 0
prd/09-找视频记录展示设计.md

@@ -0,0 +1,103 @@
+# 找视频记录展示设计
+
+## 1. 目标与范围
+
+为 `find_agent` 增加只读的运行记录工作台,主要展示以下三张表:
+
+1. `video_discovery_run`:一次找视频任务的输入、意图、状态和汇总计数。
+2. `video_discovery_search`:任务内每个关键词、每一页的搜索轨迹。
+3. `video_discovery_candidate`:搜索结果、画像证据、评分、决策分池及 AIGC 分发状态。
+
+本期只提供查询和审计,不修改运行状态、候选分池或 AIGC 计划。
+
+## 2. 信息架构
+
+三张表采用“运行 → 搜索轨迹 / 候选视频”的主从结构,不做三张彼此割裂的宽表。
+
+```text
+找视频记录
+├── 运行列表(video_discovery_run)
+│   ├── 业务日、状态、需求词 / Run ID 筛选
+│   └── 运行状态、搜索数、候选数
+└── 运行详情
+    ├── 输入、意图、停止原因和分池汇总
+    ├── 搜索轨迹(video_discovery_search)
+    └── 候选视频(video_discovery_candidate)
+```
+
+登录用户在左侧选择一次运行,右侧按需加载对应的搜索或候选记录。该结构同时保留任务上下文和逐条证据,适合日常巡检与问题排查。
+
+## 3. 页面设计
+
+### 3.1 运行列表
+
+- 筛选:业务日、运行状态、需求词、种子标题、Run ID。
+- 数据范围:固定只展示 `biz_dt >= 20260730` 的运行及其搜索、候选记录。
+- 排序:创建时间倒序,同时间按主键倒序。
+- 摘要:需求词、状态、业务日、搜索页数、候选数。
+- 分页:后端分页,每页 20 条。
+
+### 3.2 运行概览
+
+- 基本身份:`run_id`、`biz_dt`、`demand_grade_id`、种子视频。
+- Agent 解释:`intent_summary`。
+- 运行结果:搜索页、全部候选、主推荐、淘汰、待评估。
+- 默认展开上下文:`relevant_points_json` 和 `stop_reason`,用户仍可手动收起。
+- `relevant_points_json` 不直接输出 JSON,而是展示需求摘要,并按参考视频分组展示灵感点、
+  目的点、关键点及其说明;同时兼容包含 `reference_videos` 的对象格式和历史点位数组格式。
+
+计数由关联表实时聚合,避免只依赖运行表中的缓存计数而掩盖数据不一致。
+
+### 3.3 搜索轨迹
+
+默认展示:
+
+- 搜索记录 ID 和成功/失败状态;
+- 关键词、形成原因;
+- 来源类型、来源值、父搜索;
+- 供应方、页码、游标;
+- 结果数、新增候选数、是否有下一页;
+- 创建时间。
+
+展开后优先以卡片展示该搜索记录直接关联的候选视频,包括标题、作者、分池、播放量、
+点赞、评论、收藏、转发和价值评分;同时展示搜索键、完整检索参数、供应方分页状态和
+错误信息。标题、作者和互动数据被截断时,鼠标悬浮展示完整内容。
+
+### 3.4 候选视频
+
+默认展示:
+
+- 候选 ID、决策分池;
+- 标题、作者、视频 ID;
+- 播放、点赞、评论、收藏、分享;
+- `R / E / S / V` 评分;
+- AIGC 计划标签和爬取计划;
+- 创建时间。
+
+展开后只展示决策原因、标签和原视频入口,不展示年龄证据、归一化结果、来源搜索或
+AIGC 计划明细。
+
+候选支持按分池以及视频 ID、标题、作者筛选。
+候选列表中的标题、作者和互动数据同样提供完整悬浮提示。
+
+页面正文、表格、筛选控件和状态标签使用不小于 11px 的字号,主要操作与正文使用
+12px~14px,避免审计信息过小影响阅读。
+
+## 4. 接口设计
+
+| 接口 | 用途 |
+|---|---|
+| `GET /api/video-discovery/runs` | 分页查询运行记录与实时聚合计数 |
+| `GET /api/video-discovery/runs/{run_id}` | 查询单次运行概览 |
+| `GET /api/video-discovery/runs/{run_id}/searches` | 分页查询搜索轨迹 |
+| `GET /api/video-discovery/runs/{run_id}/candidates` | 分页查询候选视频 |
+
+所有接口均为登录用户可访问的只读接口。列表最大单页 100 条,页面默认 20 条;不存在的 `run_id` 返回 404。
+
+## 5. 异常与兼容
+
+- 空表、空筛选结果、加载失败分别展示明确状态。
+- 历史候选的 `search_id` 允许为空,页面标记为“历史记录未关联”。
+- JSON 字段解析成功后以结构化数据返回;历史脏数据无法解析时保留原始文本,保证审计信息不丢失。
+- 未知的状态、来源类型和决策分池直接展示原值,避免新增枚举后页面空白。
+- 宽表只在记录区域内横向滚动,不造成整个页面横向溢出。

+ 2 - 0
prd/README.md

@@ -43,6 +43,8 @@ SupplyAgent 每天自动接收变化的需求信号、外部信号和后验反
 | [05-每日运行与历史追踪.md](05-每日运行与历史追踪.md) | 每日运行批次、异常、历史和策略变更 |
 | [06-下游应用与反馈闭环.md](06-下游应用与反馈闭环.md) | 寻找 Agent、人、内容映射和后验归因 |
 | [07-核心业务图.md](07-核心业务图.md) | 全局 Harness、需求图、闭环和定义变更图 |
+| [08-需求汇总反馈机制设计.md](08-需求汇总反馈机制设计.md) | 需求、视频和命中内容的人工反馈机制 |
+| [09-找视频记录展示设计.md](09-找视频记录展示设计.md) | Find Agent 运行、搜索与候选记录的审计展示 |
 
 ## 5. 业务成功标准
 

+ 76 - 0
scripts/backfill_multi_demand_video_urls.py

@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_video_detail 已有行的 url1/url2(从 ODPS 拉取)。
+
+Usage:
+  python scripts/backfill_multi_demand_video_urls.py
+  python scripts/backfill_multi_demand_video_urls.py --decode-dt 20260729
+  python scripts/backfill_multi_demand_video_urls.py --limit 500 --offset 0
+  python scripts/backfill_multi_demand_video_urls.py --batch-size 100
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[1]
+if str(_ROOT) not in sys.path:
+    sys.path.insert(0, str(_ROOT))
+
+from supply_infra.scheduler.jobs.demand_pool.videos import (  # noqa: E402
+    VIDEO_SYNC_BATCH_SIZE,
+    backfill_multi_demand_video_urls,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+)
+
+
+def _build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="回填 multi_demand_video_detail.url1/url2"
+    )
+    parser.add_argument(
+        "--decode-dt",
+        help="ODPS dwd_topic_decode_result_di 分区日期 YYYYMMDD,默认昨天",
+    )
+    parser.add_argument(
+        "--limit",
+        type=int,
+        default=None,
+        help="本轮最多处理的 vid 数,默认全部",
+    )
+    parser.add_argument(
+        "--offset",
+        type=int,
+        default=0,
+        help="pending 列表起始偏移,用于分批手工执行",
+    )
+    parser.add_argument(
+        "--batch-size",
+        type=int,
+        default=VIDEO_SYNC_BATCH_SIZE,
+        help=f"每批查询 ODPS 的 vid 数,默认 {VIDEO_SYNC_BATCH_SIZE}",
+    )
+    return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = _build_parser().parse_args(argv)
+    result = backfill_multi_demand_video_urls(
+        limit=args.limit,
+        offset=args.offset,
+        batch_size=args.batch_size,
+        decode_dt=args.decode_dt,
+    )
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 10 - 0
sql/multi_demand_video_detail_add_url1_url2.sql

@@ -0,0 +1,10 @@
+-- multi_demand_video_detail 增加 url1、url2(与 vid 同级,来自 ODPS dwd_topic_decode_result_di 表字段)
+-- MySQL 5.7+ / 8.0+
+
+ALTER TABLE `multi_demand_video_detail`
+  ADD COLUMN `url1` VARCHAR(1024) NULL
+    COMMENT '解构URL1(dwd_topic_decode_result_di 表字段,与 vid 同级)'
+    AFTER `vid`,
+  ADD COLUMN `url2` VARCHAR(1024) NULL
+    COMMENT '解构URL2(dwd_topic_decode_result_di 表字段,与 vid 同级)'
+    AFTER `url1`;

+ 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;

+ 27 - 20
sql/video_discovery_tables.sql

@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS `video_discovery_run` (
   `search_count` INT NOT NULL DEFAULT 0 COMMENT '已保存搜索页数',
   `primary_count` INT NOT NULL DEFAULT 0 COMMENT '主推荐数',
   `stop_reason` TEXT NULL COMMENT '停止搜索的证据或失败原因',
+  `rule_version` VARCHAR(64) NULL COMMENT '本次候选门禁规则版本',
+  `rule_config_json` TEXT NULL COMMENT '运行时规则、当前时间和阈值快照 JSON',
   `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
@@ -30,12 +32,12 @@ CREATE TABLE IF NOT EXISTS `video_discovery_run` (
   KEY `idx_video_discovery_run_biz_dt` (`biz_dt`),
   KEY `idx_video_discovery_run_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
-  COMMENT='一次需求找运行';
+  COMMENT='一次需求找视频运行';
 
 CREATE TABLE IF NOT EXISTS `video_discovery_search` (
   `id` BIGINT NOT NULL AUTO_INCREMENT,
   `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 自主确定的搜索词',
   `query_reason` TEXT NOT NULL COMMENT '为何形成该搜索词、希望验证什么',
   `source_type` VARCHAR(32) NOT NULL
@@ -62,7 +64,6 @@ CREATE TABLE IF NOT EXISTS `video_discovery_search` (
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   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_parent` (`run_id`, `parent_search_id`),
   KEY `idx_video_discovery_search_keyword` (`keyword`)
@@ -72,6 +73,7 @@ CREATE TABLE IF NOT EXISTS `video_discovery_search` (
 CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `id` BIGINT NOT NULL AUTO_INCREMENT,
   `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',
   `title` VARCHAR(512) NULL COMMENT '视频标题',
   `content_link` VARCHAR(1024) NULL COMMENT '抖音页面链接',
@@ -80,32 +82,34 @@ CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `source_keywords_json` TEXT NULL COMMENT '命中过该视频的搜索词 JSON',
   `source_search_ids_json` TEXT NULL COMMENT '来源搜索轨迹 id JSON',
   `tags_json` TEXT NULL COMMENT '视频标签/话题 JSON',
-  `hit_points_json` TEXT NULL COMMENT '命中的需求相关点 JSON',
+  `publish_at` DATETIME NULL COMMENT '视频真实发布时间,按运行时区解释',
+  `duration_seconds` DECIMAL(10,3) NULL COMMENT '视频时长(秒)',
   `play_count` BIGINT NULL COMMENT '播放数快照',
   `like_count` BIGINT NULL COMMENT '点赞数快照',
   `comment_count` BIGINT NULL COMMENT '评论数快照',
   `collect_count` BIGINT NULL COMMENT '收藏数快照',
   `share_count` BIGINT NULL COMMENT '分享数快照',
-  `publish_timestamp` BIGINT NULL COMMENT '发布时间戳',
   `content_age_evidence_json` TEXT NULL COMMENT '视频点赞用户年龄证据 JSON',
   `account_age_evidence_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',
+  `content_50_plus_ratio` DECIMAL(8,6) NULL COMMENT '视频点赞用户中 50+ 占比',
+  `content_50_plus_tgi` DECIMAL(10,4) NULL COMMENT '视频点赞用户 50+ TGI',
+  `content_portrait_status` VARCHAR(16) NULL COMMENT '视频画像结论 pass / fail / missing',
+  `account_50_plus_ratio` DECIMAL(8,6) NULL COMMENT '账号粉丝中 50+ 占比',
+  `account_50_plus_tgi` DECIMAL(10,4) NULL COMMENT '账号粉丝 50+ TGI',
+  `account_portrait_status` VARCHAR(16) NULL COMMENT '账号画像结论 pass / fail / missing',
+  `portrait_conflict` TINYINT NOT NULL DEFAULT 0 COMMENT '视频与账号画像结论是否冲突',
+  `temporal_type` VARCHAR(24) NULL COMMENT '时间内容类型',
+  `temporal_status` VARCHAR(16) NULL COMMENT '时间有效性 pass / fail / unknown',
+  `temporal_evidence_json` TEXT NULL COMMENT '时间语义、有效窗口和判断理由 JSON',
+  `gate_status` VARCHAR(16) NULL COMMENT '硬门槛 pass / fail / pending',
+  `gate_results_json` TEXT NULL COMMENT '逐项硬门槛结果 JSON',
+  `reject_reason_code` VARCHAR(64) NULL COMMENT '主要淘汰原因码',
+  `rule_version` VARCHAR(64) NULL COMMENT '候选评估使用的规则版本',
   `relevance_score` DECIMAL(8,6) NULL COMMENT 'R,范围 0~1',
   `elder_score` DECIMAL(8,6) NULL COMMENT 'E,范围 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_bucket` VARCHAR(24) NOT NULL DEFAULT 'pending_evaluation'
     COMMENT '最终为 primary / rejected;pending_evaluation 仅为过程状态',
@@ -113,8 +117,11 @@ CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
   `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
     ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   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_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
   COMMENT='本次运行发现的视频及评估快照';

+ 8 - 1
supply_agent/__init__.py

@@ -4,6 +4,13 @@ from supply_agent.agent.core import Agent
 from supply_agent.config import Settings
 from supply_agent.skills.registry import SkillRegistry
 from supply_agent.tools.registry import ToolRegistry
+from supply_agent.types import CompletionGuard
 
 __version__ = "0.1.0"
-__all__ = ["Agent", "Settings", "ToolRegistry", "SkillRegistry"]
+__all__ = [
+    "Agent",
+    "CompletionGuard",
+    "Settings",
+    "ToolRegistry",
+    "SkillRegistry",
+]

+ 2 - 1
supply_agent/agent/__init__.py

@@ -1,5 +1,6 @@
 """Agent module."""
 
 from supply_agent.agent.core import Agent
+from supply_agent.types import CompletionGuard
 
-__all__ = ["Agent"]
+__all__ = ["Agent", "CompletionGuard"]

+ 11 - 1
supply_agent/agent/core.py

@@ -9,7 +9,14 @@ from supply_agent.logging.logger import AgentLogger
 from supply_agent.logging.publish import publish_run_artifacts
 from supply_agent.skills.registry import SkillRegistry
 from supply_agent.tools.registry import ToolRegistry
-from supply_agent.types import AgentEvent, AgentEventType, AgentResult, Message, Role
+from supply_agent.types import (
+    AgentEvent,
+    AgentEventType,
+    AgentResult,
+    CompletionGuard,
+    Message,
+    Role,
+)
 
 
 DEFAULT_SYSTEM_PROMPT = """\
@@ -60,6 +67,7 @@ class Agent:
         temperature: float | None = None,
         reasoning_effort: str | None = None,
         logger: AgentLogger | None = None,
+        completion_guard: CompletionGuard | None = None,
     ) -> None:
         self.name = name
         self.settings = settings or get_settings()
@@ -77,6 +85,7 @@ class Agent:
         self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
         self.max_iterations = max_iterations or self.settings.agent_max_iterations
         self.temperature = temperature
+        self.completion_guard = completion_guard
 
         if model:
             self.llm.set_model(model)
@@ -133,6 +142,7 @@ class Agent:
             temperature=self.temperature,
             logger=self.logger,
             active_skills=self._active_skills,
+            completion_guard=self.completion_guard,
         )
 
     def _finish_run(self, result: AgentResult) -> None:

+ 34 - 1
supply_agent/agent/loop.py

@@ -10,6 +10,7 @@ from supply_agent.types import (
     AgentEvent,
     AgentEventType,
     AgentResult,
+    CompletionGuard,
     Message,
     Role,
     ToolCall,
@@ -21,7 +22,12 @@ if TYPE_CHECKING:
     from supply_agent.logging.logger import AgentLogger
 
 # Outcome of one assistant turn before tools / completion.
-_TurnKind = Literal["done", "tools"]
+_TurnKind = Literal["done", "tools", "continue"]
+
+_DEFAULT_COMPLETION_GUARD_FEEDBACK = (
+    "当前结果尚未满足结束条件。请根据已有上下文继续任务;"
+    "满足结束条件后再输出最终回答。"
+)
 
 
 class AgentLoop:
@@ -44,6 +50,7 @@ class AgentLoop:
         logger: AgentLogger | None = None,
         active_skills: list[str] | None = None,
         system_message_builder: Callable[[], Message] | None = None,
+        completion_guard: CompletionGuard | None = None,
     ) -> None:
         self.llm = llm
         self.tools = tools
@@ -53,6 +60,7 @@ class AgentLoop:
         self.max_iterations = max_iterations
         self.temperature = temperature
         self.logger = logger
+        self.completion_guard = completion_guard
         # Use `is not None` so an empty shared list from Agent is kept by identity.
         self.active_skills = active_skills if active_skills is not None else []
         self.tool_calls_made = 0
@@ -143,8 +151,25 @@ class AgentLoop:
         self.messages.append(response)
         if response.tool_calls:
             return "tools", None
+        feedback = self._completion_guard_feedback(response)
+        if feedback is not None:
+            self._append_completion_guard_feedback(feedback)
+            return "continue", None
         return "done", self._build_result(response.content or "", iterations)
 
+    def _completion_guard_feedback(self, response: Message) -> str | None:
+        if self.completion_guard is None:
+            return None
+        feedback = self.completion_guard(response, tuple(self.messages))
+        if feedback is None:
+            return None
+        if not isinstance(feedback, str):
+            raise TypeError("completion_guard 必须返回 str 或 None")
+        return feedback.strip() or _DEFAULT_COMPLETION_GUARD_FEEDBACK
+
+    def _append_completion_guard_feedback(self, feedback: str) -> None:
+        self.messages.append(Message(role=Role.USER, content=feedback))
+
     def _nudge_best_answer_sync(self, iterations: int) -> AgentResult:
         self.messages.append(
             Message(
@@ -197,6 +222,8 @@ class AgentLoop:
             kind, done = self._handle_assistant_message(response, iterations)
             if kind == "done" and done is not None:
                 return done
+            if kind == "continue":
+                continue
 
             assert response.tool_calls is not None
             for tc in response.tool_calls:
@@ -224,6 +251,8 @@ class AgentLoop:
             kind, done = self._handle_assistant_message(response, iterations)
             if kind == "done" and done is not None:
                 return done
+            if kind == "continue":
+                continue
 
             assert response.tool_calls is not None
             for tc in response.tool_calls:
@@ -264,6 +293,8 @@ class AgentLoop:
                     data=done.model_dump(),
                 )
                 return
+            if kind == "continue":
+                continue
 
             assert response.tool_calls is not None
             for tc in response.tool_calls:
@@ -328,6 +359,8 @@ class AgentLoop:
                     data=done.model_dump(),
                 )
                 return
+            if kind == "continue":
+                continue
 
             assert response.tool_calls is not None
             for tc in response.tool_calls:

+ 15 - 1
supply_agent/types.py

@@ -1,7 +1,8 @@
 from __future__ import annotations
 
+from collections.abc import Callable, Sequence
 from enum import StrEnum
-from typing import Any, Literal
+from typing import Any, TypeAlias
 
 from pydantic import BaseModel, Field
 
@@ -38,6 +39,19 @@ class Message(BaseModel):
         return data
 
 
+CompletionGuard: TypeAlias = Callable[
+    [Message, Sequence[Message]],
+    str | None,
+]
+"""Completion callback.
+
+Return ``None`` to accept an assistant response without tool calls as the final
+answer. Return feedback text to reject completion and continue the agent loop;
+the feedback is appended as a user message before the next iteration. The guard
+does not run for the forced final answer after ``max_iterations`` is reached.
+"""
+
+
 class ToolCall(BaseModel):
     """A tool invocation requested by the model."""
 

+ 63 - 1
supply_infra/config.py

@@ -4,7 +4,7 @@ from functools import lru_cache
 from pathlib import Path
 from urllib.parse import quote_plus
 
-from pydantic import Field, model_validator
+from pydantic import Field, SecretStr, model_validator
 from pydantic_settings import BaseSettings, SettingsConfigDict
 
 # supply_infra/config.py -> project root; avoid depending on process cwd
@@ -73,6 +73,22 @@ class InfraSettings(BaseSettings):
     )
     mysql_echo: bool = Field(default=False, alias="MYSQL_ECHO")
 
+    # Local web authentication
+    auth_session_hours: int = Field(default=12, ge=1, le=168, alias="AUTH_SESSION_HOURS")
+    auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
+    auth_bootstrap_admin_username: str = Field(
+        default="",
+        alias="AUTH_BOOTSTRAP_ADMIN_USERNAME",
+    )
+    auth_bootstrap_admin_password: SecretStr = Field(
+        default=SecretStr(""),
+        alias="AUTH_BOOTSTRAP_ADMIN_PASSWORD",
+    )
+    auth_bootstrap_admin_display_name: str = Field(
+        default="系统管理员",
+        alias="AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME",
+    )
+
     # ODPS (MaxCompute)
     odps_access_id: str = Field(default="", alias="ODPS_ACCESS_ID")
     odps_access_key: str = Field(default="", alias="ODPS_ACCESS_KEY")
@@ -93,6 +109,52 @@ class InfraSettings(BaseSettings):
         alias="SCHEDULER_CRON_MINUTE",
     )
 
+    # find_agent quality gates
+    find_agent_rule_version: str = Field(
+        default="find-agent-gate-v1",
+        alias="FIND_AGENT_RULE_VERSION",
+    )
+    find_agent_min_duration_seconds: int = Field(
+        default=30,
+        ge=30,
+        alias="FIND_AGENT_MIN_DURATION_SECONDS",
+    )
+    find_agent_min_share_count: int = Field(
+        default=1000,
+        ge=0,
+        alias="FIND_AGENT_MIN_SHARE_COUNT",
+    )
+    find_agent_min_content_50_plus_ratio: float = Field(
+        default=0.20,
+        ge=0,
+        le=1,
+        alias="FIND_AGENT_MIN_CONTENT_50_PLUS_RATIO",
+    )
+    find_agent_min_account_50_plus_ratio: float = Field(
+        default=0.20,
+        ge=0,
+        le=1,
+        alias="FIND_AGENT_MIN_ACCOUNT_50_PLUS_RATIO",
+    )
+    find_agent_festival_lead_days: int = Field(
+        default=7,
+        ge=0,
+        le=30,
+        alias="FIND_AGENT_FESTIVAL_LEAD_DAYS",
+    )
+    find_agent_event_max_age_days: int = Field(
+        default=7,
+        ge=1,
+        le=180,
+        alias="FIND_AGENT_EVENT_MAX_AGE_DAYS",
+    )
+    find_agent_seasonal_max_age_days: int = Field(
+        default=180,
+        ge=1,
+        le=366,
+        alias="FIND_AGENT_SEASONAL_MAX_AGE_DAYS",
+    )
+
     # Pipeline process/control plane
     process_role: str = Field(default="api", alias="PROCESS_ROLE")
     database_auto_create: bool = Field(default=False, alias="DATABASE_AUTO_CREATE")

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

@@ -1,9 +1,12 @@
 """ORM entity models — one file per table."""
 
 from supply_infra.db.models.agent_document_injection import AgentDocumentInjection
+from supply_infra.db.models.auth_session import AuthSession
+from supply_infra.db.models.auth_user import AuthUser
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
+from supply_infra.db.models.demand_feedback import DemandFeedback
 from supply_infra.db.models.demand_grade import DemandGrade
 from supply_infra.db.models.demand_grade_category_rel import DemandGradeCategoryRel
 from supply_infra.db.models.demand_grade_plan import (
@@ -35,9 +38,12 @@ from supply_infra.db.models.video_discovery import (
 
 __all__ = [
     "AgentDocumentInjection",
+    "AuthSession",
+    "AuthUser",
     "CategoryTreeWeight",
     "DemandBelongCategory",
     "DemandBelongPoolRel",
+    "DemandFeedback",
     "DemandGrade",
     "DemandGradeCategoryRel",
     "DemandGradePlan",

+ 39 - 0
supply_infra/db/models/auth_session.py

@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class AuthSession(Base):
+    """Server-side browser session. Only a SHA-256 token hash is persisted."""
+
+    __tablename__ = "auth_session"
+    __table_args__ = (
+        Index("idx_auth_session_user", "user_id", "expires_at"),
+        Index("idx_auth_session_expiry", "expires_at"),
+    )
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    token_hash: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        unique=True,
+        index=True,
+    )
+    user_id: Mapped[int] = mapped_column(
+        Integer,
+        ForeignKey("auth_user.id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )

+ 43 - 0
supply_infra/db/models/auth_user.py

@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class AuthUser(Base):
+    """Local SupplyAgent account with one fixed role."""
+
+    __tablename__ = "auth_user"
+    __table_args__ = (
+        Index("idx_auth_user_role_status", "role", "status"),
+    )
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    username: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        unique=True,
+        index=True,
+    )
+    password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+    display_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
+    status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
+    failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

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

@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class DemandFeedback(Base):
+    """需求汇总页面的人工反馈记录。"""
+
+    __tablename__ = "demand_feedback"
+    __table_args__ = (
+        UniqueConstraint(
+            "client_request_id",
+            name="uk_demand_feedback_request",
+        ),
+        Index(
+            "idx_demand_feedback_demand",
+            "demand_grade_id",
+            "created_at",
+        ),
+        Index(
+            "idx_demand_feedback_video",
+            "demand_grade_id",
+            "video_id",
+            "created_at",
+        ),
+        Index(
+            "idx_demand_feedback_expansion",
+            "demand_video_expansion_id",
+            "created_at",
+        ),
+        Index(
+            "idx_demand_feedback_user",
+            "feedback_user_id",
+            "created_at",
+        ),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    client_request_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    target_type: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        comment="反馈对象:demand / video / hit_content",
+    )
+    biz_dt: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        comment="目标所属业务日",
+    )
+    demand_grade_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        comment="目标 demand_grade.id",
+    )
+    video_id: Mapped[str | None] = mapped_column(
+        String(64),
+        nullable=True,
+        comment="视频反馈或命中内容反馈的视频 id",
+    )
+    demand_video_expansion_id: Mapped[int | None] = mapped_column(
+        BigInteger,
+        nullable=True,
+        comment="命中内容 demand_video_expansion.id",
+    )
+    feedback_action: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        comment="support / oppose / correct / supplement",
+    )
+    reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    content: Mapped[str | None] = mapped_column(Text, nullable=True)
+    target_snapshot_json: Mapped[str] = mapped_column(
+        Text,
+        nullable=False,
+        comment="提交时由服务端生成的目标快照",
+    )
+    feedback_user_id: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        comment="反馈人 auth_user.id",
+    )
+    feedback_user_name_snapshot: Mapped[str] = mapped_column(
+        String(128),
+        nullable=False,
+        comment="反馈人名称快照",
+    )
+    feedback_username_snapshot: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        comment="反馈人登录账号快照",
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 10 - 0
supply_infra/db/models/multi_demand_video_detail.py

@@ -16,6 +16,16 @@ class MultiDemandVideoDetail(Base):
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     vid: Mapped[str] = mapped_column(String(64), nullable=False, comment="视频id")
+    url1: Mapped[str | None] = mapped_column(
+        String(1024),
+        nullable=True,
+        comment="解构URL1(dwd_topic_decode_result_di 表字段,与 vid 同级)",
+    )
+    url2: Mapped[str | None] = mapped_column(
+        String(1024),
+        nullable=True,
+        comment="解构URL2(dwd_topic_decode_result_di 表字段,与 vid 同级)",
+    )
     title: Mapped[str | None] = mapped_column(
         String(512), nullable=True, comment="视频标题(decode_result.target_post.title)"
     )

+ 63 - 26
supply_infra/db/models/video_discovery.py

@@ -5,6 +5,7 @@ from decimal import Decimal
 
 from sqlalchemy import (
     BigInteger,
+    ForeignKey,
     Index,
     Integer,
     Numeric,
@@ -19,7 +20,7 @@ from supply_infra.db.base import Base
 
 
 class VideoDiscoveryRun(Base):
-    """一次需求找运行,保存输入、意图解释和完成状态。"""
+    """一次需求找视频运行,保存输入、意图解释和完成状态。"""
 
     __tablename__ = "video_discovery_run"
     __table_args__ = (
@@ -70,6 +71,12 @@ class VideoDiscoveryRun(Base):
     stop_reason: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="停止搜索的证据或失败原因"
     )
+    rule_version: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="本次候选门禁规则版本"
+    )
+    rule_config_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="运行时规则、当前时间和阈值快照 JSON"
+    )
     create_time: Mapped[datetime] = mapped_column(
         nullable=False, server_default=func.now(), comment="创建时间"
     )
@@ -86,7 +93,6 @@ class VideoDiscoverySearch(Base):
 
     __tablename__ = "video_discovery_search"
     __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_parent",
@@ -99,7 +105,7 @@ class VideoDiscoverySearch(Base):
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="发现运行 run_id")
     search_key: Mapped[str] = mapped_column(
-        String(64), nullable=False, comment="关键词/筛选/游标组合哈希"
+        String(64), nullable=False, comment="搜索参数哈希,仅用于追踪,不作为幂等键"
     )
     keyword: Mapped[str] = mapped_column(
         String(256), nullable=False, comment="Agent 自主确定的搜索词"
@@ -179,15 +185,23 @@ class VideoDiscoveryCandidate(Base):
 
     __tablename__ = "video_discovery_candidate"
     __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_author", "author_sec_uid"),
     )
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     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")
     title: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="视频标题")
     content_link: Mapped[str | None] = mapped_column(
@@ -208,15 +222,17 @@ class VideoDiscoveryCandidate(Base):
     tags_json: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="视频标签/话题 JSON"
     )
-    hit_points_json: Mapped[str | None] = mapped_column(
-        Text, nullable=True, comment="命中的需求相关点 JSON"
+    publish_at: Mapped[datetime | None] = mapped_column(
+        nullable=True, comment="视频真实发布时间,按运行时区解释"
+    )
+    duration_seconds: Mapped[Decimal | None] = mapped_column(
+        Numeric(10, 3), nullable=True, comment="视频时长(秒)"
     )
     play_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)
     collect_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(
         Text, nullable=True, comment="视频点赞用户年龄证据 JSON"
     )
@@ -226,20 +242,47 @@ class VideoDiscoveryCandidate(Base):
     age_normalization_json: Mapped[str | None] = mapped_column(
         Text, nullable=True, comment="双侧年龄画像标准化结果 JSON"
     )
-    detail_verified: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已核验视频详情"
+    content_50_plus_ratio: Mapped[Decimal | None] = mapped_column(
+        Numeric(8, 6), nullable=True, comment="视频点赞用户中 50+ 占比"
+    )
+    content_50_plus_tgi: Mapped[Decimal | None] = mapped_column(
+        Numeric(10, 4), nullable=True, comment="视频点赞用户 50+ TGI"
+    )
+    content_portrait_status: Mapped[str | None] = mapped_column(
+        String(16), nullable=True, comment="视频画像结论 pass / fail / missing"
     )
-    content_portrait_attempted: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已尝试视频点赞画像"
+    account_50_plus_ratio: Mapped[Decimal | None] = mapped_column(
+        Numeric(8, 6), nullable=True, comment="账号粉丝中 50+ 占比"
     )
-    account_portrait_attempted: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已尝试作者粉丝画像"
+    account_50_plus_tgi: Mapped[Decimal | None] = mapped_column(
+        Numeric(10, 4), nullable=True, comment="账号粉丝 50+ TGI"
     )
-    age_portraits_normalized: Mapped[int] = mapped_column(
-        Integer, nullable=False, default=0, comment="是否已执行年龄画像标准化"
+    account_portrait_status: Mapped[str | None] = mapped_column(
+        String(16), nullable=True, comment="账号画像结论 pass / fail / missing"
     )
-    expansion_worthy_tags_json: Mapped[str | None] = mapped_column(
-        Text, nullable=True, comment="值得继续搜索的标签 JSON"
+    portrait_conflict: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="视频与账号画像结论是否冲突"
+    )
+    temporal_type: Mapped[str | None] = mapped_column(
+        String(24), nullable=True, comment="daypart / festival / event / seasonal / evergreen"
+    )
+    temporal_status: Mapped[str | None] = mapped_column(
+        String(16), nullable=True, comment="时间有效性 pass / fail / unknown"
+    )
+    temporal_evidence_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="时间语义、有效窗口和判断理由 JSON"
+    )
+    gate_status: Mapped[str | None] = mapped_column(
+        String(16), nullable=True, comment="硬门槛 pass / fail / pending"
+    )
+    gate_results_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="逐项硬门槛结果 JSON"
+    )
+    reject_reason_code: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="主要淘汰原因码"
+    )
+    rule_version: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="候选评估使用的规则版本"
     )
     relevance_score: Mapped[Decimal | None] = mapped_column(
         Numeric(8, 6), nullable=True, comment="R,范围 0~1"
@@ -251,14 +294,8 @@ class VideoDiscoveryCandidate(Base):
         Numeric(8, 6), nullable=True, comment="S,范围 0~1"
     )
     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_bucket: Mapped[str] = mapped_column(
         String(24),

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

@@ -3,6 +3,7 @@
 from supply_infra.db.repositories.agent_document_injection_repo import (
     AgentDocumentInjectionRepository,
 )
+from supply_infra.db.repositories.auth_repo import AuthRepository
 from supply_infra.db.repositories.base import BaseRepository
 from supply_infra.db.repositories.category_tree_weight_repo import (
     CategoryTreeWeightRepository,
@@ -13,6 +14,7 @@ from supply_infra.db.repositories.demand_belong_category_repo import (
 from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
     DemandBelongPoolRelRepository,
 )
+from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
 from supply_infra.db.repositories.demand_grade_category_rel_repo import (
     DemandGradeCategoryRelRepository,
 )
@@ -44,10 +46,12 @@ from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepo
 
 __all__ = [
     "AgentDocumentInjectionRepository",
+    "AuthRepository",
     "BaseRepository",
     "CategoryTreeWeightRepository",
     "DemandBelongCategoryRepository",
     "DemandBelongPoolRelRepository",
+    "DemandFeedbackRepository",
     "DemandGradeCategoryRelRepository",
     "DemandGradeRepository",
     "DemandGradePlanRepository",

+ 104 - 0
supply_infra/db/repositories/auth_repo.py

@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from supply_infra.db.models.auth_session import AuthSession
+from supply_infra.db.models.auth_user import AuthUser
+
+
+class AuthRepository:
+    """Persistence operations for local users and server-side sessions."""
+
+    def __init__(self, session: Session) -> None:
+        self.session = session
+
+    def get_user(self, user_id: int) -> AuthUser | None:
+        return self.session.get(AuthUser, user_id)
+
+    def get_user_by_username(self, username: str) -> AuthUser | None:
+        stmt = select(AuthUser).where(AuthUser.username == username)
+        return self.session.scalar(stmt)
+
+    def list_users(self) -> list[AuthUser]:
+        stmt = select(AuthUser).order_by(AuthUser.created_at.asc(), AuthUser.id.asc())
+        return list(self.session.scalars(stmt).all())
+
+    def create_user(
+        self,
+        *,
+        username: str,
+        password_hash: str,
+        display_name: str,
+        role: str,
+        status: str = "active",
+    ) -> AuthUser:
+        user = AuthUser(
+            username=username,
+            password_hash=password_hash,
+            display_name=display_name,
+            role=role,
+            status=status,
+        )
+        self.session.add(user)
+        self.session.flush()
+        return user
+
+    def delete_user(self, user: AuthUser) -> None:
+        self.session.delete(user)
+        self.session.flush()
+
+    def create_session(
+        self,
+        *,
+        token_hash: str,
+        user_id: int,
+        expires_at: datetime,
+        ip_address: str | None,
+        user_agent: str | None,
+    ) -> AuthSession:
+        auth_session = AuthSession(
+            token_hash=token_hash,
+            user_id=user_id,
+            expires_at=expires_at,
+            ip_address=ip_address,
+            user_agent=user_agent,
+        )
+        self.session.add(auth_session)
+        self.session.flush()
+        return auth_session
+
+    def get_active_session(
+        self,
+        *,
+        token_hash: str,
+        now: datetime,
+    ) -> AuthUser | None:
+        stmt = (
+            select(AuthUser)
+            .select_from(AuthSession)
+            .join(AuthUser, AuthUser.id == AuthSession.user_id)
+            .where(
+                AuthSession.token_hash == token_hash,
+                AuthSession.expires_at > now,
+                AuthUser.status == "active",
+            )
+        )
+        return self.session.scalar(stmt)
+
+    def delete_session_by_hash(self, token_hash: str) -> None:
+        self.session.execute(
+            delete(AuthSession).where(AuthSession.token_hash == token_hash)
+        )
+
+    def delete_user_sessions(self, user_id: int) -> None:
+        self.session.execute(
+            delete(AuthSession).where(AuthSession.user_id == user_id)
+        )
+
+    def delete_expired_sessions(self, now: datetime) -> None:
+        self.session.execute(
+            delete(AuthSession).where(AuthSession.expires_at <= now)
+        )

+ 105 - 0
supply_infra/db/repositories/demand_feedback_repo.py

@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from sqlalchemy import func, select
+
+from supply_infra.db.models.demand_feedback import DemandFeedback
+from supply_infra.db.repositories.base import BaseRepository
+
+
+class DemandFeedbackRepository(BaseRepository[DemandFeedback]):
+    """需求、视频与命中内容反馈 repository。"""
+
+    model = DemandFeedback
+
+    def get_by_client_request_id(self, client_request_id: str) -> DemandFeedback | None:
+        stmt = select(DemandFeedback).where(
+            DemandFeedback.client_request_id == client_request_id
+        )
+        return self.session.scalars(stmt).first()
+
+    def count_demands(self, demand_grade_ids: list[int]) -> dict[int, int]:
+        if not demand_grade_ids:
+            return {}
+        stmt = (
+            select(
+                DemandFeedback.demand_grade_id,
+                func.count(DemandFeedback.id),
+            )
+            .where(
+                DemandFeedback.target_type == "demand",
+                DemandFeedback.demand_grade_id.in_(demand_grade_ids),
+            )
+            .group_by(DemandFeedback.demand_grade_id)
+        )
+        return {
+            int(demand_grade_id): int(count or 0)
+            for demand_grade_id, count in self.session.execute(stmt).all()
+        }
+
+    def count_for_demand(
+        self,
+        demand_grade_id: int,
+    ) -> tuple[int, dict[str, int], dict[int, int]]:
+        stmt = (
+            select(
+                DemandFeedback.target_type,
+                DemandFeedback.video_id,
+                DemandFeedback.demand_video_expansion_id,
+                func.count(DemandFeedback.id),
+            )
+            .where(DemandFeedback.demand_grade_id == int(demand_grade_id))
+            .group_by(
+                DemandFeedback.target_type,
+                DemandFeedback.video_id,
+                DemandFeedback.demand_video_expansion_id,
+            )
+        )
+        demand_count = 0
+        video_counts: dict[str, int] = {}
+        expansion_counts: dict[int, int] = {}
+        for target_type, video_id, expansion_id, count in self.session.execute(stmt).all():
+            value = int(count or 0)
+            if target_type == "demand":
+                demand_count += value
+            elif target_type == "video" and video_id:
+                video_counts[str(video_id)] = value
+            elif target_type == "hit_content" and expansion_id is not None:
+                expansion_counts[int(expansion_id)] = value
+        return demand_count, video_counts, expansion_counts
+
+    def list_for_target(
+        self,
+        *,
+        target_type: str,
+        demand_grade_id: int,
+        video_id: str | None,
+        demand_video_expansion_id: int | None,
+        limit: int,
+        offset: int,
+    ) -> tuple[list[DemandFeedback], int]:
+        filters = [
+            DemandFeedback.target_type == target_type,
+            DemandFeedback.demand_grade_id == int(demand_grade_id),
+        ]
+        if target_type in {"video", "hit_content"}:
+            filters.append(DemandFeedback.video_id == video_id)
+        else:
+            filters.append(DemandFeedback.video_id.is_(None))
+        if target_type == "hit_content":
+            filters.append(
+                DemandFeedback.demand_video_expansion_id
+                == int(demand_video_expansion_id or 0)
+            )
+        else:
+            filters.append(DemandFeedback.demand_video_expansion_id.is_(None))
+
+        total_stmt = select(func.count(DemandFeedback.id)).where(*filters)
+        total = int(self.session.scalar(total_stmt) or 0)
+        rows_stmt = (
+            select(DemandFeedback)
+            .where(*filters)
+            .order_by(DemandFeedback.created_at.desc(), DemandFeedback.id.desc())
+            .offset(offset)
+            .limit(limit)
+        )
+        return list(self.session.scalars(rows_stmt).all()), total

+ 22 - 0
supply_infra/db/repositories/demand_video_expansion_repo.py

@@ -17,6 +17,28 @@ class DemandVideoExpansionRepository(BaseRepository[DemandVideoExpansion]):
 
     model = DemandVideoExpansion
 
+    def get_active_by_id(self, expansion_id: int) -> DemandVideoExpansion | None:
+        stmt = select(DemandVideoExpansion).where(
+            DemandVideoExpansion.id == int(expansion_id),
+            DemandVideoExpansion.is_delete == 0,
+        )
+        return self.session.scalars(stmt).first()
+
+    def has_video(
+        self,
+        *,
+        biz_dt: str,
+        source_demand_grade_id: int,
+        video_id: str,
+    ) -> bool:
+        stmt = select(DemandVideoExpansion.id).where(
+            DemandVideoExpansion.biz_dt == biz_dt,
+            DemandVideoExpansion.source_demand_grade_id == int(source_demand_grade_id),
+            DemandVideoExpansion.video_id == video_id,
+            DemandVideoExpansion.is_delete == 0,
+        )
+        return self.session.scalar(stmt.limit(1)) is not None
+
     def list_by_demand_grade(
         self, biz_dt: str, source_demand_grade_id: int
     ) -> list[DemandVideoExpansion]:

+ 28 - 0
supply_infra/db/repositories/multi_demand_video_detail_repo.py

@@ -64,6 +64,16 @@ class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
         )
         return [str(v) for v in self.session.scalars(stmt).all() if v]
 
+    def list_vids_missing_urls(self) -> list[str]:
+        """返回 url1 或 url2 为空的全部 vid。"""
+        stmt = select(MultiDemandVideoDetail.vid).where(
+            MultiDemandVideoDetail.url1.is_(None)
+            | (MultiDemandVideoDetail.url1 == "")
+            | MultiDemandVideoDetail.url2.is_(None)
+            | (MultiDemandVideoDetail.url2 == "")
+        )
+        return [str(v) for v in self.session.scalars(stmt).all() if v]
+
     def bulk_insert_ignore(self, rows: list[dict]) -> int:
         """批量插入,MySQL 自动忽略 vid 重复行。"""
         if not rows:
@@ -93,6 +103,24 @@ class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
             updated += result.rowcount or 0
         return updated
 
+    def update_urls(self, urls_by_vid: dict[str, dict[str, str | None]]) -> int:
+        """按 vid 批量更新 url1/url2(values 为落库字段名 → URL 文本)。"""
+        if not urls_by_vid:
+            return 0
+
+        updated = 0
+        for vid, values in urls_by_vid.items():
+            if not values:
+                continue
+            stmt = (
+                update(MultiDemandVideoDetail)
+                .where(MultiDemandVideoDetail.vid == vid)
+                .values(**values)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated
+
     def update_points(self, points_by_vid: dict[str, dict[str, str | None]]) -> int:
         """按 vid 批量更新灵感点/目的点/关键点(values 为落库字段名 → JSON 文本)。"""
         if not points_by_vid:

+ 17 - 0
supply_infra/db/repositories/pipeline_step_run_repo.py

@@ -6,6 +6,7 @@ from typing import Any
 
 from sqlalchemy import func, select
 
+from supply_infra.db.models.pipeline_run import PipelineRun
 from supply_infra.db.models.pipeline_step_run import PipelineStepRun
 from supply_infra.db.repositories.base import BaseRepository
 from supply_infra.db.repositories.pipeline_lock_repo import PipelineLockRepository
@@ -223,3 +224,19 @@ class PipelineStepRunRepository(BaseRepository[PipelineStepRun]):
             count += 1
         self.session.flush()
         return count
+
+    def get_latest_succeeded_biz_dt(self, step_key: str) -> str | None:
+        """返回指定步骤最近一次成功完成时所属 pipeline_run 的 biz_dt。"""
+        stmt = (
+            select(PipelineRun.biz_dt)
+            .join(PipelineStepRun, PipelineStepRun.run_id == PipelineRun.run_id)
+            .where(
+                PipelineStepRun.step_key == step_key,
+                PipelineStepRun.status == "succeeded",
+                PipelineStepRun.finished_at.is_not(None),
+            )
+            .order_by(PipelineStepRun.finished_at.desc())
+            .limit(1)
+        )
+        value = self.session.scalar(stmt)
+        return str(value) if value else None

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

@@ -6,6 +6,10 @@ from typing import Any
 from sqlalchemy import func, select
 from sqlalchemy.exc import OperationalError, ProgrammingError
 
+from supply_infra.video_discovery_gates import (
+    evaluate_candidate_gate,
+    load_rule_snapshot,
+)
 from supply_infra.db.models.video_discovery import (
     VideoDiscoveryCandidate,
     VideoDiscoveryRun,
@@ -13,19 +17,6 @@ from supply_infra.db.models.video_discovery import (
 )
 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]:
     if not raw:
         return []
@@ -54,7 +45,7 @@ _PUBLISHABLE_BUCKETS = ("primary",)
 
 
 class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
-    """需求找运行、搜索轨迹和候选快照的统一 Repository。"""
+    """需求找视频运行、搜索轨迹和候选快照的统一 Repository。"""
 
     model = VideoDiscoveryRun
 
@@ -136,142 +127,149 @@ class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
         self,
         search_values: dict[str, Any],
         candidate_rows: list[dict[str, Any]],
-    ) -> tuple[VideoDiscoverySearch, int]:
-        """幂等保存一个搜索页,并把页内结果并入本次运行候选集。"""
+    ) -> tuple[VideoDiscoverySearch, list[VideoDiscoveryCandidate]]:
+        """新增一个搜索页,并为本页每条结果新增独立候选记录。"""
         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:
                 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)
-            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():
-                if key == "aweme_id" or value is None:
+                if value is None:
                     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)
+            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._refresh_run_counts(run_id)
-        return search, new_count
+        return search, candidates
 
-    def save_candidate_evaluations(
+    def update_candidates(
         self,
         run_id: str,
         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 不能重复")
+
+        run = self.get_run(run_id)
+        if run is None:
+            raise ValueError(f"run_id 不存在: {run_id}")
+        rule_snapshot = load_rule_snapshot(run.rule_config_json)
+
         stmt = select(VideoDiscoveryCandidate).where(
             VideoDiscoveryCandidate.run_id == run_id,
-            VideoDiscoveryCandidate.aweme_id.in_(aweme_ids),
+            VideoDiscoveryCandidate.id.in_(candidate_ids),
         )
         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
+        gate_failures: list[str] = []
         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():
-                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
                 if key in {
                     "source_keywords_json",
                     "source_search_ids_json",
                     "tags_json",
-                    "hit_points_json",
-                    "expansion_worthy_tags_json",
                 }:
                     values = value if isinstance(value, list) else _json_list(str(value))
                     setattr(entity, key, _merge_json_list(getattr(entity, key), values))
                 elif hasattr(entity, key):
                     setattr(entity, key, value)
 
-            after_audit_state = tuple(
-                getattr(entity, field)
-                for field in _AUDIT_RELEVANT_CANDIDATE_FIELDS
+            gate = evaluate_candidate_gate(
+                {
+                    "title": entity.title,
+                    "tags_json": entity.tags_json,
+                    "publish_at": entity.publish_at,
+                    "duration_seconds": entity.duration_seconds,
+                    "share_count": entity.share_count,
+                    "content_50_plus_ratio": entity.content_50_plus_ratio,
+                    "account_50_plus_ratio": entity.account_50_plus_ratio,
+                    "temporal_type": entity.temporal_type,
+                    "temporal_status": entity.temporal_status,
+                    "temporal_evidence_json": entity.temporal_evidence_json,
+                },
+                rule_snapshot,
+            )
+            entity.content_portrait_status = gate["content_portrait_status"]
+            entity.account_portrait_status = gate["account_portrait_status"]
+            entity.portrait_conflict = int(bool(gate["portrait_conflict"]))
+            entity.temporal_type = gate["temporal"]["temporal_type"]
+            entity.temporal_status = gate["temporal"]["status"]
+            entity.temporal_evidence_json = json.dumps(
+                gate["temporal"],
+                ensure_ascii=False,
+                default=str,
+            )
+            entity.gate_status = gate["status"]
+            entity.gate_results_json = json.dumps(
+                gate,
+                ensure_ascii=False,
+                default=str,
+            )
+            entity.rule_version = str(gate["rule_version"])
+
+            if entity.decision_bucket == "primary":
+                if not gate["primary_eligible"]:
+                    codes = ", ".join(gate["failed_reason_codes"])
+                    gate_failures.append(f"candidate_id={candidate_id}: {codes}")
+                else:
+                    entity.reject_reason_code = None
+            else:
+                failed_codes = gate["failed_reason_codes"]
+                if failed_codes:
+                    entity.reject_reason_code = str(failed_codes[0])
+                elif not entity.reject_reason_code:
+                    entity.reject_reason_code = "REJECTED_BY_AGENT"
+
+        if gate_failures:
+            raise ValueError(
+                "primary 候选未通过 P0 硬门槛: " + "; ".join(gate_failures)
             )
-            if (
-                before_audit_state is None
-                or before_audit_state != after_audit_state
-            ):
-                audit_relevant_changed = True
-
         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(
         self,

+ 3 - 1
supply_infra/odps/client.py

@@ -124,7 +124,7 @@ class ODPSClient:
         vids: list[str],
         batch_size: int = 100,
     ) -> list[dict[str, Any]]:
-        """按 vid 批量拉取 dwd_topic_decode_result_di 的 decode_result。"""
+        """按 vid 批量拉取 dwd_topic_decode_result_di 的 vid、url1、url2、decode_result。"""
         # 保序去重
         unique_vids = list(
             dict.fromkeys(
@@ -141,6 +141,8 @@ class ODPSClient:
             sql = f"""
             SELECT  vid
                     ,decode_result
+                    ,url1
+                    ,url2
             FROM    loghubods.dwd_topic_decode_result_di
             WHERE   dt = '{dt}'
             AND     vid IN ({in_list})

+ 1 - 1
supply_infra/scheduler/constants.py

@@ -3,5 +3,5 @@
 SUPPLY_PIPELINE_JOB_ID = "run_supply_pipeline"
 SUPPLY_PIPELINE_JOB_NAME = "供给数据流水线"
 
-# find_agent:当日全部 S/A 需求(有拓展点位),单线程串行找;有效视频满 200 提前结束
+# find_agent:当日全部 S/A 需求(有拓展点位),单线程串行找视频;有效视频满 200 提前结束
 PIPELINE_FIND_AGENT_WORKERS = 1

+ 163 - 0
supply_infra/scheduler/jobs/demand_pool/videos.py

@@ -99,6 +99,14 @@ def _extract_title(payload: dict[str, Any]) -> str | None:
     return text[:512] if text else None
 
 
+def _extract_url(value: Any, max_len: int = 1024) -> str | None:
+    """从 ODPS 行字段提取 URL 文本(与 vid 同级)。"""
+    if value is None:
+        return None
+    text = str(value).strip()
+    return text[:max_len] if text else None
+
+
 def _sync_one_batch(
     odps: ODPSClient,
     decode_dt: str,
@@ -141,6 +149,8 @@ def _sync_one_batch(
         insert_rows.append(
             {
                 "vid": vid,
+                "url1": _extract_url(row.get("url1")),
+                "url2": _extract_url(row.get("url2")),
                 "title": _extract_title(payload),
                 "final_topic_json": topic_json,
                 **_extract_all_points(payload),
@@ -280,3 +290,156 @@ def sync_multi_demand_videos(
     }
     logger.info("Multi demand video sync completed: %s", result)
     return result
+
+
+def _backfill_urls_one_batch(
+    odps: ODPSClient,
+    decode_dt: str,
+    batch_vids: list[str],
+    batch_idx: int,
+    batch_total: int,
+) -> dict[str, int]:
+    """查询一批 vid 的 url1/url2,立刻更新 MySQL。"""
+    logger.info(
+        "Backfill batch %d/%d: query %d vids, decode_dt=%s",
+        batch_idx,
+        batch_total,
+        len(batch_vids),
+        decode_dt,
+    )
+    odps_rows = odps.fetch_topic_decode_results(
+        decode_dt, batch_vids, batch_size=len(batch_vids)
+    )
+
+    urls_by_vid: dict[str, dict[str, str | None]] = {}
+    seen_vids: set[str] = set()
+    for row in odps_rows:
+        raw_vid = row.get("vid")
+        if raw_vid is None:
+            continue
+        vid = str(raw_vid).strip()
+        if not vid or vid in seen_vids:
+            continue
+        url1 = _extract_url(row.get("url1"))
+        url2 = _extract_url(row.get("url2"))
+        if url1 is None and url2 is None:
+            continue
+        seen_vids.add(vid)
+        urls_by_vid[vid] = {"url1": url1, "url2": url2}
+
+    with get_session() as session:
+        updated = MultiDemandVideoDetailRepository(session).update_urls(urls_by_vid)
+
+    odps_vids = {
+        str(r.get("vid")).strip()
+        for r in odps_rows
+        if r.get("vid") is not None and str(r.get("vid")).strip()
+    }
+    stats = {
+        "odps_rows": len(odps_rows),
+        "matched": len(urls_by_vid),
+        "updated": updated,
+        "missing_in_odps": len(set(batch_vids) - odps_vids),
+        "no_urls_in_odps": len(odps_rows) - len(urls_by_vid),
+    }
+    logger.info("Backfill batch %d/%d done: %s", batch_idx, batch_total, stats)
+    return stats
+
+
+def backfill_multi_demand_video_urls(
+    limit: int | None = None,
+    offset: int = 0,
+    batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+    *,
+    decode_dt: str | None = None,
+) -> dict[str, Any]:
+    """
+    回填已有 multi_demand_video_detail 行的 url1/url2。
+
+    - 目标:url1 或 url2 为空的 vid
+    - 数据来源:ODPS dwd_topic_decode_result_di 表字段(与 vid 同级)
+    - decode_dt 默认「今天的昨天」
+    """
+    resolved_decode_dt = decode_dt or (
+        datetime.now() - timedelta(days=1)
+    ).strftime("%Y%m%d")
+    start = max(0, int(offset))
+    chunk = max(1, int(batch_size))
+    logger.info(
+        "Starting multi demand video url backfill: decode_dt=%s limit=%s offset=%d batch_size=%d",
+        resolved_decode_dt,
+        limit,
+        start,
+        chunk,
+    )
+
+    with get_session() as session:
+        pending_all = sorted(
+            MultiDemandVideoDetailRepository(session).list_vids_missing_urls()
+        )
+
+    pending = pending_all[start:]
+    if limit is not None:
+        pending = pending[: max(0, int(limit))]
+
+    logger.info(
+        "Url backfill vids: pending_total=%d to_process=%d",
+        len(pending_all),
+        len(pending),
+    )
+
+    empty = {
+        "decode_dt": resolved_decode_dt,
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": 0,
+        "processed": 0,
+        "odps_rows": 0,
+        "matched": 0,
+        "updated": 0,
+        "missing_in_odps": 0,
+        "no_urls_in_odps": 0,
+        "next_offset": start,
+        "remaining": max(0, len(pending_all) - start),
+    }
+    if not pending:
+        logger.info("No pending vids for url backfill: %s", empty)
+        return empty
+
+    odps = get_odps_client()
+    batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
+    total_odps_rows = 0
+    total_matched = 0
+    total_updated = 0
+    total_missing = 0
+    total_no_urls = 0
+
+    for idx, batch_vids in enumerate(batches, start=1):
+        stats = _backfill_urls_one_batch(
+            odps, resolved_decode_dt, batch_vids, idx, len(batches)
+        )
+        total_odps_rows += stats["odps_rows"]
+        total_matched += stats["matched"]
+        total_updated += stats["updated"]
+        total_missing += stats["missing_in_odps"]
+        total_no_urls += stats["no_urls_in_odps"]
+
+    next_offset = start + len(pending)
+    result = {
+        "decode_dt": resolved_decode_dt,
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": len(batches),
+        "processed": len(pending),
+        "odps_rows": total_odps_rows,
+        "matched": total_matched,
+        "updated": total_updated,
+        "missing_in_odps": total_missing,
+        "no_urls_in_odps": total_no_urls,
+        "next_offset": next_offset,
+        "remaining": max(0, len(pending_all) - next_offset),
+    }
+    logger.info("Multi demand video url backfill completed: %s", result)
+    return result

+ 67 - 55
supply_infra/services/video_discovery_service.py

@@ -48,6 +48,8 @@ def _serialize_run(run: Any) -> dict[str, Any]:
         "search_count": int(run.search_count or 0),
         "primary_count": int(run.primary_count or 0),
         "stop_reason": run.stop_reason,
+        "rule_version": run.rule_version,
+        "rule_config": _load_json(run.rule_config_json, {}),
     }
 
 
@@ -61,12 +63,17 @@ def _serialize_search(item: Any) -> dict[str, Any]:
         "parent_search_id": item.parent_search_id,
         "provider": item.provider,
         "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,
         "page_no": item.page_no,
         "results_count": item.results_count,
         "new_candidate_count": item.new_candidate_count,
         "has_more": bool(item.has_more),
         "next_cursor": item.next_cursor,
+        "status": item.status,
+        "error_message": item.error_message,
     }
 
 
@@ -77,6 +84,8 @@ def _serialize_candidate(item: Any) -> dict[str, Any]:
         else item.decision_bucket
     )
     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,
         "title": item.title,
         "content_link": item.content_link,
@@ -85,7 +94,10 @@ def _serialize_candidate(item: Any) -> dict[str, Any]:
         "source_keywords": _load_json(item.source_keywords_json, []),
         "source_search_ids": _load_json(item.source_search_ids_json, []),
         "tags": _load_json(item.tags_json, []),
-        "hit_points": _load_json(item.hit_points_json, []),
+        "publish_at": item.publish_at.isoformat() if item.publish_at else None,
+        "duration_seconds": (
+            float(item.duration_seconds) if item.duration_seconds is not None else None
+        ),
         "play_count": item.play_count,
         "like_count": item.like_count,
         "comment_count": item.comment_count,
@@ -97,19 +109,40 @@ def _serialize_candidate(item: Any) -> dict[str, Any]:
         "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,
         "value_score": float(item.value_score) if item.value_score is not None else None,
-        "confidence": item.confidence,
         "decision_bucket": decision_bucket,
         "content_age_evidence": _load_json(item.content_age_evidence_json, {}),
         "account_age_evidence": _load_json(item.account_age_evidence_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,
+        "content_50_plus_ratio": (
+            float(item.content_50_plus_ratio)
+            if item.content_50_plus_ratio is not None
+            else None
+        ),
+        "content_50_plus_tgi": (
+            float(item.content_50_plus_tgi)
+            if item.content_50_plus_tgi is not None
+            else None
+        ),
+        "content_portrait_status": item.content_portrait_status,
+        "account_50_plus_ratio": (
+            float(item.account_50_plus_ratio)
+            if item.account_50_plus_ratio is not None
+            else None
+        ),
+        "account_50_plus_tgi": (
+            float(item.account_50_plus_tgi)
+            if item.account_50_plus_tgi is not None
+            else None
+        ),
+        "account_portrait_status": item.account_portrait_status,
+        "portrait_conflict": bool(item.portrait_conflict),
+        "temporal_type": item.temporal_type,
+        "temporal_status": item.temporal_status,
+        "temporal_evidence": _load_json(item.temporal_evidence_json, {}),
+        "gate_status": item.gate_status,
+        "gate_results": _load_json(item.gate_results_json, {}),
+        "reject_reason_code": item.reject_reason_code,
+        "rule_version": item.rule_version,
         "decision_reason": item.decision_reason,
     }
 
@@ -160,24 +193,36 @@ class VideoDiscoveryService:
             repo = VideoDiscoveryRepository(session)
             if repo.get_run(run_id) is None:
                 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 {
-                "search_id": int(search.id),
+                **_serialize_search(search),
                 "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,
         run_id: str,
         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,
         intent_summary: str | None = None,
@@ -187,24 +232,13 @@ class VideoDiscoveryService:
             repo = VideoDiscoveryRepository(session)
             if repo.get_run(run_id) is None:
                 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_id,
                 status=status,
                 intent_summary=intent_summary,
                 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(
         self,
@@ -233,28 +267,6 @@ class VideoDiscoveryService:
                 "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(
         self,
         *,

+ 519 - 0
supply_infra/video_discovery_gates.py

@@ -0,0 +1,519 @@
+"""find_agent P0 候选硬门槛与基础时效规则。"""
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import asdict, dataclass
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
+from typing import Any, Mapping
+from zoneinfo import ZoneInfo
+
+from supply_infra.config import get_infra_settings
+
+
+@dataclass(frozen=True)
+class FindAgentGateRules:
+    rule_version: str
+    timezone: str
+    min_duration_seconds: int
+    min_share_count: int
+    min_content_50_plus_ratio: float
+    min_account_50_plus_ratio: float
+    festival_lead_days: int
+    event_max_age_days: int
+    seasonal_max_age_days: int
+
+
+_DAYPART_RULES: tuple[tuple[str, tuple[str, ...], time, time], ...] = (
+    ("morning", ("早上好", "早安", "晨安", "早晨好"), time(5, 0), time(10, 30)),
+    ("noon", ("午安", "中午好", "午间好"), time(11, 0), time(14, 0)),
+    ("evening", ("晚上好", "晚安", "夜安"), time(18, 0), time(1, 0)),
+)
+
+_FIXED_FESTIVALS: dict[str, tuple[int, int]] = {
+    "元旦": (1, 1),
+    "情人节": (2, 14),
+    "妇女节": (3, 8),
+    "劳动节": (5, 1),
+    "儿童节": (6, 1),
+    "建党节": (7, 1),
+    "建军节": (8, 1),
+    "教师节": (9, 10),
+    "国庆": (10, 1),
+    "国庆节": (10, 1),
+    "圣诞": (12, 25),
+    "圣诞节": (12, 25),
+}
+
+# 业务运行期内使用的农历节日阳历日期。超出覆盖年份时返回 unknown,不猜测日期。
+_LUNAR_FESTIVALS: dict[int, dict[str, tuple[int, int]]] = {
+    2025: {
+        "春节": (1, 29),
+        "元宵": (2, 12),
+        "元宵节": (2, 12),
+        "端午": (5, 31),
+        "端午节": (5, 31),
+        "七夕": (8, 29),
+        "中秋": (10, 6),
+        "中秋节": (10, 6),
+        "重阳": (10, 29),
+        "重阳节": (10, 29),
+    },
+    2026: {
+        "春节": (2, 17),
+        "元宵": (3, 3),
+        "元宵节": (3, 3),
+        "端午": (6, 19),
+        "端午节": (6, 19),
+        "七夕": (8, 19),
+        "中秋": (9, 25),
+        "中秋节": (9, 25),
+        "重阳": (10, 18),
+        "重阳节": (10, 18),
+    },
+    2027: {
+        "春节": (2, 6),
+        "元宵": (2, 20),
+        "元宵节": (2, 20),
+        "端午": (6, 9),
+        "端午节": (6, 9),
+        "七夕": (8, 8),
+        "中秋": (9, 15),
+        "中秋节": (9, 15),
+        "重阳": (10, 8),
+        "重阳节": (10, 8),
+    },
+}
+
+_EVENT_MARKERS = (
+    "最新消息",
+    "突发",
+    "刚刚",
+    "今日新闻",
+    "赛事结果",
+    "比赛结果",
+)
+_RELATIVE_DATE_MARKERS = ("今天", "今日", "明天", "昨日", "昨天")
+
+
+def get_gate_rules() -> FindAgentGateRules:
+    settings = get_infra_settings()
+    return FindAgentGateRules(
+        rule_version=settings.find_agent_rule_version,
+        timezone=settings.scheduler_timezone,
+        min_duration_seconds=settings.find_agent_min_duration_seconds,
+        min_share_count=settings.find_agent_min_share_count,
+        min_content_50_plus_ratio=settings.find_agent_min_content_50_plus_ratio,
+        min_account_50_plus_ratio=settings.find_agent_min_account_50_plus_ratio,
+        festival_lead_days=settings.find_agent_festival_lead_days,
+        event_max_age_days=settings.find_agent_event_max_age_days,
+        seasonal_max_age_days=settings.find_agent_seasonal_max_age_days,
+    )
+
+
+def build_rule_snapshot(now: datetime | None = None) -> dict[str, Any]:
+    rules = get_gate_rules()
+    timezone = ZoneInfo(rules.timezone)
+    evaluated_at = now or datetime.now(timezone)
+    if evaluated_at.tzinfo is None:
+        evaluated_at = evaluated_at.replace(tzinfo=timezone)
+    else:
+        evaluated_at = evaluated_at.astimezone(timezone)
+    return {
+        **asdict(rules),
+        "current_datetime": evaluated_at.isoformat(timespec="seconds"),
+        "current_date": evaluated_at.strftime("%Y-%m-%d"),
+    }
+
+
+def load_rule_snapshot(value: Mapping[str, Any] | str | None) -> dict[str, Any]:
+    if isinstance(value, str):
+        try:
+            loaded = json.loads(value)
+        except (TypeError, ValueError):
+            loaded = {}
+    else:
+        loaded = dict(value or {})
+    fallback = build_rule_snapshot()
+    return {**fallback, **loaded}
+
+
+def parse_datetime_value(
+    value: Any,
+    *,
+    timezone_name: str = "Asia/Shanghai",
+) -> datetime | None:
+    if value in (None, "") or isinstance(value, bool):
+        return None
+    timezone = ZoneInfo(timezone_name)
+    if isinstance(value, datetime):
+        parsed = value
+    elif isinstance(value, date):
+        parsed = datetime.combine(value, time.min)
+    elif isinstance(value, (int, float, Decimal)):
+        timestamp = float(value)
+        if timestamp > 10_000_000_000:
+            timestamp /= 1000
+        try:
+            parsed = datetime.fromtimestamp(timestamp, timezone)
+        except (OSError, OverflowError, ValueError):
+            return None
+    else:
+        text = str(value).strip()
+        if not text:
+            return None
+        if re.fullmatch(r"\d{10,13}", text):
+            return parse_datetime_value(int(text), timezone_name=timezone_name)
+        normalized = text.replace("Z", "+00:00")
+        parsed = None
+        for candidate in (
+            normalized,
+            normalized.replace("/", "-"),
+        ):
+            try:
+                parsed = datetime.fromisoformat(candidate)
+                break
+            except ValueError:
+                continue
+        if parsed is None:
+            for pattern in ("%Y%m%d", "%Y-%m-%d", "%Y/%m/%d"):
+                try:
+                    parsed = datetime.strptime(text, pattern)
+                    break
+                except ValueError:
+                    continue
+        if parsed is None:
+            return None
+    if parsed.tzinfo is None:
+        return parsed.replace(tzinfo=timezone)
+    return parsed.astimezone(timezone)
+
+
+def normalize_duration_seconds(value: Any, *, unit: str = "seconds") -> Decimal | None:
+    if value in (None, "") or isinstance(value, bool):
+        return None
+    try:
+        number = Decimal(str(value))
+    except (ArithmeticError, ValueError):
+        return None
+    if unit == "milliseconds":
+        number /= Decimal("1000")
+    if number < 0:
+        return None
+    return number.quantize(Decimal("0.001"))
+
+
+def _candidate_text(candidate: Mapping[str, Any]) -> str:
+    fragments = [str(candidate.get("title") or "")]
+    tags = candidate.get("tags")
+    if tags is None:
+        tags = candidate.get("tags_json")
+    if isinstance(tags, str):
+        try:
+            loaded = json.loads(tags)
+        except (TypeError, ValueError):
+            loaded = tags
+        tags = loaded
+    if isinstance(tags, list):
+        fragments.extend(str(item) for item in tags)
+    elif tags:
+        fragments.append(str(tags))
+    evidence = candidate.get("temporal_evidence")
+    if evidence is None:
+        evidence = candidate.get("temporal_evidence_json")
+    if evidence:
+        fragments.append(str(evidence))
+    return " ".join(fragments)
+
+
+def _time_in_window(current: time, start: time, end: time) -> bool:
+    if start <= end:
+        return start <= current <= end
+    return current >= start or current <= end
+
+
+def _festival_dates(year: int) -> dict[str, date]:
+    result = {
+        name: date(year, month, day)
+        for name, (month, day) in _FIXED_FESTIVALS.items()
+    }
+    for name, (month, day) in _LUNAR_FESTIVALS.get(year, {}).items():
+        result[name] = date(year, month, day)
+    return result
+
+
+def _nearest_festival_date(name: str, current: date) -> date | None:
+    candidates = [
+        festival_date
+        for year in (current.year - 1, current.year, current.year + 1)
+        if (festival_date := _festival_dates(year).get(name)) is not None
+    ]
+    if not candidates:
+        return None
+    return min(candidates, key=lambda value: abs((value - current).days))
+
+
+def evaluate_temporal_status(
+    candidate: Mapping[str, Any],
+    rule_snapshot: Mapping[str, Any] | str | None,
+) -> dict[str, Any]:
+    rules = load_rule_snapshot(rule_snapshot)
+    timezone_name = str(rules["timezone"])
+    timezone = ZoneInfo(timezone_name)
+    current = parse_datetime_value(
+        rules.get("current_datetime"),
+        timezone_name=timezone_name,
+    ) or datetime.now(timezone)
+    publish_at = parse_datetime_value(
+        candidate.get("publish_at"),
+        timezone_name=timezone_name,
+    )
+    text = _candidate_text(candidate)
+    inferred_type = str(candidate.get("temporal_type") or "").strip() or "evergreen"
+    evidence: dict[str, Any] = {
+        "evaluated_at": current.isoformat(timespec="seconds"),
+        "publish_at": publish_at.isoformat(timespec="seconds") if publish_at else None,
+        "matched_terms": [],
+    }
+
+    if publish_at is None:
+        return {
+            "temporal_type": inferred_type,
+            "status": "unknown",
+            "reason_code": "TEMPORAL_UNKNOWN",
+            "reason": "缺少真实发布时间,无法完成时效校验",
+            "evidence": evidence,
+        }
+
+    for daypart, terms, start, end in _DAYPART_RULES:
+        matched = [term for term in terms if term in text]
+        if not matched:
+            continue
+        inferred_type = "daypart"
+        evidence["matched_terms"].extend(matched)
+        evidence["valid_time"] = (
+            f"{start.strftime('%H:%M')}-{end.strftime('%H:%M')}"
+        )
+        if not _time_in_window(current.timetz().replace(tzinfo=None), start, end):
+            return {
+                "temporal_type": inferred_type,
+                "status": "fail",
+                "reason_code": "DAYPART_EXPIRED",
+                "reason": f"当前时间不在{daypart}内容有效时段",
+                "evidence": evidence,
+            }
+
+    matched_festivals: list[tuple[str, date]] = []
+    known_names = set(_FIXED_FESTIVALS)
+    for yearly in _LUNAR_FESTIVALS.values():
+        known_names.update(yearly)
+    for name in sorted(known_names, key=len, reverse=True):
+        if name not in text:
+            continue
+        festival_date = _nearest_festival_date(name, current.date())
+        if festival_date is None:
+            return {
+                "temporal_type": "festival",
+                "status": "unknown",
+                "reason_code": "TEMPORAL_UNKNOWN",
+                "reason": f"规则版本未覆盖当前相邻年份的{name}日期",
+                "evidence": {**evidence, "matched_terms": [name]},
+            }
+        matched_festivals.append((name, festival_date))
+        break
+    if matched_festivals:
+        inferred_type = "festival"
+        name, festival_date = matched_festivals[0]
+        lead_days = int(rules["festival_lead_days"])
+        valid_from = festival_date - timedelta(days=lead_days)
+        valid_to = festival_date + timedelta(days=1)
+        evidence.update(
+            {
+                "matched_terms": [name],
+                "valid_from": valid_from.isoformat(),
+                "valid_to": valid_to.isoformat(),
+            }
+        )
+        if not valid_from <= current.date() <= valid_to:
+            return {
+                "temporal_type": inferred_type,
+                "status": "fail",
+                "reason_code": "FESTIVAL_OUT_OF_WINDOW",
+                "reason": f"{name}内容不在当前有效窗口",
+                "evidence": evidence,
+            }
+
+    matched_relative = [term for term in _RELATIVE_DATE_MARKERS if term in text]
+    if matched_relative and publish_at.date() != current.date():
+        evidence["matched_terms"].extend(matched_relative)
+        return {
+            "temporal_type": "event",
+            "status": "fail",
+            "reason_code": "RELATIVE_DATE_EXPIRED",
+            "reason": "内容包含相对日期表述,但并非当天发布",
+            "evidence": evidence,
+        }
+
+    if any(marker in text for marker in _EVENT_MARKERS):
+        inferred_type = "event"
+    age_days = max(0.0, (current - publish_at).total_seconds() / 86400)
+    evidence["content_age_days"] = round(age_days, 3)
+    if inferred_type == "event" and age_days > int(rules["event_max_age_days"]):
+        return {
+            "temporal_type": inferred_type,
+            "status": "fail",
+            "reason_code": "EVENT_EXPIRED",
+            "reason": "事件型内容超过允许的新鲜度窗口",
+            "evidence": evidence,
+        }
+    if (
+        inferred_type == "seasonal"
+        and age_days > int(rules["seasonal_max_age_days"])
+    ):
+        return {
+            "temporal_type": inferred_type,
+            "status": "fail",
+            "reason_code": "SEASONAL_EXPIRED",
+            "reason": "季节型内容超过允许的新鲜度窗口",
+            "evidence": evidence,
+        }
+
+    explicit_status = str(candidate.get("temporal_status") or "").strip()
+    if explicit_status in {"fail", "unknown"}:
+        reason_code = (
+            "TEMPORAL_EXPIRED" if explicit_status == "fail" else "TEMPORAL_UNKNOWN"
+        )
+        return {
+            "temporal_type": inferred_type,
+            "status": explicit_status,
+            "reason_code": reason_code,
+            "reason": "候选补证结果未通过时间有效性判断",
+            "evidence": evidence,
+        }
+    return {
+        "temporal_type": inferred_type,
+        "status": "pass",
+        "reason_code": None,
+        "reason": "发布时间与基础时间语义有效",
+        "evidence": evidence,
+    }
+
+
+def _number(value: Any) -> float | None:
+    if value in (None, "") or isinstance(value, bool):
+        return None
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def evaluate_candidate_gate(
+    candidate: Mapping[str, Any],
+    rule_snapshot: Mapping[str, Any] | str | None,
+) -> dict[str, Any]:
+    rules = load_rule_snapshot(rule_snapshot)
+    temporal = evaluate_temporal_status(candidate, rules)
+    duration = _number(candidate.get("duration_seconds"))
+    shares = _number(candidate.get("share_count"))
+    content_ratio = _number(candidate.get("content_50_plus_ratio"))
+    account_ratio = _number(candidate.get("account_50_plus_ratio"))
+
+    checks: list[dict[str, Any]] = [
+        {
+            "name": "temporal",
+            "status": temporal["status"],
+            "reason_code": temporal["reason_code"],
+            "actual": temporal["evidence"],
+        }
+    ]
+
+    def threshold_check(
+        name: str,
+        actual: float | None,
+        threshold: float,
+        *,
+        missing_code: str,
+        low_code: str,
+    ) -> None:
+        if actual is None:
+            status = "fail"
+            reason_code = missing_code
+        elif actual < threshold:
+            status = "fail"
+            reason_code = low_code
+        else:
+            status = "pass"
+            reason_code = None
+        checks.append(
+            {
+                "name": name,
+                "status": status,
+                "reason_code": reason_code,
+                "actual": actual,
+                "threshold": threshold,
+            }
+        )
+
+    threshold_check(
+        "duration_seconds",
+        duration,
+        float(rules["min_duration_seconds"]),
+        missing_code="DURATION_UNKNOWN",
+        low_code="DURATION_TOO_SHORT",
+    )
+    threshold_check(
+        "share_count",
+        shares,
+        float(rules["min_share_count"]),
+        missing_code="SHARE_COUNT_UNKNOWN",
+        low_code="SHARE_COUNT_TOO_LOW",
+    )
+    threshold_check(
+        "content_50_plus_ratio",
+        content_ratio,
+        float(rules["min_content_50_plus_ratio"]),
+        missing_code="CONTENT_PORTRAIT_MISSING",
+        low_code="CONTENT_50_PLUS_TOO_LOW",
+    )
+
+    failed_codes = [
+        str(check["reason_code"])
+        for check in checks
+        if check["status"] != "pass" and check.get("reason_code")
+    ]
+    content_status = (
+        "missing"
+        if content_ratio is None
+        else (
+            "pass"
+            if content_ratio >= float(rules["min_content_50_plus_ratio"])
+            else "fail"
+        )
+    )
+    account_status = (
+        "missing"
+        if account_ratio is None
+        else (
+            "pass"
+            if account_ratio >= float(rules["min_account_50_plus_ratio"])
+            else "fail"
+        )
+    )
+    portrait_conflict = (
+        content_status in {"pass", "fail"}
+        and account_status in {"pass", "fail"}
+        and content_status != account_status
+    )
+    return {
+        "rule_version": str(rules["rule_version"]),
+        "status": "pass" if not failed_codes else "fail",
+        "primary_eligible": not failed_codes,
+        "failed_reason_codes": failed_codes,
+        "checks": checks,
+        "temporal": temporal,
+        "content_portrait_status": content_status,
+        "account_portrait_status": account_status,
+        "portrait_conflict": portrait_conflict,
+    }

+ 1 - 0
tests/api/__init__.py

@@ -0,0 +1 @@
+"""API tests."""

+ 172 - 0
tests/api/test_demand_feedback.py

@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+from datetime import datetime
+from types import SimpleNamespace
+
+import pytest
+from pydantic import ValidationError
+
+from api.schemas.demand_feedback import CreateDemandFeedbackBody
+from api.services import demand_feedback as feedback_service
+from supply_infra.db.models.demand_feedback import DemandFeedback
+
+
+def _body(**changes) -> CreateDemandFeedbackBody:
+    values = {
+        "client_request_id": "request-123",
+        "target_type": "demand",
+        "demand_grade_id": 10,
+        "feedback_action": "support",
+    }
+    values.update(changes)
+    return CreateDemandFeedbackBody(**values)
+
+
+def test_feedback_body_validates_target_fields() -> None:
+    with pytest.raises(ValidationError):
+        _body(target_type="video")
+    with pytest.raises(ValidationError):
+        _body(
+            target_type="hit_content",
+            video_id="vid-1",
+        )
+    with pytest.raises(ValidationError):
+        _body(video_id="vid-1")
+
+    body = _body(target_type="video", video_id="vid-1")
+    assert body.video_id == "vid-1"
+
+
+def test_feedback_body_validates_action_content_and_reason() -> None:
+    with pytest.raises(ValidationError):
+        _body(feedback_action="oppose")
+    with pytest.raises(ValidationError):
+        _body(feedback_action="correct")
+    with pytest.raises(ValidationError):
+        _body(reason_code="irrelevant")
+
+    body = _body(
+        feedback_action="correct",
+        reason_code="wrong_reason",
+        content="  正确原因应引用后验样本。  ",
+    )
+    assert body.content == "正确原因应引用后验样本。"
+
+
+def test_feedback_model_has_person_but_no_review_or_boolean_state() -> None:
+    columns = DemandFeedback.__table__.columns
+    assert "feedback_user_id" in columns
+    assert "feedback_user_name_snapshot" in columns
+    assert "feedback_username_snapshot" in columns
+    assert "review_status" not in columns
+    assert "has_feedback" not in columns
+    assert "is_feedback" not in columns
+
+
+def test_create_feedback_uses_server_target_and_current_user(monkeypatch) -> None:
+    grade = SimpleNamespace(
+        id=10,
+        biz_dt="20260730",
+        demand_name="老年人智能手机使用",
+        grade="A",
+        reason="需求有明确内容意图",
+        video_list=None,
+    )
+    expansion = SimpleNamespace(
+        id=30,
+        biz_dt="20260730",
+        source_demand_grade_id=10,
+        video_id="vid-1",
+        point_type="purpose",
+        expanded_text="降低老人使用智能设备的门槛",
+        point_desc="解释基础操作",
+        reason="覆盖当前需求",
+    )
+    detail = SimpleNamespace(title="教父母使用手机")
+    saved: list[DemandFeedback] = []
+
+    class Session:
+        def refresh(self, row) -> None:
+            row.id = 99
+            row.created_at = datetime(2026, 7, 30, 15, 20)
+
+        def rollback(self) -> None:
+            raise AssertionError("unexpected rollback")
+
+    class SessionContext:
+        def __enter__(self):
+            return Session()
+
+        def __exit__(self, *_args):
+            return False
+
+    class GradeRepo:
+        def __init__(self, _session):
+            pass
+
+        def get_by_id(self, grade_id):
+            return grade if grade_id == 10 else None
+
+    class ExpansionRepo:
+        def __init__(self, _session):
+            pass
+
+        def has_video(self, **kwargs):
+            return kwargs["video_id"] == "vid-1"
+
+        def get_active_by_id(self, expansion_id):
+            return expansion if expansion_id == 30 else None
+
+    class DetailRepo:
+        def __init__(self, _session):
+            pass
+
+        def list_by_vids(self, _video_ids):
+            return {"vid-1": detail}
+
+    class FeedbackRepo:
+        def __init__(self, _session):
+            pass
+
+        def get_by_client_request_id(self, _request_id):
+            return None
+
+        def add(self, row):
+            saved.append(row)
+            return row
+
+        def list_for_target(self, **_kwargs):
+            return saved, len(saved)
+
+    monkeypatch.setattr(feedback_service, "get_session", lambda: SessionContext())
+    monkeypatch.setattr(feedback_service, "DemandGradeRepository", GradeRepo)
+    monkeypatch.setattr(feedback_service, "DemandVideoExpansionRepository", ExpansionRepo)
+    monkeypatch.setattr(feedback_service, "MultiDemandVideoDetailRepository", DetailRepo)
+    monkeypatch.setattr(feedback_service, "DemandFeedbackRepository", FeedbackRepo)
+
+    result = feedback_service.create_demand_feedback(
+        _body(
+            target_type="hit_content",
+            video_id="vid-1",
+            demand_video_expansion_id=30,
+            feedback_action="oppose",
+            reason_code="not_hit",
+            content="没有覆盖老人学习使用的需求。",
+        ),
+        {
+            "id": 7,
+            "username": "zhangsan",
+            "display_name": "张三",
+        },
+    )
+
+    assert result["feedback_summary"] == {"count": 1}
+    assert result["item"]["feedback_user"] == {
+        "id": 7,
+        "username": "zhangsan",
+        "display_name": "张三",
+    }
+    assert result["item"]["target_snapshot"]["expanded_text"] == "降低老人使用智能设备的门槛"
+    assert saved[0].feedback_user_id == 7
+    assert saved[0].feedback_user_name_snapshot == "张三"
+    assert saved[0].feedback_username_snapshot == "zhangsan"

+ 239 - 0
tests/api/test_video_discovery_records.py

@@ -0,0 +1,239 @@
+from __future__ import annotations
+
+import json
+from collections.abc import Generator
+from contextlib import contextmanager
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from api.services import video_discovery_records as records_service
+from supply_infra.db.models.video_discovery import (
+    VideoDiscoveryCandidate,
+    VideoDiscoveryRun,
+    VideoDiscoverySearch,
+)
+
+
+def _patch_sessions(monkeypatch) -> sessionmaker[Session]:
+    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)
+
+    @contextmanager
+    def get_test_session() -> Generator[Session, None, None]:
+        session = factory()
+        try:
+            yield session
+            session.commit()
+        except Exception:
+            session.rollback()
+            raise
+        finally:
+            session.close()
+
+    monkeypatch.setattr(records_service, "get_session", get_test_session)
+    return factory
+
+
+def _seed(factory: sessionmaker[Session]) -> None:
+    now = datetime(2026, 7, 30, 12, 30)
+    with factory.begin() as session:
+        session.add_all(
+            [
+                VideoDiscoveryRun(
+                    id=1,
+                    run_id="find-001",
+                    biz_dt="20260730",
+                    demand_grade_id=10,
+                    demand_word="老年人智能手机教程",
+                    seed_video_id="seed-1",
+                    seed_video_title="手机使用入门",
+                    relevant_points_json=json.dumps([{"point": "大字模式"}]),
+                    intent_summary="寻找步骤清楚、面向老年人的手机教程。",
+                    status="finished",
+                    search_count=0,
+                    primary_count=0,
+                    create_time=now,
+                    update_time=now,
+                ),
+                VideoDiscoveryRun(
+                    id=2,
+                    run_id="find-002",
+                    biz_dt="20260729",
+                    demand_grade_id=11,
+                    demand_word="退休生活",
+                    relevant_points_json="[]",
+                    status="failed",
+                    search_count=0,
+                    primary_count=0,
+                    create_time=now,
+                    update_time=now,
+                ),
+            ]
+        )
+        session.add(
+            VideoDiscoverySearch(
+                id=101,
+                run_id="find-001",
+                search_key="key-1",
+                keyword="老年人 手机 教程",
+                query_reason="验证教程内容",
+                source_type="demand",
+                provider="internal_keyword",
+                provider_state_json='{"cursor":"next"}',
+                content_type="视频",
+                sort_type="综合排序",
+                publish_time="不限",
+                cursor="0",
+                page_no=1,
+                results_count=2,
+                new_candidate_count=2,
+                has_more=1,
+                result_ids_json='["aweme-1","aweme-2"]',
+                status="success",
+                create_time=now,
+                update_time=now,
+            )
+        )
+        session.add_all(
+            [
+                VideoDiscoveryCandidate(
+                    id=1001,
+                    run_id="find-001",
+                    search_id=101,
+                    aweme_id="aweme-1",
+                    title="教爸妈设置大字体",
+                    author_name="数字生活助手",
+                    tags_json='["手机教程"]',
+                    relevance_score=Decimal("0.91"),
+                    elder_score=Decimal("0.88"),
+                    share_score=Decimal("0.70"),
+                    value_score=Decimal("0.82"),
+                    decision_bucket="primary",
+                    create_time=now,
+                    update_time=now,
+                ),
+                VideoDiscoveryCandidate(
+                    id=1002,
+                    run_id="find-001",
+                    search_id=101,
+                    aweme_id="aweme-2",
+                    title="手机发布会",
+                    decision_bucket="rejected",
+                    create_time=now,
+                    update_time=now,
+                ),
+            ]
+        )
+
+
+def test_lists_runs_with_live_relation_counts(monkeypatch) -> None:
+    factory = _patch_sessions(monkeypatch)
+    _seed(factory)
+
+    response = records_service.list_video_discovery_runs(
+        biz_dt="20260730",
+        keyword="智能手机",
+        limit=20,
+        offset=0,
+    )
+
+    assert response["total"] == 1
+    run = response["items"][0]
+    assert run["run_id"] == "find-001"
+    assert run["search_count"] == 1
+    assert run["candidate_count"] == 2
+    assert run["primary_count"] == 1
+    assert run["rejected_count"] == 1
+    assert run["relevant_points"] == [{"point": "大字模式"}]
+
+
+def test_hides_runs_before_visible_date(monkeypatch) -> None:
+    factory = _patch_sessions(monkeypatch)
+    _seed(factory)
+
+    response = records_service.list_video_discovery_runs(limit=20, offset=0)
+
+    assert response["total"] == 1
+    assert [item["run_id"] for item in response["items"]] == ["find-001"]
+    assert records_service.get_video_discovery_run("find-002") is None
+    assert (
+        records_service.list_video_discovery_searches(
+            "find-002",
+            limit=20,
+            offset=0,
+        )
+        is None
+    )
+    assert (
+        records_service.list_video_discovery_candidates(
+            "find-002",
+            limit=20,
+            offset=0,
+        )
+        is None
+    )
+
+
+def test_lists_searches_and_candidates_with_filters(monkeypatch) -> None:
+    factory = _patch_sessions(monkeypatch)
+    _seed(factory)
+
+    searches = records_service.list_video_discovery_searches(
+        "find-001",
+        keyword="教程",
+        limit=20,
+        offset=0,
+    )
+    assert searches is not None
+    assert searches["total"] == 1
+    assert searches["items"][0]["provider_state"] == {"cursor": "next"}
+    assert searches["items"][0]["result_ids"] == ["aweme-1", "aweme-2"]
+    assert [item["aweme_id"] for item in searches["items"][0]["candidates"]] == [
+        "aweme-1",
+        "aweme-2",
+    ]
+    assert searches["items"][0]["candidates"][0]["title"] == "教爸妈设置大字体"
+    assert searches["items"][0]["candidates"][0]["comment_count"] is None
+    assert searches["items"][0]["candidates"][0]["collect_count"] is None
+    assert searches["items"][0]["candidates"][0]["share_count"] is None
+
+    candidates = records_service.list_video_discovery_candidates(
+        "find-001",
+        bucket="primary",
+        keyword="数字生活",
+        limit=20,
+        offset=0,
+    )
+    assert candidates is not None
+    assert candidates["total"] == 1
+    assert candidates["items"][0]["id"] == 1001
+    assert candidates["items"][0]["tags"] == ["手机教程"]
+    assert candidates["items"][0]["relevance_score"] == 0.91
+
+
+def test_record_children_return_none_for_missing_run(monkeypatch) -> None:
+    _patch_sessions(monkeypatch)
+
+    assert records_service.get_video_discovery_run("missing") is None
+    assert (
+        records_service.list_video_discovery_searches(
+            "missing",
+            limit=20,
+            offset=0,
+        )
+        is None
+    )
+    assert (
+        records_service.list_video_discovery_candidates(
+            "missing",
+            limit=20,
+            offset=0,
+        )
+        is None
+    )

+ 0 - 0
tests/supply_agent/__init__.py


+ 229 - 0
tests/supply_agent/test_agent_loop.py

@@ -0,0 +1,229 @@
+"""AgentLoop shared-path behavior tests."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from supply_agent.agent.loop import AgentLoop
+from supply_agent.tools.base import tool
+from supply_agent.tools.registry import ToolRegistry
+from supply_agent.types import (
+    AgentEventType,
+    Message,
+    Role,
+    ToolCall,
+)
+
+
+class _FakeLLM:
+    """Deterministic LLM that returns scripted assistant messages."""
+
+    def __init__(self, responses: list[Message]) -> None:
+        self._responses = list(responses)
+        self.calls: list[dict[str, Any]] = []
+
+    def chat(
+        self,
+        messages: list[Message],
+        tools: list[Any] | None = None,
+        temperature: float | None = None,
+        *,
+        iteration: int = 0,
+    ) -> Message:
+        self.calls.append(
+            {
+                "messages": list(messages),
+                "tools": tools,
+                "iteration": iteration,
+            }
+        )
+        if not self._responses:
+            raise AssertionError("FakeLLM: no more scripted responses")
+        return self._responses.pop(0)
+
+    async def achat(
+        self,
+        messages: list[Message],
+        tools: list[Any] | None = None,
+        temperature: float | None = None,
+        *,
+        iteration: int = 0,
+    ) -> Message:
+        return self.chat(
+            messages,
+            tools=tools,
+            temperature=temperature,
+            iteration=iteration,
+        )
+
+
+def _system() -> Message:
+    return Message(role=Role.SYSTEM, content="base system")
+
+
+def _assistant_text(content: str) -> Message:
+    return Message(role=Role.ASSISTANT, content=content)
+
+
+def _assistant_tools(*calls: ToolCall) -> Message:
+    return Message(role=Role.ASSISTANT, content=None, tool_calls=list(calls))
+
+
+@pytest.fixture
+def skill_tools() -> ToolRegistry:
+    registry = ToolRegistry()
+
+    @tool(name="load_skill")
+    def load_skill(name: str) -> str:
+        return f"skill-body:{name}"
+
+    registry.register(load_skill, name="load_skill")
+    return registry
+
+
+def test_stream_loads_skill_into_system_message(skill_tools: ToolRegistry) -> None:
+    active_skills: list[str] = []
+    system_parts = {"value": "base system"}
+
+    def builder() -> Message:
+        parts = [system_parts["value"]]
+        for name in active_skills:
+            parts.append(f"LOADED:{name}")
+        return Message(role=Role.SYSTEM, content="\n\n".join(parts))
+
+    llm = _FakeLLM(
+        [
+            _assistant_tools(
+                ToolCall(
+                    id="c1",
+                    name="load_skill",
+                    arguments='{"name": "demo"}',
+                )
+            ),
+            _assistant_text("done"),
+        ]
+    )
+    loop = AgentLoop(
+        llm=llm,  # type: ignore[arg-type]
+        tools=skill_tools,
+        system_message=builder(),
+        system_message_builder=builder,
+        messages=[Message(role=Role.USER, content="hi")],
+        max_iterations=5,
+        active_skills=active_skills,
+    )
+
+    events = list(loop.stream())
+    assert any(e.type == AgentEventType.DONE for e in events)
+    assert "demo" in active_skills
+    # Second LLM call must see refreshed system prompt with skill content.
+    assert "LOADED:demo" in (llm.calls[1]["messages"][0].content or "")
+
+
+def test_stream_nudges_best_answer_on_max_iterations() -> None:
+    registry = ToolRegistry()
+
+    @tool(name="noop")
+    def noop() -> str:
+        return '{"ok": true}'
+
+    registry.register(noop, name="noop")
+
+    llm = _FakeLLM(
+        [
+            _assistant_tools(ToolCall(id="c1", name="noop", arguments="{}")),
+            _assistant_text("final after nudge"),
+        ]
+    )
+    loop = AgentLoop(
+        llm=llm,  # type: ignore[arg-type]
+        tools=registry,
+        system_message=_system(),
+        messages=[Message(role=Role.USER, content="hi")],
+        max_iterations=1,
+    )
+
+    events = list(loop.stream())
+    done = next(e for e in events if e.type == AgentEventType.DONE)
+    assert done.data["content"] == "final after nudge"
+    assert len(llm.calls) == 2
+    assert "best answer" in (llm.calls[1]["messages"][-1].content or "").lower()
+
+
+def test_run_and_stream_share_max_iteration_nudge() -> None:
+    registry = ToolRegistry()
+
+    @tool(name="noop")
+    def noop() -> str:
+        return '{"ok": true}'
+
+    registry.register(noop, name="noop")
+
+    def make_loop(responses: list[Message]) -> AgentLoop:
+        return AgentLoop(
+            llm=_FakeLLM(responses),  # type: ignore[arg-type]
+            tools=registry,
+            system_message=_system(),
+            messages=[Message(role=Role.USER, content="hi")],
+            max_iterations=1,
+        )
+
+    run_result = make_loop(
+        [
+            _assistant_tools(ToolCall(id="c1", name="noop", arguments="{}")),
+            _assistant_text("from-run"),
+        ]
+    ).run()
+    stream_events = list(
+        make_loop(
+            [
+                _assistant_tools(ToolCall(id="c1", name="noop", arguments="{}")),
+                _assistant_text("from-stream"),
+            ]
+        ).stream()
+    )
+    stream_done = next(e for e in stream_events if e.type == AgentEventType.DONE)
+
+    assert run_result.content == "from-run"
+    assert stream_done.data["content"] == "from-stream"
+    assert run_result.iterations == stream_done.data["iterations"] == 1
+
+
+@pytest.mark.asyncio
+async def test_astream_loads_skill(skill_tools: ToolRegistry) -> None:
+    active_skills: list[str] = []
+
+    def builder() -> Message:
+        parts = ["base"]
+        for name in active_skills:
+            parts.append(f"LOADED:{name}")
+        return Message(role=Role.SYSTEM, content="\n\n".join(parts))
+
+    llm = _FakeLLM(
+        [
+            _assistant_tools(
+                ToolCall(
+                    id="c1",
+                    name="load_skill",
+                    arguments='{"name": "async-demo"}',
+                )
+            ),
+            _assistant_text("ok"),
+        ]
+    )
+    loop = AgentLoop(
+        llm=llm,  # type: ignore[arg-type]
+        tools=skill_tools,
+        system_message=builder(),
+        system_message_builder=builder,
+        messages=[Message(role=Role.USER, content="hi")],
+        max_iterations=5,
+        active_skills=active_skills,
+    )
+
+    events = [event async for event in loop.astream()]
+    assert any(e.type == AgentEventType.DONE for e in events)
+    assert "async-demo" in active_skills
+    assert "LOADED:async-demo" in (llm.calls[1]["messages"][0].content or "")

+ 205 - 0
tests/supply_agent/test_completion_guard.py

@@ -0,0 +1,205 @@
+"""Tests for the optional agent completion guard."""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import pytest
+
+from agents.find_agent.completion_guard import (
+    configure_find_agent_completion_guard,
+    create_find_completion_guard,
+)
+from supply_agent import Agent
+from supply_agent.agent.loop import AgentLoop
+from supply_agent.tools.registry import ToolRegistry
+from supply_agent.types import Message, Role
+
+
+class _FakeLLM:
+    def __init__(self, responses: list[Message]) -> None:
+        self.responses = list(responses)
+
+    def chat(self, *_args, **_kwargs) -> Message:
+        return self.responses.pop(0)
+
+    async def achat(self, *_args, **_kwargs) -> Message:
+        return self.responses.pop(0)
+
+
+def _loop(
+    responses: list[Message],
+    *,
+    max_iterations: int = 3,
+    completion_guard=None,
+) -> AgentLoop:
+    return AgentLoop(
+        llm=_FakeLLM(responses),  # type: ignore[arg-type]
+        tools=ToolRegistry(),
+        system_message=Message(role=Role.SYSTEM, content="system"),
+        messages=[Message(role=Role.USER, content="task")],
+        max_iterations=max_iterations,
+        completion_guard=completion_guard,
+    )
+
+
+def test_completion_guard_is_disabled_by_default() -> None:
+    agent = Agent()
+    loop = _loop([Message(role=Role.ASSISTANT, content="final")])
+
+    result = loop.run()
+
+    assert agent.completion_guard is None
+    assert result.content == "final"
+    assert result.iterations == 1
+
+
+def test_agent_forwards_configured_completion_guard_to_loop() -> None:
+    def guard(_response: Message, _messages: Sequence[Message]) -> None:
+        return None
+
+    agent = Agent(completion_guard=guard)
+
+    assert agent.completion_guard is guard
+    assert agent._create_loop([]).completion_guard is guard
+
+
+def test_completion_guard_feedback_continues_sync_loop() -> None:
+    calls = 0
+
+    def guard(_response: Message, messages: Sequence[Message]) -> str | None:
+        nonlocal calls
+        calls += 1
+        assert messages[-1].role == Role.ASSISTANT
+        return "run.status 仍为 running,请先更新为终态" if calls == 1 else None
+
+    loop = _loop(
+        [
+            Message(role=Role.ASSISTANT, content="尚未完成"),
+            Message(role=Role.ASSISTANT, content="最终结果"),
+        ],
+        completion_guard=guard,
+    )
+
+    result = loop.run()
+
+    assert result.content == "最终结果"
+    assert result.iterations == 2
+    assert result.messages[-2].content == "run.status 仍为 running,请先更新为终态"
+
+
+def test_completion_guard_feedback_continues_stream_loop() -> None:
+    calls = 0
+
+    def guard(_response: Message, _messages: Sequence[Message]) -> str | None:
+        nonlocal calls
+        calls += 1
+        return "not ready" if calls == 1 else None
+
+    loop = _loop(
+        [
+            Message(role=Role.ASSISTANT, content="premature"),
+            Message(role=Role.ASSISTANT, content="done"),
+        ],
+        completion_guard=guard,
+    )
+
+    events = list(loop.stream())
+
+    assert events[-1].data["content"] == "done"
+    assert events[-1].data["iterations"] == 2
+
+
+@pytest.mark.asyncio
+async def test_completion_guard_feedback_continues_async_loop() -> None:
+    calls = 0
+
+    def guard(_response: Message, _messages: Sequence[Message]) -> str | None:
+        nonlocal calls
+        calls += 1
+        return "not ready" if calls == 1 else None
+
+    loop = _loop(
+        [
+            Message(role=Role.ASSISTANT, content="premature"),
+            Message(role=Role.ASSISTANT, content="done"),
+        ],
+        completion_guard=guard,
+    )
+
+    result = await loop.arun()
+
+    assert result.content == "done"
+    assert result.iterations == 2
+
+
+def test_iteration_limit_takes_priority_over_completion_guard() -> None:
+    def guard(_response: Message, _messages: Sequence[Message]) -> str:
+        return "run.status 仍为 running"
+
+    loop = _loop(
+        [
+            Message(role=Role.ASSISTANT, content="premature"),
+            Message(role=Role.ASSISTANT, content="forced final"),
+        ],
+        max_iterations=1,
+        completion_guard=guard,
+    )
+
+    result = loop.run()
+
+    assert result.content == "forced final"
+    assert result.iterations == 1
+
+
+@pytest.mark.parametrize("status", ["finished", "failed"])
+def test_find_agent_completion_guard_accepts_terminal_status(
+    monkeypatch: pytest.MonkeyPatch,
+    status: str,
+) -> None:
+    class _Service:
+        @staticmethod
+        def lookup_run(_run_id: str) -> dict[str, str]:
+            return {"status": status}
+
+    monkeypatch.setattr(
+        "agents.find_agent.completion_guard.get_video_discovery_service",
+        lambda: _Service(),
+    )
+    guard = create_find_completion_guard("run-1")
+
+    assert guard(
+        Message(role=Role.ASSISTANT, content="final"),
+        (),
+    ) is None
+
+
+def test_find_agent_completion_guard_rejects_running_status(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    class _Service:
+        @staticmethod
+        def lookup_run(_run_id: str) -> dict[str, str]:
+            return {"status": "running"}
+
+    monkeypatch.setattr(
+        "agents.find_agent.completion_guard.get_video_discovery_service",
+        lambda: _Service(),
+    )
+    guard = create_find_completion_guard("run-1")
+
+    feedback = guard(
+        Message(role=Role.ASSISTANT, content="premature"),
+        (),
+    )
+
+    assert feedback is not None
+    assert "run.status 仍为 running" in feedback
+    assert "update_video_discovery_run_status" in feedback
+
+
+def test_configure_find_agent_completion_guard_only_when_missing() -> None:
+    agent = Agent()
+
+    configure_find_agent_completion_guard(agent, "scheduled-run")
+
+    assert agent.completion_guard is not None

+ 64 - 0
tests/supply_agent/test_publish_hook.py

@@ -0,0 +1,64 @@
+"""Publish hook stays free of supply_infra at import time."""
+
+from __future__ import annotations
+
+import importlib
+from pathlib import Path
+
+from supply_agent.logging.publish import (
+    get_run_artifact_publisher,
+    publish_run_artifacts,
+    set_run_artifact_publisher,
+)
+
+
+def test_publish_module_does_not_import_supply_infra() -> None:
+    source = importlib.util.find_spec("supply_agent.logging.publish")
+    assert source is not None and source.origin is not None
+    text = Path(source.origin).read_text(encoding="utf-8")
+    assert "import supply_infra" not in text
+    assert "from supply_infra" not in text
+
+
+def test_publish_is_noop_without_publisher() -> None:
+    previous = get_run_artifact_publisher()
+    try:
+        set_run_artifact_publisher(None)
+
+        class _Logger:
+            pass
+
+        assert publish_run_artifacts(_Logger()) is None  # type: ignore[arg-type]
+    finally:
+        set_run_artifact_publisher(previous)
+
+
+def test_set_run_artifact_publisher_is_invoked() -> None:
+    previous = get_run_artifact_publisher()
+    seen: list[object] = []
+
+    def _publisher(logger: object) -> str:
+        seen.append(logger)
+        return "https://example.com/log.html"
+
+    try:
+        set_run_artifact_publisher(_publisher)
+        marker = object()
+        assert publish_run_artifacts(marker) == "https://example.com/log.html"  # type: ignore[arg-type]
+        assert seen == [marker]
+    finally:
+        set_run_artifact_publisher(previous)
+
+
+def test_infra_registers_publisher_hook() -> None:
+    previous = get_run_artifact_publisher()
+    try:
+        set_run_artifact_publisher(None)
+        import supply_infra  # noqa: F401
+        from supply_infra.agent_logging.register import register_agent_logging_hooks
+        from supply_infra.agent_logging.publish import publish_run_artifacts_to_oss
+
+        register_agent_logging_hooks()
+        assert get_run_artifact_publisher() is publish_run_artifacts_to_oss
+    finally:
+        set_run_artifact_publisher(previous)

+ 44 - 0
tests/supply_agent/test_tool_errors.py

@@ -0,0 +1,44 @@
+"""Tests for tool error detection."""
+
+from __future__ import annotations
+
+from supply_agent.tools.base import tool
+from supply_agent.tools.errors import content_indicates_error
+from supply_agent.tools.registry import ToolRegistry
+
+
+def test_content_indicates_error_truthy_error_field() -> None:
+    assert content_indicates_error('{"error": "boom"}')
+    assert content_indicates_error('{\n  "error": "boom",\n  "title": "x"\n}')
+
+
+def test_content_indicates_error_ignores_null_or_missing() -> None:
+    assert not content_indicates_error('{"error": null, "ok": true}')
+    assert not content_indicates_error('{"error": "", "ok": true}')
+    assert not content_indicates_error('{"ok": true}')
+    assert not content_indicates_error("plain text success")
+    assert not content_indicates_error('["not", "an", "object"]')
+
+
+def test_content_indicates_error_does_not_use_prefix_heuristic() -> None:
+    # Would be true under startswith('{"error"'), but is a success payload.
+    assert not content_indicates_error('{"error_code": 0, "message": "ok"}')
+
+
+def test_registry_execute_sets_is_error_from_json() -> None:
+    registry = ToolRegistry()
+
+    @tool(name="fail")
+    def fail() -> str:
+        return '{\n  "error": "bad args",\n  "input_error": true\n}'
+
+    @tool(name="ok")
+    def ok() -> str:
+        return '{"error": null, "items": []}'
+
+    registry.register(fail, name="fail")
+    registry.register(ok, name="ok")
+
+    assert registry.execute("1", "fail", "{}").is_error is True
+    assert registry.execute("2", "ok", "{}").is_error is False
+    assert registry.execute("3", "missing", "{}").is_error is True

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

@@ -1,12 +1,15 @@
 from __future__ import annotations
 
 import asyncio
+import importlib
 import json
 from contextlib import contextmanager
+from datetime import datetime
+from decimal import Decimal
 from unittest.mock import patch
 
 import pytest
-from sqlalchemy import create_engine
+from sqlalchemy import create_engine, event, select
 from sqlalchemy.orm import Session, sessionmaker
 
 from agents.find_agent import create_find_agent
@@ -15,12 +18,29 @@ from agents.find_agent.demand_run import (
     FindDemandContext,
     FindDemandPoint,
     FindDemandVideo,
+    build_find_agent_user_input,
     prepare_video_discovery_run,
 )
 from agents.find_agent.tools import video_discovery_store
+from agents.find_agent.tools.batch_search_and_record import batch_search_and_record
+from agents.find_agent.support.search_persistence import _candidate_from_search_result
+from agents.find_agent.support.douyin_search import (
+    DEFAULT_MIN_DURATION_SECONDS as INTERNAL_SEARCH_MIN_DURATION,
+)
+from agents.find_agent.support.douyin_search_tikhub import (
+    DEFAULT_MIN_DURATION_SECONDS as TIKHUB_SEARCH_MIN_DURATION,
+)
+from agents.find_agent.support.douyin_user_videos import (
+    DEFAULT_MIN_DURATION_SECONDS as AUTHOR_SEARCH_MIN_DURATION,
+)
+from supply_infra.video_discovery_gates import evaluate_candidate_gate
 from supply_infra.db.models.video_discovery import (
     VideoDiscoveryCandidate,
     VideoDiscoveryRun,
+    VideoDiscoverySearch,
+)
+from supply_infra.db.repositories.video_discovery_repo import (
+    VideoDiscoveryRepository,
 )
 from supply_infra.scheduler.jobs.discover_videos_from_demands import (
     discover_videos_from_demands,
@@ -205,17 +225,465 @@ def test_video_discovery_models_exclude_unused_columns() -> None:
         "video_url",
         "content_analysis",
         "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)
+    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:
     agent = create_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()
 
 
+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
+    assert "current_datetime:" in user_input
+    assert "timezone:Asia/Shanghai" in user_input
+    assert "quality_gate_rules:" in user_input
+
+
+_P0_RULES = {
+    "rule_version": "test-p0",
+    "timezone": "Asia/Shanghai",
+    "current_datetime": "2026-07-31T12:00:00+08:00",
+    "current_date": "2026-07-31",
+    "min_duration_seconds": 30,
+    "min_share_count": 1000,
+    "min_content_50_plus_ratio": 0.20,
+    "min_account_50_plus_ratio": 0.20,
+    "festival_lead_days": 7,
+    "event_max_age_days": 7,
+    "seasonal_max_age_days": 180,
+}
+
+
+def _p0_candidate(**overrides):
+    candidate = {
+        "title": "适合家庭分享的生活技巧",
+        "publish_at": "2026-07-31T09:00:00+08:00",
+        "duration_seconds": 30,
+        "share_count": 1000,
+        "content_50_plus_ratio": 0.20,
+        "account_50_plus_ratio": 0.30,
+    }
+    candidate.update(overrides)
+    return candidate
+
+
+def test_p0_gate_enforces_boundaries_and_time_context() -> None:
+    assert evaluate_candidate_gate(_p0_candidate(), _P0_RULES)["primary_eligible"]
+
+    short = evaluate_candidate_gate(
+        _p0_candidate(duration_seconds=Decimal("29.999")),
+        _P0_RULES,
+    )
+    assert "DURATION_TOO_SHORT" in short["failed_reason_codes"]
+
+    low_share = evaluate_candidate_gate(_p0_candidate(share_count=999), _P0_RULES)
+    assert "SHARE_COUNT_TOO_LOW" in low_share["failed_reason_codes"]
+
+    low_elder = evaluate_candidate_gate(
+        _p0_candidate(content_50_plus_ratio=0.199),
+        _P0_RULES,
+    )
+    assert "CONTENT_50_PLUS_TOO_LOW" in low_elder["failed_reason_codes"]
+
+    morning = evaluate_candidate_gate(
+        _p0_candidate(title="早上好,送给家人的祝福"),
+        _P0_RULES,
+    )
+    assert "DAYPART_EXPIRED" in morning["failed_reason_codes"]
+
+    festival = evaluate_candidate_gate(
+        _p0_candidate(title="春节祝福送给全家"),
+        _P0_RULES,
+    )
+    assert "FESTIVAL_OUT_OF_WINDOW" in festival["failed_reason_codes"]
+
+
+def test_p0_gate_keeps_content_and_account_portraits_separate() -> None:
+    conflict = evaluate_candidate_gate(
+        _p0_candidate(
+            content_50_plus_ratio=0.28,
+            account_50_plus_ratio=0.08,
+        ),
+        _P0_RULES,
+    )
+    assert conflict["content_portrait_status"] == "pass"
+    assert conflict["account_portrait_status"] == "fail"
+    assert conflict["portrait_conflict"] is True
+    assert conflict["primary_eligible"] is True
+
+    account_only = evaluate_candidate_gate(
+        _p0_candidate(
+            content_50_plus_ratio=None,
+            account_50_plus_ratio=0.55,
+        ),
+        _P0_RULES,
+    )
+    assert "CONTENT_PORTRAIT_MISSING" in account_only["failed_reason_codes"]
+
+
+def test_search_candidate_persists_publish_time_duration_and_shares() -> None:
+    candidate = _candidate_from_search_result(
+        {
+            "aweme_id": "video-p0",
+            "desc": "测试视频",
+            "duration_ms": 65000,
+            "publish_at": "2026-07-31T08:30:00+08:00",
+            "statistics": {"share_count": 45},
+        },
+        "测试关键词",
+    )
+    assert candidate is not None
+    assert candidate["duration_seconds"] == Decimal("65.000")
+    assert candidate["publish_at"] == datetime(2026, 7, 31, 8, 30)
+    assert candidate["share_count"] == 45
+
+
+def test_all_search_sources_default_to_thirty_seconds() -> None:
+    assert INTERNAL_SEARCH_MIN_DURATION == 30
+    assert TIKHUB_SEARCH_MIN_DURATION == 30
+    assert AUTHOR_SEARCH_MIN_DURATION == 30
+
+
+def test_repository_rejects_primary_that_fails_p0_gate() -> None:
+    factory = _expire_on_commit_session_factory()
+    with factory() as session:
+        session.add(
+            VideoDiscoveryRun(
+                id=1,
+                run_id="p0-gate-run",
+                demand_word="生活技巧",
+                relevant_points_json="[]",
+                status="running",
+                rule_version="test-p0",
+                rule_config_json=json.dumps(_P0_RULES, ensure_ascii=False),
+            )
+        )
+        session.add(
+            VideoDiscoveryCandidate(
+                id=2,
+                run_id="p0-gate-run",
+                aweme_id="video-p0",
+                title="适合家庭分享的生活技巧",
+                publish_at=datetime(2026, 7, 31, 9, 0),
+                duration_seconds=Decimal("30.000"),
+                share_count=999,
+                content_50_plus_ratio=Decimal("0.280000"),
+                account_50_plus_ratio=Decimal("0.080000"),
+                decision_bucket="pending_evaluation",
+            )
+        )
+        session.commit()
+
+    with factory() as session:
+        repo = VideoDiscoveryRepository(session)
+        with pytest.raises(ValueError, match="SHARE_COUNT_TOO_LOW"):
+            repo.update_candidates(
+                "p0-gate-run",
+                [{"candidate_id": 2, "decision_bucket": "primary"}],
+            )
+
+
+@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(
     factory: sessionmaker[Session],
     *,
@@ -377,6 +845,7 @@ async def test_find_agent_timeout_closes_async_client() -> None:
         await arun_find_agent(
             agent,  # type: ignore[arg-type]
             "test",
+            run_id="test-run",
             timeout_seconds=0.01,
         )
 

Vissa filer visades inte eftersom för många filer har ändrats