Kaynağa Gözat

可视化页面修改和寻找Agent

xueyiming 1 hafta önce
ebeveyn
işleme
388dc0b9c0
58 değiştirilmiş dosya ile 8046 ekleme ve 142 silme
  1. 6 1
      .env.example
  2. 13 2
      Dockerfile
  3. 1 1
      agents/README.md
  4. 842 0
      agents/find_agent/PRD.md
  5. 119 0
      agents/find_agent/README.md
  6. 105 0
      agents/find_agent/VALIDATION.md
  7. 33 3
      agents/find_agent/__init__.py
  8. 252 11
      agents/find_agent/agent.py
  9. 72 0
      agents/find_agent/async_runner.py
  10. 400 0
      agents/find_agent/demand_run.py
  11. 119 0
      agents/find_agent/output_sync.py
  12. 237 0
      agents/find_agent/prompt/system_prompt.md
  13. 2 2
      agents/find_agent/run.py
  14. 25 0
      agents/find_agent/tools/__init__.py
  15. 418 0
      agents/find_agent/tools/decision_support.py
  16. 16 2
      agents/find_agent/tools/douyin_detail.py
  17. 405 0
      agents/find_agent/tools/douyin_search_tikhub.py
  18. 264 0
      agents/find_agent/tools/douyin_user_videos.py
  19. 48 12
      agents/find_agent/tools/hotspot_profile.py
  20. 381 11
      agents/find_agent/tools/qwen_video_analyze.py
  21. 703 0
      agents/find_agent/tools/video_discovery_store.py
  22. 77 0
      jobs/discover_videos_from_demands.py
  23. 2 1
      jobs/grade_demand_pool.py
  24. 65 0
      jobs/publish_videos_from_discovery.py
  25. 1 0
      pyproject.toml
  26. 2 0
      requirements.txt
  27. 649 0
      scripts/aigc_platform_api.py
  28. 3 0
      scripts/docker-deploy.sh
  29. 1 1
      scripts/run_grade_plan_groups.py
  30. 59 0
      scripts/verify_find_agent_flow.py
  31. 126 0
      scripts/verify_find_agent_from_demand.py
  32. 226 0
      scripts/verify_find_agent_live.py
  33. 33 0
      scripts/verify_video_truncate_runtime.py
  34. 16 0
      sql/video_discovery_add_aigc_plan.sql
  35. 25 0
      sql/video_discovery_add_audit_evidence.sql
  36. 13 0
      sql/video_discovery_add_biz_dt.sql
  37. 125 0
      sql/video_discovery_tables.sql
  38. 10 1
      supply_agent/agent/core.py
  39. 285 31
      supply_agent/agent/loop.py
  40. 4 0
      supply_agent/config.py
  41. 78 2
      supply_agent/llm/client.py
  42. 20 10
      supply_agent/tools/base.py
  43. 9 0
      supply_infra/aigc/__init__.py
  44. 192 0
      supply_infra/aigc/client.py
  45. 100 0
      supply_infra/aigc/plan_map.py
  46. 4 0
      supply_infra/config.py
  47. 8 0
      supply_infra/db/models/__init__.py
  48. 304 0
      supply_infra/db/models/video_discovery.py
  49. 2 0
      supply_infra/db/repositories/__init__.py
  50. 395 0
      supply_infra/db/repositories/video_discovery_repo.py
  51. 47 0
      supply_infra/scheduler/_verify_auto_assign.py
  52. 123 0
      supply_infra/scheduler/auto_assign_grade_plan.py
  53. 221 0
      supply_infra/scheduler/jobs/discover_videos_from_demands.py
  54. 7 4
      supply_infra/scheduler/jobs/grade_demand_pool.py
  55. 207 0
      supply_infra/scheduler/jobs/publish_videos_from_discovery.py
  56. 52 0
      supply_infra/scheduler/plan_group_batch.py
  57. 66 38
      web/src/components/IcicleHeatTree.vue
  58. 28 9
      zhangbo.md

+ 6 - 1
.env.example

@@ -4,6 +4,7 @@ OPENROUTER_API_KEY=sk-or-v1-...
 # Default model (any OpenRouter-supported model)
 # Examples: google/gemini-2.5-flash, google/gemini-2.5-flash-lite, anthropic/claude-sonnet-5
 OPENROUTER_MODEL=google/gemini-2.5-flash
+OPENROUTER_TIMEOUT_SECONDS=120
 
 # Agent defaults
 AGENT_MAX_ITERATIONS=20
@@ -37,7 +38,7 @@ ODPS_ENDPOINT=https://service.cn.maxcompute.aliyun.com/api
 # Scheduler
 SCHEDULER_ENABLED=true
 SCHEDULER_TIMEZONE=Asia/Shanghai
-# Aliyun OSS (agent 运行日志可视化上传)
+# Aliyun OSS (agent 运行日志可视化上传;qwen 视频截断后片段也上传到此 bucket)
 ALIYUN_OSS_ACCESS_KEY_ID=
 ALIYUN_OSS_ACCESS_KEY_SECRET=
 ALIYUN_OSS_REGION=cn-hangzhou
@@ -47,3 +48,7 @@ ALIYUN_OSS_ROOT_PREFIX=supply_agent
 ALIYUN_OSS_MANUAL_LOG_PREFIX=supply_agent/manual_logs
 ALIYUN_OSS_PUBLIC_BASE_URL=http://rescdn.yishihui.com
 LOG_OSS_UPLOAD_ENABLED=true
+
+# AIGC platform (视频爬取/发布计划)
+AIGC_API_TOKEN=
+AIGC_DRY_RUN=false

+ 13 - 2
Dockerfile

@@ -29,7 +29,16 @@ RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors
     sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
 
 RUN apt-get update \
-    && apt-get install -y --no-install-recommends gcc \
+    && apt-get install -y --no-install-recommends gcc ffmpeg \
+    && ffmpeg -version >/dev/null \
+    && ffprobe -version >/dev/null \
+    && ffmpeg -hide_banner -loglevel error \
+        -f lavfi -i testsrc=duration=2:size=160x120:rate=10 \
+        -t 2 -c:v libx264 -pix_fmt yuv420p /tmp/verify_src.mp4 \
+    && ffmpeg -hide_banner -loglevel error -y \
+        -i /tmp/verify_src.mp4 -t 1 -c copy /tmp/verify_clip.mp4 \
+    && test -s /tmp/verify_clip.mp4 \
+    && rm -f /tmp/verify_src.mp4 /tmp/verify_clip.mp4 \
     && rm -rf /var/lib/apt/lists/*
 
 COPY pyproject.toml requirements.txt README.md ./
@@ -38,8 +47,10 @@ COPY supply_infra/ supply_infra/
 COPY agents/ agents/
 COPY api/ api/
 COPY jobs/ jobs/
+COPY scripts/verify_video_truncate_runtime.py scripts/
 
-RUN pip install -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com ".[odps]"
+RUN pip install -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com ".[odps]" \
+    && python scripts/verify_video_truncate_runtime.py
 
 COPY --from=web-builder /app/web/dist ./web/dist
 

+ 1 - 1
agents/README.md

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

+ 842 - 0
agents/find_agent/PRD.md

@@ -0,0 +1,842 @@
+# find_agent 产品需求文档(PRD)
+
+> 文档版本:v1.0
+>
+> 基线日期:2026-07-23
+>
+> 文档状态:基于当前代码整理的产品基线,包含目标态要求
+>
+> Agent 定位:老年受众高潜抖音视频发现 Agent
+
+## 1. 文档目的
+
+本文定义 `find_agent` 为什么存在、接收什么输入、如何搜索与判断、必须遵守哪些公理、
+如何保存过程与输出结果,以及如何验收。
+
+本文以当前代码为事实基线,同时将以下两类内容明确分开:
+
+- **当前实现**:仓库中已经存在、运行时实际可用的能力;
+- **目标要求**:为了使 Agent 稳定、可审计地完成业务任务,产品必须达到的状态。
+
+相关实现入口:
+
+- Agent 组装:[agent.py](agent.py)
+- 核心提示词:[prompt/system_prompt.md](prompt/system_prompt.md)
+- 业务任务组装:[demand_run.py](demand_run.py)
+- 工具注册:[tools/__init__.py](tools/__init__.py)
+- 结果同步:[output_sync.py](output_sync.py)
+- 当前说明:[README.md](README.md)
+- 验证记录:[VALIDATION.md](VALIDATION.md)
+
+## 2. 产品概述
+
+### 2.1 业务问题
+
+平台已经拥有按需求分级的内容需求、参考视频和需求拓展点,但仍需要从抖音海量内容中
+找到真正可以承接需求的视频。
+
+单纯按关键词或分享数排序无法回答三个关键问题:
+
+1. 视频是否真的回答了本次需求,而不只是标题中出现了相同词语;
+2. 视频实际或潜在受众是否偏向老年人;
+3. 视频是否具备被转发给家人、朋友或同龄人的传播价值。
+
+`find_agent` 的任务是同时验证这三个问题,并保留完整的搜索、证据、评分和分池过程。
+
+### 2.2 产品目标
+
+针对一条 S/A 级需求,自动完成:
+
+1. 理解需求真实意图;
+2. 自主生成多个搜索假设;
+3. 从多个来源召回、翻页和扩展候选视频;
+4. 核验视频详情、互动数据、双侧年龄画像和真实内容;
+5. 对需求相关性、老年受众倾向和分享价值进行独立判断;
+6. 输出并持久化主推荐、补充推荐和淘汰候选;
+7. 保存可恢复、可解释、可审计的完整发现过程。
+
+优先产出至少 5 条质量可靠的保留视频;5 条是探索目标,不是降低准入质量的硬指标。
+
+### 2.3 核心价值
+
+- 为内容供给侧提供可以直接使用的视频候选;
+- 将“为什么搜索、为什么推荐、为什么淘汰”变为可查询的数据;
+- 让推荐结论建立在内容与受众证据上,而不是题材刻板印象上;
+- 为后续需求—内容—线上表现反馈闭环提供可追溯的内容承接记录。
+
+## 3. 用户与使用场景
+
+### 3.1 主要用户
+
+| 用户/系统 | 需要解决的问题 |
+|---|---|
+| 内容供给运营 | 针对高优需求快速获得可用视频及推荐理由 |
+| 业务分析人员 | 查看某条需求搜过什么、遗漏什么、为何保留或淘汰 |
+| SupplyAgent 调度任务 | 批量处理每日 S/A 需求并持久化结果 |
+| 下游内容系统 | 消费结构化主推荐和补充推荐 |
+| 开发与运维人员 | 定位外部接口、模型、持久化或流程提前结束问题 |
+
+### 3.2 核心场景
+
+1. **每日批量发现**:按业务日读取全部 S/A 需求及其参考视频点位,逐条执行找片;
+2. **单需求重跑**:指定 `demand_grade_id` 或强制重跑某条需求;
+3. **交互式找片**:直接向 Agent 提供需求词、参考视频和相关点;
+4. **长任务恢复**:使用 `run_id` 查询已执行搜索和候选分池,继续未完成的探索;
+5. **结果审计**:从数据库还原搜索树、证据和最终推荐,解释每个决策。
+
+## 4. 产品范围
+
+### 4.1 范围内
+
+- S/A 级需求上下文组装;
+- 抖音关键词搜索、TikHub 搜索、作者作品扩展;
+- 关键词翻页、标签扩展和作者扩展;
+- 候选跨来源、跨页去重;
+- 视频详情和播放地址核验;
+- 视频点赞用户画像与作者粉丝画像;
+- 年龄桶标准化;
+- 视频内容解析;
+- `R / E / S / V` 评分、解释与分池;
+- 搜索过程、候选证据和最终结果持久化;
+- 双池结果报告和最终文字—数据库同步;
+- 外部接口失败时的可解释降级。
+
+### 4.2 范围外
+
+- 生产或改写视频内容;
+- 直接发布视频;
+- 仅凭模型常识推断真实受众;
+- 将点赞用户画像表述为转发用户画像;
+- 替代上游需求分级;
+- 当前阶段自动使用线上 ROV/VOV 重新训练或校准评分;
+- 当前阶段对推荐视频进行人工审核排队。
+
+## 5. 核心概念
+
+| 概念 | 定义 |
+|---|---|
+| `demand_word` | 原始需求词,定义意图边界,但不强制成为实际搜索词 |
+| `seed_video_title` | 已知相关视频标题,用于消除语义歧义 |
+| `relevant_points` | 参考视频中与需求相关的灵感、目的或关键点 |
+| `reference_videos` | 同一需求下的全部参考视频及各自点位 |
+| `run_id` | 一次找片任务的稳定标识,贯穿所有搜索、候选和结果 |
+| 搜索根节点 | 由需求、参考标题、相关点或混合语义形成的独立搜索假设 |
+| 搜索扩展节点 | 由翻页、标签或作者形成的子搜索 |
+| 候选 | 任一搜索页召回并按 `aweme_id` 幂等合并的视频 |
+| 双侧画像 | 视频点赞用户年龄画像与作者粉丝年龄画像 |
+| 主推荐 | 需求相关性、老年倾向、分享价值均有可靠证据的候选 |
+| 补充推荐 | 相关性较弱或不成立,但老年倾向和分享价值同时很强的候选 |
+| 淘汰候选 | 证据显示不应保留,或补证后仍不能满足任一保留池的候选 |
+
+## 6. 决策对象与目标函数
+
+对候选视频 `v`,定义三个互相独立的命题:
+
+- `R(v)`:需求相关性,取值 `0~1`;
+- `E(v)`:老年受众倾向,取值 `0~1`;
+- `S(v)`:分享价值,取值 `0~1`。
+
+主推荐寻找的是联合事件:
+
+`G(v) = R(v) ∩ E(v) ∩ S(v)`
+
+综合价值使用加权几何关系:
+
+`V(v) = 100 × R(v)^0.40 × E(v)^0.35 × S(v)^0.25`
+
+`V` 用于保持候选排序一致,但不替代证据说明,也不应制造虚假精确性。最终报告以整数
+展示 `R / E / S / V`,数据库可保留更高精度。
+
+## 7. 决策公理与定理
+
+### 7.1 需求闸门公理
+
+相关性是主推荐的准入条件,不是普通加分项。
+
+- 高分享、老年受众明显但不回答本次需求的视频,不能进入主推荐;
+- 若其 `E` 与 `S` 均强,应保留到补充推荐,而不是直接丢弃;
+- 搜索词命中不等于内容相关,必须由标题、详情或内容核验提供相关性证据;
+- 参考标题和相关点用于理解意图,不要求候选逐字匹配。
+
+### 7.2 搜索词自主权公理
+
+`demand_word` 不是必须原样提交搜索接口的命令。
+
+- Agent 应先提炼对象、事件、场景、用途、冲突、情绪和叙事角度;
+- 应形成 2~3 个语义不同的根搜索假设;
+- 搜索词价值由新增有效候选衡量,而不是由字面相似度衡量;
+- 第一个关键词有结果不代表需求已经被充分覆盖。
+
+### 7.3 搜索前沿扩展定理
+
+搜索是可生长、可追踪的探索图,不是一次接口调用。
+
+- 根节点来源:`demand / seed / point / mixed`;
+- 子节点来源:`tag / author / pagination`;
+- `has_more=true` 且本页产生有效新增候选时,下一页是仍存在的信息前沿;
+- 优质候选的话题、标题实体、作者和内容新角度可以触发子搜索;
+- 每个搜索节点必须记录形成原因、来源、父节点和供应方分页状态;
+- 根节点不得设置 `parent_search_id`,扩展节点应记录父节点。
+
+### 7.4 分享—年龄不可替代定理
+
+分享证据与年龄证据不能相互替代。
+
+- 高 `share_count` 只说明内容已发生传播;
+- 高老年占比或 TGI 只说明受众偏老;
+- 只有同一候选同时具备两类证据,才可以表述为“老年人可能喜欢并分享”;
+- 当前工具提供的是点赞用户画像,不是转发用户画像,禁止声称已经观察到老年分享者。
+
+### 7.5 受众证据层级定理
+
+老年倾向证据按以下优先级使用:
+
+1. 视频自身的点赞用户年龄画像;
+2. 作者粉丝年龄画像;
+3. 视频真实内容呈现出的适配特征;
+4. 标题、题材、画面人物或作者形象带来的直觉。
+
+第 4 层不能单独形成结论。视频画像与作者画像冲突时,优先视频画像并降低置信度,
+不能静默平均。
+
+明确覆盖 50 岁及以上的桶才是直接老年信号;`40+` 只能作为成熟人群代理信号。
+`50- / 50+ / 50岁以上 / >=50 / 41-50` 等表达必须先标准化。
+
+### 7.6 相对传播定理
+
+分享价值同时考虑规模、效率和动机:
+
+- 规模:`log(1 + share_count)` 在同类候选中的相对位置;
+- 效率:优先使用 `share_count / play_count`;
+- 替代效率:播放数缺失时使用平滑后的 `share_count / like_count`,并标明其局限;
+- 动机:实用提醒、家庭沟通、情感认同、共同记忆或谈资价值。
+
+禁止跨不同题材机械比较原始分享数,也不能让低样本高比率候选自动排到最前。
+
+### 7.7 联合短板定理
+
+`R / E / S` 任一维度接近零都会显著压低 `V`。三个弱证据不能通过简单相加伪装成一个
+强结论。
+
+- 主推荐必须同时通过 `R / E / S` 的证据判断;
+- 补充推荐不是“低质量主推荐”,而是相关性受限但 `E / S` 双强的独立池;
+- Agent 对分池负责,持久化工具不应偷偷重算评分或改写分池;
+- 5 条是结果目标,不是放宽质量边界的理由。
+
+### 7.8 反证优先公理
+
+一个强反证比多个弱正向线索更重要。
+
+- 真实内容明显围绕青少年校园、年轻圈层黑话或特定年轻文化时,应降低 `E`;
+- 画像明显偏年轻时,应作为强反证;
+- 快剪辑或网络表达等单一风格特征不能直接证明老年人不喜欢;
+- 缺失数据是未知,不是负证据;接口失败不能计为零分。
+
+### 7.9 多样性边际定理
+
+推荐集合应提供新增价值。
+
+- 高度重复的视频只保留证据更强的一条;
+- 价值相近时,优先覆盖不同需求点、内容角度和分享动机;
+- 不能用同质内容堆叠数量。
+
+### 7.10 信息价值停止律
+
+只有当额外搜索、详情、画像或内容解析可能改变准入、排序或置信度时,才继续调用。
+
+- 证据足以区分候选时停止;
+- 合理搜索前沿耗尽后,允许少于 5 条结束;
+- 没有可靠候选时可以返回空结果;
+- 不得为了凑数扩大到明显低质内容。
+
+## 8. 产品设计原则
+
+1. **证据先于直觉**:模型解释必须锚定搜索结果、详情、画像或内容解析;
+2. **事实与推断分离**:明确区分接口事实、计算结果和模型判断;
+3. **未知不等于否定**:缺失、失败和样本不足通过未知与置信度表达;
+4. **过程优先可审计**:每次搜索、翻页、扩展、补证和分池都必须可还原;
+5. **决策权单一**:Agent 负责最终分池,工具负责保存,不在多个位置重复解释规则;
+6. **状态可恢复**:长任务可以通过 `run_id` 从数据库恢复,而不依赖单次模型上下文;
+7. **幂等去重**:同一运行内以 `(run_id, aweme_id)` 唯一,同一搜索页重复保存不新增候选;
+8. **成本逐层增加**:先搜索和互动预筛,再详情与画像,最后只解析最有信息价值的视频;
+9. **失败可降级**:单一供应方失败不应让整次任务直接失去业务结果;
+10. **输出与数据库一致**:最终报告中的主推荐/补充推荐必须与最终持久化分池一致。
+
+## 9. 端到端流程
+
+### 9.1 总流程图
+
+```mermaid
+flowchart TD
+    A["读取业务日 S/A 需求"] --> B["聚合同一需求下全部参考视频与相关点"]
+    B --> C{"是否已有 running / finished 运行"}
+    C -->|"是且非强制重跑"| C1["跳过并记录原因"]
+    C -->|"否或强制重跑"| D["预创建 video_discovery_run"]
+    D --> E["Agent 复用 run_id 并解释真实需求意图"]
+    E --> F["生成 2~3 个语义不同的根搜索词"]
+    F --> G["内部搜索 / TikHub 搜索"]
+    G --> H["保存搜索页并按 aweme_id 幂等并入候选"]
+    H --> I["候选状态 pending_evaluation"]
+    I --> J["按相关性、分享规模/效率、补充潜力廉价预筛"]
+    J --> K["批量拉取详情,默认最多 8 条"]
+    K --> L["批量获取视频点赞画像 + 作者粉丝画像"]
+    L --> M["标准化年龄桶并识别一致、冲突或缺失"]
+    M --> N["对最可能改变决策的候选做视频内容解析,默认最多 3 条"]
+    N --> O["计算 R / E / S / V,形成证据理由与置信度"]
+    O --> P{"分池判断"}
+    P -->|"三维共同成立"| P1["primary"]
+    P -->|"E/S 双强但 R 弱"| P2["backup"]
+    P -->|"不满足保留条件"| P3["rejected"]
+    P1 --> Q["保存候选证据与分池"]
+    P2 --> Q
+    P3 --> Q
+    Q --> R{"是否仍有高信息价值前沿"}
+    R -->|"翻页"| G
+    R -->|"标签扩展"| G
+    R -->|"作者扩展"| G
+    R -->|"没有"| S["保存 finished 与停止原因"]
+    S --> T["查询最终数据库状态"]
+    T --> U["输出主推荐、补充推荐、淘汰候选、搜索树和缺失数据"]
+    U --> V["按最终报告再次同步主/补充分池"]
+```
+
+### 9.2 阶段说明
+
+#### 阶段 1:任务装配
+
+1. 读取指定业务日的 S/A 级 `demand_grade`;
+2. 聚合该需求下全部 `demand_video_expansion`;
+3. 仅保留 `inspiration / purpose / key` 三类有效点位;
+4. 补充参考视频标题;
+5. 一个需求形成一条 `FindDemandContext`,而不是一个参考视频形成一条任务。
+
+#### 阶段 2:运行初始化
+
+1. 以 `(biz_dt, demand_grade_id)` 检查已有运行;
+2. 默认跳过 `running / finished`;
+3. 强制重跑时复用或重置对应运行;
+4. 预创建 `video_discovery_run`;
+5. Agent 第一项动作必须复用系统给定 `run_id`。
+
+#### 阶段 3:意图理解与根搜索
+
+1. 综合 `demand_word`、全部参考视频和全部相关点;
+2. 输出一句清晰的真实意图解释;
+3. 形成 2~3 个不同语义方向的根搜索词;
+4. 为每个词写明希望验证的内容假设。
+
+#### 阶段 4:多源召回与搜索图扩展
+
+1. 内部关键词搜索作为基础来源;
+2. TikHub 作为独立来源,失败时回退内部搜索;
+3. TikHub 翻页必须同时复用 `cursor / search_id / backtrace`;
+4. 生产性首页默认最多继续一页;
+5. 高潜作者可按最热或最新扩展作品;
+6. 高价值话题可形成标签搜索分支;
+7. 每一页,无论成功、空结果或失败,都应保存状态。
+
+#### 阶段 5:候选并入与预筛
+
+1. 所有来源统一为 `search_results`;
+2. 以 `aweme_id` 做跨词、跨页、跨供应方幂等合并;
+3. 新候选进入 `pending_evaluation`;
+4. 先根据标题相关性、互动量和补充潜力预筛;
+5. 默认最多 8 条进入详情和双画像阶段。
+
+#### 阶段 6:证据补全
+
+1. `douyin_detail` 核验标题、作者、互动数据、话题、页面链接和播放地址;
+2. `batch_fetch_portraits(fetch_account_portrait=true)` 同时获取双侧画像;
+3. 批量画像自动输出标准化年龄结果;
+4. 内容画像缺失时,作者画像只能作为账号先验;
+5. 画像冲突、缺失和失败必须原样保存;
+6. 默认最多 3 条调用视频内容解析;
+7. 内容解析提示必须包含本次需求和相关点,并区分明确呈现与模型推断。
+
+#### 阶段 7:评分与分池
+
+1. 独立形成 `R / E / S`;
+2. 计算 `V` 并给出置信度;
+3. 输出每一维的正证、反证和限制;
+4. 分为 `primary / backup / rejected`;
+5. 对新增或证据变化的候选重新保存;
+6. 保留数量不足 5 条时,优先继续高价值搜索前沿。
+
+#### 阶段 8:停止、持久化与输出
+
+1. 无明显高信息价值前沿后,将运行保存为 `finished`;
+2. 保存停止原因;
+3. 查询最终状态,确保搜索与候选完整;
+4. 输出最终报告;
+5. 运行结束后,从最终报告的主推荐和补充推荐段提取 `aweme_id`,再次同步数据库分池。
+
+## 10. 状态模型
+
+### 10.1 运行状态
+
+```text
+running ──正常完成──> finished
+   │
+   └────异常中止──> failed
+```
+
+同一业务日与 `demand_grade_id` 只保留一条调度运行身份。`--force` 允许重新执行。
+
+### 10.2 候选状态
+
+```text
+搜索召回
+   ↓
+pending_evaluation
+   ↓ 详情、画像、内容、评分
+   ├── primary
+   ├── backup
+   └── rejected
+```
+
+- `pending_evaluation` 不能直接作为最终推荐;
+- 候选补充新证据后允许重新评估和改变分池;
+- 最终报告中的主推荐与补充推荐对数据库对应分池具有同步作用。
+
+### 10.3 搜索图
+
+| `source_type` | 类型 | 父节点规则 |
+|---|---|---|
+| `demand` | 需求语义根搜索 | 无父节点 |
+| `seed` | 参考标题根搜索 | 无父节点 |
+| `point` | 相关点根搜索 | 无父节点 |
+| `mixed` | 多证据混合根搜索 | 无父节点 |
+| `tag` | 标签扩展 | 记录父搜索 |
+| `author` | 作者作品扩展 | 记录父搜索 |
+| `pagination` | 同一搜索翻页 | 记录父搜索 |
+
+## 11. 功能需求
+
+### FR-01 输入聚合
+
+- 系统必须按需求粒度聚合全部参考视频和有效点位;
+- 必须保留每个点位的来源视频、点位类型和描述;
+- 无有效点位的需求不进入找片任务;
+- 交互式输入至少应包含 `demand_word`,参考标题或相关点缺失时应明确降低意图置信度。
+
+### FR-02 运行身份与幂等
+
+- 调度执行必须在 Agent 运行前预创建 `run_id`;
+- Agent 必须复用传入的 `run_id`;
+- 所有持久化工具必须使用同一 `run_id`;
+- `(biz_dt, demand_grade_id)` 和 `(run_id, aweme_id)` 必须保持唯一;
+- 重复保存同一搜索页不得重复增加候选。
+
+### FR-03 意图解释
+
+- Agent 必须先形成对真实需求的简短解释;
+- 解释必须引用需求词、参考视频或相关点;
+- 解释必须明确内容对象、场景或用途;
+- 不能把原始需求词直接等同于唯一搜索词。
+
+### FR-04 自主搜索
+
+- 默认形成 2~3 个语义不同的根搜索词;
+- 每个搜索词必须包含 `query_reason`;
+- 搜索结果必须统一返回视频 ID、标题、链接、作者和互动数据;
+- TikHub 缺少密钥或请求失败时,必须记录失败并回退内部搜索;
+- 不得混用不同供应方的分页游标。
+
+### FR-05 搜索页持久化
+
+- 每次搜索调用后必须保存搜索页;
+- 空页应保存为成功空结果,而不是伪装成失败;
+- 失败页应保存错误原因;
+- 必须保存实际查询词、供应方、筛选条件、游标、页码、是否有下一页和新增候选数;
+- 标签、作者和翻页扩展必须能回溯父搜索。
+
+### FR-06 候选召回与去重
+
+- 新召回候选必须进入 `pending_evaluation`;
+- 同一视频在多个搜索中出现时合并来源词、来源搜索 ID 和标签;
+- 互动数据可由详情核验结果更新;
+- 已评估候选再次被召回时不得无条件重置为待评估。
+
+### FR-07 详情核验
+
+- 正式保留候选必须尝试核验详情;
+- 单次详情工具最多处理 8 条;
+- 部分视频失败时必须返回成功项与失败项,不因单项失败丢弃整批;
+- 详情应尽量提供页面链接、播放地址、作者、话题和互动数据。
+
+### FR-08 双侧年龄画像
+
+- 正式候选必须尝试视频点赞用户画像;
+- 正式候选应同时尝试作者粉丝画像;
+- 批量画像单次最多处理 8 条;
+- 必须保存每一侧是否尝试、是否有数据和失败原因;
+- 画像必须执行确定性年龄桶标准化;
+- 仅有作者画像时,`E` 置信度必须受限;
+- 双侧冲突时必须明确标注并降低置信度。
+
+### FR-09 视频内容核验
+
+- 有可用播放地址且内容核验会影响决策时,应调用视频解析;
+- 默认最多解析 3 条最有信息价值的候选;
+- 解析提示必须包含本次需求与相关点;
+- 内容解析不能代替年龄画像;
+- 超长视频默认截断到 180 秒,截断失败或依赖缺失必须返回明确错误。
+
+### FR-10 评分与解释
+
+- 每个已评估候选应包含 `R / E / S / V`;
+- 每一维应有独立理由;
+- 必须保存命中需求点、分享动机、正向证据、主要限制和置信度;
+- 分数缺失时不得伪造;
+- 缺失证据不应自动变成零分。
+
+### FR-11 双池分流
+
+- `primary`:`R / E / S` 三个命题均成立;
+- `backup`:不满足主推荐,但 `E / S` 同时强,并明确相关性限制;
+- `rejected`:不满足任一保留池,或强反证足以否定;
+- `backup` 不得在报告中伪装成主推荐;
+- 低相关但高传播候选必须在淘汰前先评估其补充潜力。
+
+### FR-12 探索与停止
+
+- 保留候选不足 5 条时,应优先继续不同根搜索、生产性首页翻页或高价值标签/作者扩展;
+- 达到 5 条后,没有明显更高价值前沿时应优先结束;
+- 第二页仍明显产生高价值新增候选时,可继续深挖,但不得无界翻页;
+- 少于 5 条结束时,必须说明前沿已耗尽或继续探索价值较低;
+- 空结果允许结束,但必须能证明不是模型提前停止。
+
+### FR-13 持久化与恢复
+
+- 必须保存运行、搜索页和候选三个粒度的数据;
+- 候选记录必须包含详情、互动、双侧画像、标准化结果、内容分析、评分、理由和分池;
+- 查询状态必须可恢复搜索树与候选池;
+- 数据库不可用时只尝试一次初始化写入,之后以内存结构继续业务流程;
+- 降级结果必须醒目标注“未持久化”及原因。
+
+### FR-14 最终输出
+
+最终报告必须按以下顺序输出:
+
+1. 一句话需求意图理解;
+2. 主推荐;
+3. 补充推荐;
+4. 最有竞争力但被淘汰的候选;
+5. 搜索树;
+6. 缺失数据、画像冲突、未继续前沿和接口失败。
+
+每条主推荐或补充推荐必须包含:
+
+- 排名、标题、作者、抖音页面链接、`aweme_id`;
+- 命中的需求点与相关性证据;
+- 原始 `share_count`;
+- 可计算时的分享率或替代指标;
+- 视频点赞年龄画像证据;
+- 作者粉丝年龄画像证据;
+- 老年人可能愿意分享的内容动机;
+- `R / E / S / V` 整数分;
+- 高/中/低置信度;
+- 同时包含正证和主要限制的一句话理由。
+
+### FR-15 最终一致性
+
+- 报告中主推荐的 ID 必须属于数据库 `primary`;
+- 报告中补充推荐的 ID 必须属于数据库 `backup`;
+- 淘汰候选不能出现在补充推荐段;
+- 最终文字分池与数据库不一致时,系统必须阻止完成或产生显式错误;
+- 最终分池同步必须可观测,失败不能静默吞掉。
+
+## 12. 工具能力与证据含义
+
+| 工具 | 产品用途 | 关键约束 |
+|---|---|---|
+| `douyin_search` | 内部关键词召回 | 支持筛选与游标翻页;结果不是最终事实 |
+| `douyin_search_tikhub` | 独立 TikHub 召回 | 翻页必须复用 `cursor/search_id/backtrace` |
+| `douyin_user_videos` | 扩展高潜作者作品 | 作者优秀不代表作品自动合格 |
+| `douyin_detail` | 批量核验详情与播放地址 | 单次最多 8 条 |
+| `get_content_fans_portrait` | 单条视频点赞用户画像 | 不是分享用户画像 |
+| `get_account_fans_portrait` | 单条作者粉丝画像 | 只作为账号受众先验 |
+| `batch_fetch_portraits` | 批量获取双侧画像 | 单次最多 8 条;正式候选设置作者画像为真 |
+| `normalize_age_portraits` | 确定性标准化年龄桶 | 不替代业务评分 |
+| `qwen_video_analyze` | 核验真实内容与分享动机 | 默认最多 3 条;不能替代年龄画像 |
+| `create_video_discovery_run` | 创建或复用运行 | 调度场景必须复用预创建 `run_id` |
+| `record_video_search_page` | 保存搜索页并合并候选 | 所有搜索页都必须保存 |
+| `batch_save_video_candidate_evaluations` | 保存证据、评分和分池 | 原样保存,不重算、不改池 |
+| `query_video_discovery_state` | 恢复和检查运行状态 | 可选择是否包含淘汰候选 |
+
+当前共享基础工具还会注册 `load_skill`,但它不是本 Agent 的核心业务链路。
+
+## 13. 数据模型
+
+### 13.1 `video_discovery_run`
+
+一次需求找片运行,保存:
+
+- 业务日和需求 ID;
+- `demand_word`、参考视频和相关点输入快照;
+- Agent 的最终意图解释;
+- `running / finished / failed` 状态;
+- 搜索页数、主推荐数、补充推荐数;
+- 停止原因或失败原因。
+
+### 13.2 `video_discovery_search`
+
+搜索图中的一个具体页面,保存:
+
+- 实际搜索词和形成原因;
+- 根搜索/标签/作者/翻页来源;
+- 父搜索 ID;
+- 供应方与供应方分页状态;
+- 筛选条件、游标和页码;
+- 本页结果数、新增候选数、是否有下一页;
+- 本页候选 ID;
+- 成功、失败和错误信息。
+
+### 13.3 `video_discovery_candidate`
+
+一次运行中的一条视频,保存:
+
+- 视频与作者身份;
+- 来源词、来源搜索和标签;
+- 互动数据;
+- 视频点赞年龄证据、作者粉丝年龄证据和标准化结果;
+- 详情、画像、年龄标准化和内容解析是否已执行;
+- 内容分析和扩展价值标签;
+- `R / E / S / V`、置信度和各维理由;
+- `primary / backup / rejected / pending_evaluation` 分池。
+
+## 14. 成本与运行预算
+
+### 14.1 产品默认预算
+
+| 项目 | 默认约束 |
+|---|---|
+| Agent 最大迭代 | 60 |
+| 搜索类工具调用 | 最多 10 次 |
+| 详情工具调用 | 最多 2 次,每次最多 8 条 |
+| 画像工具调用 | 最多 3 次,每批最多 8 条 |
+| 视频内容解析 | 最多 3 次 |
+| 候选评估保存 | 最多 6 次 |
+| 状态查询 | 最多 8 次,且无状态变化时禁止无意义重复 |
+| 根搜索词 | 默认 2~3 个 |
+| 生产性首页翻页 | 默认继续 1 页 |
+| 正式补证候选 | 默认最多 8 条 |
+| 视频解析候选 | 默认最多 3 条 |
+
+### 14.2 预算原则
+
+- 预算是防止无界探索的上限,不是必须耗尽的配额;
+- 优先使用批量详情和批量画像;
+- 只有可能改变分池或置信度时才做视频解析;
+- 外部接口存在串行限流,搜索深度必须服从信息价值。
+
+## 15. 异常与降级策略
+
+| 异常 | 处理要求 |
+|---|---|
+| TikHub Key 缺失 | 保存错误,切换内部关键词搜索,不重复相同失败调用 |
+| 某搜索源失败 | 保留失败页,尝试其他供应方或语义假设 |
+| 搜索空页 | 保存空页并关闭对应无效前沿,不伪装为接口失败 |
+| 详情部分失败 | 保留成功项和逐条失败原因 |
+| 内容画像缺失 | 尝试作者画像,标记 `account_only`,降低置信度 |
+| 双侧画像冲突 | 优先视频画像,显式标注冲突 |
+| 视频无播放地址 | 不强制内容解析,保存缺失状态 |
+| 视频解析失败 | 保留原始错误,不用模型常识补写内容 |
+| 数据库不可用 | 一次失败后转内存流程,最终披露未持久化 |
+| Agent 或模型异常 | 运行标记 `failed` 并保存异常原因 |
+| 最终分池同步失败 | 记录错误并将任务视为未完整完成 |
+
+## 16. 非功能需求
+
+### 16.1 可解释性
+
+- 每个搜索词必须有形成原因;
+- 每个保留或淘汰候选必须有决策理由;
+- 最终报告必须披露主要缺失与限制;
+- 禁止无证据年龄结论。
+
+### 16.2 可追溯性
+
+- 输入、搜索树、候选证据、分数、分池和停止原因必须通过同一 `run_id` 关联;
+- 供应方分页状态必须原样保存;
+- 最终结果必须能回溯到召回页面。
+
+### 16.3 一致性
+
+- 搜索页与候选合并必须幂等;
+- 运行计数应从真实搜索和分池状态刷新;
+- 最终报告与数据库分池必须一致;
+- 同一候选的重复来源必须合并而不是覆盖。
+
+### 16.4 可用性
+
+- 一个外部接口失败不应中止所有业务判断;
+- 部分证据缺失时允许降置信度继续;
+- 所有错误必须保留真实语义,不能编造缺失字段。
+
+### 16.5 安全与配置
+
+- API Key、数据库密码和 OSS 密钥只能从环境配置读取;
+- 日志和报告不得输出完整密钥;
+- 长视频临时文件必须在处理后清理;
+- 公网播放地址和 OSS 地址应遵守数据授权与访问控制要求。
+
+## 17. 成功指标
+
+### 17.1 业务指标
+
+| 指标 | 定义 |
+|---|---|
+| 任务完成率 | 成功进入 `finished` 且输出完整报告的任务占比 |
+| 主推荐需求准确率 | 抽检中真正承接需求的主推荐占比 |
+| 老年证据有效率 | 保留候选中具有有效视频或作者年龄画像的占比 |
+| 分享证据完整率 | 保留候选中同时具有分享规模/效率和内容动机说明的占比 |
+| 有效保留数 | 每次运行 `primary + backup` 数量及分布 |
+| 补充池有效率 | 补充候选经业务抽检确认值得保留的占比 |
+| 推荐多样性 | 保留结果覆盖的不同需求点和分享动机数量 |
+
+### 17.2 流程指标
+
+| 指标 | 定义 |
+|---|---|
+| 搜索页持久化率 | 已调用搜索页中成功保存的占比 |
+| 候选证据完整率 | 保留候选完成详情、双画像尝试、年龄标准化和必要内容核验的占比 |
+| 最终分池一致率 | 最终报告与数据库分池完全一致的运行占比 |
+| 重复候选率 | 去重前重复候选占比,用于观察搜索冗余 |
+| 搜索新增效率 | 每个搜索页新增有效候选数 |
+| 降级成功率 | 单供应方失败后仍成功完成任务的占比 |
+| 平均运行时长 | 从运行创建到最终完成的耗时 |
+| 单任务工具成本 | 各类搜索、详情、画像和解析调用次数 |
+
+## 18. 验收标准
+
+### 18.1 功能验收
+
+一次运行满足以下条件才视为产品完成:
+
+1. 正确装配一条需求及其全部有效参考点;
+2. 创建或复用唯一 `run_id`;
+3. 执行并保存实际搜索页;
+4. 所有召回候选按 `aweme_id` 幂等合并;
+5. 最终保留候选具有足够的详情、画像和内容证据;
+6. 所有年龄画像已经标准化;
+7. 候选被明确分为主推荐、补充推荐、淘汰或待评估;
+8. 最终运行状态和停止原因已保存;
+9. 报告满足输出契约;
+10. 报告与数据库分池完全一致;
+11. 任何外部失败、证据缺失和画像冲突均被披露;
+12. 少于 5 条时没有为了凑数放宽质量标准。
+
+### 18.2 负向验收
+
+出现以下任一情况,运行不得被判定为稳定完成:
+
+- 用题材或画面中出现老人代替受众年龄证据;
+- 将点赞用户画像声称为分享用户画像;
+- 把低相关高分享视频放入主推荐;
+- 将符合补充池条件的候选直接丢弃且无理由;
+- `pending_evaluation` 直接出现在推荐结果;
+- 新搜索或新证据产生后未重新保存受影响候选;
+- 报告推荐 ID 与数据库分池不一致;
+- 审计未通过仍输出过程性“接下来继续”的半截答案;
+- 因数量不足而推荐明显低质视频;
+- 接口失败后编造画像、详情或内容结论。
+
+## 19. 当前实现状态
+
+### 19.1 已实现
+
+- S/A 需求、参考视频和点位的任务装配;
+- 业务日批量执行、并发 worker、跳过已运行任务和强制重跑;
+- 内部关键词、TikHub 和作者作品三类召回;
+- 多源统一候选结构与供应方分页状态;
+- 搜索页和候选的幂等持久化;
+- 详情批量核验;
+- 视频点赞画像、作者粉丝画像和批量双侧画像;
+- 年龄桶确定性标准化;
+- 千问视频内容解析与长视频截断;
+- `R / E / S / V`、理由、完成标记和分池字段;
+- 三张视频发现数据表及状态恢复;
+- 最终报告 ID 提取和主/补充分池同步;
+- 工具调用预算和无状态变化时的重复查询约束。
+
+### 19.2 部分实现
+
+- 搜索图扩展、信息价值停止和双池判断主要依赖模型遵守提示词;
+- 数据库不可用的内存降级由提示词要求,缺少统一的结构化运行时接管;
+- 最终报告会反向同步分池,但同步失败当前只记录日志,不会让调用显式失败;
+- 最终报告同步当前只更新报告中出现的 ID,不会自动清理报告中已省略的旧主/补充候选;
+- `find_agent_completion_guard` 已有实现与测试,但当前 Agent 配置为
+  `completion_guard=None`;
+- 流程审计函数已经实现,但 `audit_video_discovery_process` 和
+  `audit_video_discovery_run` 当前没有注册给 Agent。
+
+### 19.3 尚未实现或仍有关键证据缺口
+
+- 实际转发用户年龄画像;
+- 各年龄段曝光、完播率和观看时长;
+- 同题材、相近发布时间的传播基线;
+- 平台相似视频直接扩展;
+- 标准话题热度与稳定批量话题服务;
+- 评论中的转发动机结构化信号;
+- 推荐内容上线后的真实表现回流与评分校准;
+- 运行时确定性完成守卫;
+- 正式的端到端 SLA、告警和仪表盘。
+
+## 20. 当前主要风险
+
+| 风险 | 影响 | 产品要求 |
+|---|---|---|
+| 点赞画像不等于转发画像 | “老年人会分享”只能是概率推断 | 报告必须使用谨慎措辞,并优先建设转发画像 |
+| 内容画像经常缺失 | `E` 过度依赖作者先验 | 明确 `account_only` 和较低置信度 |
+| 完成守卫暂停 | 模型可能在流程未闭合时提前结束 | P0 恢复确定性完成约束 |
+| 审计工具未注册 | 无法在 Agent 内主动验证流程 | P0 注册并要求结束前审计 |
+| 最终文字可反向改池 | 格式错误可能造成数据状态变化 | 同步前做严格校验并保证事务一致性 |
+| 最终分池采用增量同步 | 报告中省略的旧候选可能仍留在主/补充池 | 改为一次运行内的原子全量对齐 |
+| 外部接口串行限流 | 多词、多候选任务时延较高 | 更强预筛、批量接口和明确 SLA |
+| 原始分享数不可跨题材比较 | `S` 排序可能偏向高曝光题材 | 建设同主题传播基线 |
+| TikHub 为可选依赖 | 环境遗漏时搜索覆盖下降 | 配置预检、清晰降级与环境样例补全 |
+| 模型行为依赖长提示词 | 规则可能被遗漏 | 将硬性流程约束下沉到确定性代码 |
+
+## 21. 迭代优先级
+
+### P0:稳定完成与数据一致性
+
+1. 注册数据库审计工具;
+2. 恢复并完善 `find_agent_completion_guard`;
+3. 强制最后一次搜索、证据变更、评估、审计和状态查询满足时序要求;
+4. 最终报告同步前校验 ID 与分池,并以一次运行为范围原子替换最终双池;
+5. 同步失败时显式返回未完成,而不是只写日志;
+6. 为所有外部依赖增加启动前配置检查;
+7. 增加端到端成功、提前停止、数据库降级和分池错位验收。
+
+### P1:提升结论可靠性
+
+1. 接入实际转发用户年龄画像;
+2. 接入受众留存、完播率和观看时长;
+3. 实现多关键词批量搜索与服务端限流;
+4. 接入平台相似视频;
+5. 接入标准话题与话题热度;
+6. 用同题材传播基线校准分享规模和效率。
+
+### P2:建立反馈闭环
+
+1. 建立需求—推荐视频—最终使用内容的映射;
+2. 回收真实 ROV/VOV 与内容质量信息;
+3. 分析主推荐、补充推荐和淘汰候选的后验表现;
+4. 基于历史样本校准 `R / E / S` 与置信度;
+5. 将校准参数纳入版本化策略,而不是继续写入提示词。
+
+## 22. 产品完成定义
+
+`find_agent` 达到产品化完成状态,需要同时满足:
+
+- 对每日 S/A 需求可以稳定自动执行;
+- 任务过程可恢复、可解释、可审计;
+- 推荐建立在需求、年龄和分享三类独立证据上;
+- 主推荐、补充推荐和淘汰候选的边界清晰;
+- 合理前沿耗尽时能够停止,数量不足时不降低质量;
+- 外部接口失败时可解释降级;
+- 最终报告与持久化结果始终一致;
+- 运行时能够确定性阻止提前结束和不完整输出;
+- 后续可以将真实内容表现回流到需求供给闭环中。

+ 119 - 0
agents/find_agent/README.md

@@ -0,0 +1,119 @@
+# 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`:与需求相关,且老年倾向、分享价值达到主推荐边界;
+- `backup`:未进入主推荐,但老年倾向和分享价值达到保留线,由 Agent 自动保留。
+
+最终结果优先保证至少 5 条,可以更多。优先使用 `primary`;不足 5 条时,由 Agent
+按 `V` 从 `backup` 选择质量最好的补充项,并明确标注限制。
+
+5 条是优先目标,不是硬性门槛。合理搜索后确实没有足够的合格视频时,可以少于 5 条
+结束,不会为了凑数保留明显低质内容。流程不再等待人工审核。
+
+## 已实现的搜索记忆
+
+执行 `.venv/bin/python jobs/init_db.py` 后会创建三张表:
+
+| 表 | 粒度 | 用途 |
+|---|---|---|
+| `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 给出的证据、评分和分池;
+- `query_video_discovery_state`:恢复搜索树、主推荐池和补充候选池;
+
+候选分池完全由 Agent 决定:
+
+- `pending_evaluation`:搜索已经召回,但 Agent 尚未完成详情、画像、内容核验和评分;
+- `primary`:Agent 决定的主推荐;
+- `backup`:Agent 决定保留的补充推荐;
+- `rejected`:Agent 决定淘汰的候选。
+
+状态流固定为:
+
+`搜索召回 → pending_evaluation → Agent补证和评分 → primary / backup / rejected`。
+
+`pending_evaluation` 不能直接进入最终主推荐或补充推荐。
+
+保存工具不会重算 `R/E/S/V`、限制年龄分,也不会修改 Agent 给出的
+`decision_bucket`。最终报告中列为主推荐或补充推荐的视频会同步到对应数据库分池。
+
+## 建议补充的外部数据工具
+
+以下工具依赖抖音爬虫或热点宝增加接口,当前仓库无法自行补出真实数据。建议按优先级
+评估能否实现。
+
+### 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` 和缺失原因。
+没有样本量与统计周期的百分比,不适合用于高置信判断。

+ 105 - 0
agents/find_agent/VALIDATION.md

@@ -0,0 +1,105 @@
+# find_agent 工具验证报告
+
+验证日期:2026-07-23
+
+## 验证口径
+
+- **真实通过**:实际调用当前配置的外部接口,并检查关键字段。
+- **契约通过**:使用模拟接口/Repository 检查参数、分页、解析和错误格式。
+- **受阻**:缺少密钥、数据库表或明确授权,不能宣称真实可用。
+
+## 本轮回归结果
+
+- 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个已保存搜索页,并执行了分页;
+- 候选详情、批量双侧画像和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` | 完整流/提前停止/备选误删场景 | 通过;能阻止少根词、未翻页、未扩标签和E/S双高误删 |
+| `qwen_video_analyze` | 使用本轮详情播放URL、需求和相关点 | 真实通过;返回530字分析,耗时约21秒 |
+| `create_video_discovery_run` | 真实 MySQL 端到端测试 | 真实通过;运行记录成功创建 |
+| `record_video_search_page` | 真实 MySQL 保存与重复页测试 | 真实通过;3条候选入库,重复保存新增数为0 |
+| `batch_save_video_candidate_evaluations` | 真实主推荐/补充候选/淘汰分池测试 | 真实通过;三类候选各1条,运行计数正确 |
+| `query_video_discovery_state` | 真实查询运行、搜索树和三类候选 | 真实通过;返回1个搜索页和3条候选 |
+
+## 真实样例观察
+
+- 搜索首条视频分享数为 `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 / content_analysis_verified` 等审计状态,导致状态恢复后的
+   候选不能直接满足审计输入契约。
+4. AgentLoop 没有 find_agent 完成守卫;模型可以在 `can_finish=false` 时直接返回文本。
+
+## 下一步修复
+
+1. 为候选表增加详情、双画像尝试、视频解析和年龄标准化状态,并由
+   `query_video_discovery_state` 原样恢复;搜索状态同时补回 `parent_search_id`。
+2. 给 find_agent 增加确定性完成守卫:最后一次审计不是 `can_finish=true` 时,禁止
+   AgentLoop 接受最终文本;搜索或候选变更后必须重新审计。
+3. 将年龄标准化合并到批画像或候选保存流程,避免模型跳过强制证据处理。
+4. 调整搜索/详情/解析预算。当前详情按每条约10秒串行,且一次解析6条视频,真实任务
+   延迟偏高;应先用分享量、相关性和画像可得性做更强预筛。
+5. 完成上述修改后,使用同一输入重新干跑,直到最终审计通过并输出完整双池报告。

+ 33 - 3
agents/find_agent/__init__.py

@@ -1,8 +1,38 @@
 """
-find_agent — 内容发现 Agent
+find_agent — 老年受众高潜视频发现 Agent
 
-职责:抖音搜索、视频解析、内容入库
+职责:按需求搜索抖音视频,结合内容、分享行为和双侧年龄画像进行筛选
 """
+from __future__ import annotations
+
+import logging
+
 from agents.find_agent.agent import create_find_agent
+from agents.find_agent.async_runner import _run_coroutine, arun_find_agent
+from agents.find_agent.output_sync import persist_model_recommendations
+from supply_agent.config import Settings
+from supply_agent.types import AgentResult
+
+__all__ = ["create_find_agent", "run_find_agent"]
+
+logger = logging.getLogger(__name__)
+
+
+def run_find_agent(
+    user_input: str,
+    *,
+    settings: Settings | None = None,
+    model: str | None = None,
+) -> AgentResult:
+    """同步运行 find_agent。
 
-__all__ = ["create_find_agent"]
+    find_agent 的搜索/详情/画像等工具均为 async,不能直接用 agent.run();
+    此函数内部会走 agent.arun(),并在结束前关闭异步 HTTP 客户端。
+    """
+    agent = create_find_agent(settings=settings, model=model)
+    result = _run_coroutine(arun_find_agent(agent, user_input))
+    try:
+        persist_model_recommendations(result)
+    except Exception:
+        logger.exception("persist model recommendations failed")
+    return result

+ 252 - 11
agents/find_agent/agent.py

@@ -6,22 +6,228 @@ find_agent 工厂 — 组装 Agent 实例。
 """
 from __future__ import annotations
 
+import json
+import re
+from pathlib import Path
+
 from supply_agent import Agent
 from supply_agent.config import Settings
+from supply_agent.types import Message, Role
 from agents.find_agent.tools import register_all_tools
 
-FIND_AGENT_SYSTEM_PROMPT = """\
-你是内容发现助手(find_agent),帮助用户搜索和分析短视频内容。
+_PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
+FIND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 
-## 工作流程
-1. 使用 douyin_search 搜索抖音视频
-2. 使用 douyin_detail 根据 aweme_id 批量获取详情与真实播放链接 video_url
-3. 使用 qwen_video_analyze 解析视频内容
-4. 使用 save_video_content 将结果存入数据库
-5. 使用 query_video_content 查询历史数据
+_EVIDENCE_TOOLS = {
+    "douyin_detail",
+    "get_content_fans_portrait",
+    "get_account_fans_portrait",
+    "batch_fetch_portraits",
+    "normalize_age_portraits",
+    "qwen_video_analyze",
+}
+_VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
 
-请按步骤执行,给出清晰的分析报告。
-"""
+
+def _report_section(
+    content: str,
+    start_marker: str,
+    end_markers: tuple[str, ...],
+) -> str | None:
+    start = content.find(start_marker)
+    if start < 0:
+        return None
+    ends = [
+        position
+        for marker in end_markers
+        if (position := content.find(marker, start + len(start_marker))) >= 0
+    ]
+    end = min(ends) if ends else len(content)
+    return content[start:end]
+
+
+def _validate_report_buckets(
+    content: str,
+    state: dict[str, object],
+) -> str | None:
+    candidates = state.get("candidates")
+    if not isinstance(candidates, list):
+        return "最终状态缺少 candidates,无法校验报告分池"
+    bucket_by_id = {
+        str(item.get("aweme_id")): str(item.get("decision_bucket"))
+        for item in candidates
+        if isinstance(item, dict) and item.get("aweme_id")
+    }
+    run = state.get("run")
+    run_state = run if isinstance(run, dict) else {}
+
+    primary_section = _report_section(
+        content,
+        "主推荐",
+        ("补充推荐",),
+    )
+    backup_section = _report_section(
+        content,
+        "补充推荐",
+        ("最有竞争力", "搜索树", "缺失数据", "总结"),
+    )
+    if primary_section is None or backup_section is None:
+        return "最终报告必须分别包含“主推荐”和“补充推荐”段"
+
+    primary_ids = set(_VIDEO_ID_PATTERN.findall(primary_section))
+    backup_ids = set(_VIDEO_ID_PATTERN.findall(backup_section))
+    wrong_primary = sorted(
+        video_id
+        for video_id in primary_ids
+        if bucket_by_id.get(video_id) != "primary"
+    )
+    wrong_backup = sorted(
+        video_id
+        for video_id in backup_ids
+        if bucket_by_id.get(video_id) != "backup"
+    )
+    if wrong_primary:
+        return (
+            "主推荐段包含非 primary 候选: "
+            + ", ".join(wrong_primary[:5])
+            + "。必须按数据库 decision_bucket 输出"
+        )
+    if wrong_backup:
+        return (
+            "补充推荐段包含非 backup 候选: "
+            + ", ".join(wrong_backup[:5])
+            + "。这些条目应移到淘汰候选,不得冒充 Agent 保留结果"
+        )
+
+    primary_count = int(run_state.get("primary_count") or 0)
+    backup_count = int(run_state.get("backup_count") or 0)
+    if primary_count > 0 and not primary_ids:
+        return "数据库存在 primary 候选,但主推荐段没有输出 aweme_id"
+    if primary_count == 0 and backup_count > 0 and not backup_ids:
+        return "数据库只有 backup 保留候选,补充推荐段至少应输出一条"
+    return None
+
+
+def _successful_tool_events(
+    messages: list[Message],
+) -> list[tuple[int, str, dict[str, object]]]:
+    events: list[tuple[int, str, dict[str, object]]] = []
+    for index, message in enumerate(messages):
+        if message.role != Role.TOOL or not message.name or not message.content:
+            continue
+        try:
+            payload = json.loads(message.content)
+        except (TypeError, json.JSONDecodeError):
+            continue
+        if not isinstance(payload, dict) or payload.get("error"):
+            continue
+        events.append((index, message.name, payload))
+    return events
+
+
+def find_agent_completion_guard(messages: list[Message]) -> str | None:
+    """Reject final text until persisted state and the final audit prove completion."""
+    events = _successful_tool_events(messages)
+    if not any(name == "create_video_discovery_run" for _, name, _ in events):
+        return "尚未成功创建视频发现运行"
+
+    record_indexes = [
+        index
+        for index, name, _ in events
+        if name == "record_video_search_page"
+    ]
+    if not record_indexes:
+        return "尚未持久化任何搜索页"
+
+    evaluation_events = [
+        (index, payload)
+        for index, name, payload in events
+        if name == "batch_save_video_candidate_evaluations"
+    ]
+    if not evaluation_events:
+        return "尚未保存候选评估"
+    evaluation_index, evaluation = evaluation_events[-1]
+    if evaluation_index < max(record_indexes):
+        return "最后一次搜索发生在候选评估之后,新增候选尚未重新评估"
+    if evaluation.get("status") != "finished":
+        return "最后一次候选保存尚未把运行状态设置为 finished"
+
+    evidence_indexes = [
+        index for index, name, _ in events if name in _EVIDENCE_TOOLS
+    ]
+    if evidence_indexes and evaluation_index < max(evidence_indexes):
+        return "最后一次证据获取发生在候选评估之后,证据尚未重新保存"
+
+    audit_events = [
+        (index, payload)
+        for index, name, payload in events
+        if name in {
+            "audit_video_discovery_process",
+            "audit_video_discovery_run",
+        }
+    ]
+    if not audit_events:
+        return "尚未执行完成审计"
+    audit_index, audit = audit_events[-1]
+    if audit_index < evaluation_index:
+        post_audit_evaluations = [
+            payload
+            for index, payload in evaluation_events
+            if index > audit_index
+        ]
+        if not post_audit_evaluations or any(
+            payload.get("audit_relevant_changed") is not False
+            for payload in post_audit_evaluations
+        ):
+            return (
+                "最后一次候选评估改变了审计相关状态,尚未重新审计;"
+                "下一步只调用 audit_video_discovery_run,"
+                "不要再次保存或查询"
+            )
+    if audit_index < max(record_indexes):
+        return "最后一次搜索页保存尚未重新审计"
+    if evidence_indexes and audit_index < max(evidence_indexes):
+        return "最后一次证据获取尚未重新审计"
+    if audit.get("can_finish") is not True:
+        violations = audit.get("critical_violations")
+        if isinstance(violations, list) and violations:
+            summary = ";".join(str(item) for item in violations[:5])
+            return f"最后一次审计未通过:{summary}"
+        return "最后一次审计未通过"
+
+    state_events = [
+        (index, payload)
+        for index, name, payload in events
+        if name == "query_video_discovery_state"
+    ]
+    if not state_events:
+        return "尚未查询最终数据库状态"
+    state_index, state = state_events[-1]
+    last_changed_evaluation_index = max(
+        (
+            index
+            for index, payload in evaluation_events
+            if payload.get("audit_relevant_changed") is not False
+        ),
+        default=evaluation_index,
+    )
+    if state_index < last_changed_evaluation_index:
+        return "最终数据库状态早于最后一次有效候选变更,请重新查询"
+
+    last_assistant = next(
+        (
+            message
+            for message in reversed(messages)
+            if message.role == Role.ASSISTANT and not message.tool_calls
+        ),
+        None,
+    )
+    if last_assistant is None or not (last_assistant.content or "").strip():
+        return "最终回答为空"
+    report_error = _validate_report_buckets(last_assistant.content, state)
+    if report_error:
+        return report_error
+    return None
 
 
 def create_find_agent(
@@ -33,8 +239,43 @@ def create_find_agent(
     agent = Agent(
         settings=settings,
         name="find_agent",
-        model=model,
+        model="google/gemini-3-flash-preview",
         system_prompt=FIND_AGENT_SYSTEM_PROMPT,
+        max_iterations=60,
+        temperature=0.2,
+        # 暂停启用完成守卫;函数保留,便于后续按需恢复。
+        completion_guard=None,
+        tool_call_budgets={
+            "search": (
+                {
+                    "douyin_search",
+                    "douyin_search_tikhub",
+                    "douyin_user_videos",
+                },
+                10,
+            ),
+            "detail": ({"douyin_detail"}, 2),
+            "portrait": (
+                {
+                    "get_content_fans_portrait",
+                    "get_account_fans_portrait",
+                    "batch_fetch_portraits",
+                },
+                3,
+            ),
+            "video_analysis": ({"qwen_video_analyze"}, 3),
+            "evaluation_save": (
+                {"batch_save_video_candidate_evaluations"},
+                6,
+            ),
+            "state_query": ({"query_video_discovery_state"}, 8),
+        },
+        tool_repeat_requires_change={
+            "query_video_discovery_state": {
+                "record_video_search_page",
+                "batch_save_video_candidate_evaluations",
+            },
+        },
     )
 
     # 本 Agent 专属工具

+ 72 - 0
agents/find_agent/async_runner.py

@@ -0,0 +1,72 @@
+"""find_agent 异步运行与资源清理。"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import threading
+
+from supply_agent.agent.core import Agent
+from supply_agent.types import AgentResult
+
+logger = logging.getLogger(__name__)
+
+_thread_loop = threading.local()
+
+
+async def _drain_event_loop() -> None:
+    """等待 httpx/OpenAI 等异步客户端的收尾任务完成。"""
+    loop = asyncio.get_running_loop()
+    for _ in range(5):
+        pending = [
+            task
+            for task in asyncio.all_tasks(loop)
+            if task is not asyncio.current_task() and not task.done()
+        ]
+        if not pending:
+            break
+        results = await asyncio.gather(*pending, return_exceptions=True)
+        for result in results:
+            if isinstance(result, Exception) and not isinstance(
+                result, asyncio.CancelledError
+            ):
+                logger.debug("pending async task error during drain: %s", result)
+        await asyncio.sleep(0)
+
+
+async def _close_agent_async_resources(agent: Agent) -> None:
+    """在事件循环关闭前显式释放异步 HTTP 连接。"""
+    async_client = getattr(agent.llm, "_async_client", None)
+    if async_client is not None:
+        try:
+            await async_client.close()
+        except Exception:
+            logger.debug("close async llm client failed", exc_info=True)
+    await _drain_event_loop()
+
+
+async def arun_find_agent(agent: Agent, user_input: str) -> AgentResult:
+    """在单个事件循环内运行 find_agent 并确保资源释放。"""
+    try:
+        return await agent.arun(user_input)
+    finally:
+        await _close_agent_async_resources(agent)
+
+
+def _run_coroutine(coro) -> AgentResult:
+    """同步入口:主线程用 asyncio.run,worker 线程复用线程级 loop。"""
+    try:
+        asyncio.get_running_loop()
+    except RuntimeError:
+        pass
+    else:
+        raise RuntimeError("run_find_agent 不能在已运行的事件循环内调用")
+
+    if threading.current_thread() is threading.main_thread():
+        return asyncio.run(coro)
+
+    loop = getattr(_thread_loop, "loop", None)
+    if loop is None or loop.is_closed():
+        loop = asyncio.new_event_loop()
+        _thread_loop.loop = loop
+    asyncio.set_event_loop(loop)
+    return loop.run_until_complete(coro)

+ 400 - 0
agents/find_agent/demand_run.py

@@ -0,0 +1,400 @@
+"""从 demand_grade + demand_video_expansion 组装 find_agent 输入并执行。"""
+from __future__ import annotations
+
+import json
+import logging
+import uuid
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from supply_agent.types import AgentResult
+from supply_infra.config import get_infra_settings
+from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
+from supply_infra.db.repositories.demand_video_expansion_repo import (
+    DemandVideoExpansionRepository,
+)
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+_POINT_TYPES = {"inspiration", "purpose", "key"}
+_SKIP_STATUSES = frozenset({"running", "finished"})
+
+
+@dataclass
+class FindDemandPoint:
+    point: str
+    point_type: str
+    point_desc: str | None = None
+
+
+@dataclass
+class FindDemandVideo:
+    video_id: str
+    title: str
+    points: list[FindDemandPoint] = field(default_factory=list)
+
+
+@dataclass
+class FindDemandContext:
+    """单条 find_agent 任务:一个 S/A 需求词及其下全部视频与全部拓展点位。"""
+
+    biz_dt: str
+    demand_grade_id: int
+    demand_name: str
+    grade: str
+    videos: list[FindDemandVideo] = field(default_factory=list)
+
+    @property
+    def video_count(self) -> int:
+        return len(self.videos)
+
+    @property
+    def point_count(self) -> int:
+        return sum(len(video.points) for video in self.videos)
+
+    @property
+    def primary_video(self) -> FindDemandVideo | None:
+        return self.videos[0] if self.videos else None
+
+
+@dataclass
+class FindDemandExecutionResult:
+    """单条需求找片执行结果。"""
+
+    run_id: str | None = None
+    skipped: bool = False
+    skip_reason: str | None = None
+    agent_result: AgentResult | None = None
+
+
+def _resolve_biz_dt(biz_dt: str | None) -> str:
+    if biz_dt:
+        text = str(biz_dt).strip()
+        if len(text) == 8 and text.isdigit():
+            return text
+        raise ValueError(f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}")
+
+    with get_session() as session:
+        latest = DemandGradeRepository(session).get_latest_biz_dt()
+    if latest:
+        return str(latest)
+
+    timezone = ZoneInfo(get_infra_settings().scheduler_timezone)
+    return datetime.now(timezone).strftime("%Y%m%d")
+
+
+def _build_video_points(expansions: list[Any]) -> list[FindDemandPoint]:
+    points: list[FindDemandPoint] = []
+    seen: set[tuple[str, str]] = set()
+    for row in expansions:
+        point_type = str(row.point_type or "").strip()
+        if point_type not in _POINT_TYPES:
+            continue
+        expanded_text = str(row.expanded_text or "").strip()
+        if not expanded_text:
+            continue
+        dedupe_key = (point_type, expanded_text)
+        if dedupe_key in seen:
+            continue
+        seen.add(dedupe_key)
+        point_desc = str(row.point_desc or "").strip() or None
+        points.append(
+            FindDemandPoint(
+                point=expanded_text,
+                point_type=point_type,
+                point_desc=point_desc,
+            )
+        )
+    return points
+
+
+def _serialize_video(video: FindDemandVideo) -> dict[str, Any]:
+    return {
+        "video_id": video.video_id,
+        "title": video.title,
+        "points": [
+            {
+                "point": point.point,
+                "point_type": point.point_type,
+                **({"point_desc": point.point_desc} if point.point_desc else {}),
+            }
+            for point in video.points
+        ],
+    }
+
+
+def load_find_demand_contexts(
+    session,
+    biz_dt: str,
+    *,
+    grades: Iterable[str] = ("S", "A"),
+) -> list[FindDemandContext]:
+    """加载指定业务日 S/A 需求,每个需求词组装为一条完整上下文。"""
+    grades_rows = DemandGradeRepository(session).list_by_biz_dt_and_grades(biz_dt, grades)
+    if not grades_rows:
+        return []
+
+    expansion_repo = DemandVideoExpansionRepository(session)
+    detail_repo = MultiDemandVideoDetailRepository(session)
+
+    contexts: list[FindDemandContext] = []
+    for grade_row in grades_rows:
+        expansions = expansion_repo.list_by_demand_grade(biz_dt, int(grade_row.id))
+        if not expansions:
+            continue
+
+        by_video: dict[str, list[Any]] = {}
+        video_order: list[str] = []
+        for row in expansions:
+            video_id = str(row.video_id or "").strip()
+            if not video_id:
+                continue
+            if video_id not in by_video:
+                by_video[video_id] = []
+                video_order.append(video_id)
+            by_video[video_id].append(row)
+
+        if not video_order:
+            continue
+
+        details = detail_repo.list_by_vids(video_order)
+        videos: list[FindDemandVideo] = []
+        for video_id in video_order:
+            points = _build_video_points(by_video[video_id])
+            if not points:
+                continue
+
+            detail = details.get(video_id)
+            title = str(detail.title).strip() if detail and detail.title else ""
+            videos.append(
+                FindDemandVideo(
+                    video_id=video_id,
+                    title=title or f"(无标题|{video_id})",
+                    points=points,
+                )
+            )
+
+        if not videos:
+            continue
+
+        contexts.append(
+            FindDemandContext(
+                biz_dt=biz_dt,
+                demand_grade_id=int(grade_row.id),
+                demand_name=str(grade_row.demand_name),
+                grade=str(grade_row.grade),
+                videos=videos,
+            )
+        )
+
+    return contexts
+
+
+def pick_find_demand_context(
+    biz_dt: str | None = None,
+    *,
+    index: int = 0,
+    demand_grade_id: int | None = None,
+    grades: Iterable[str] = ("S", "A"),
+) -> FindDemandContext | None:
+    """从数据库选取一条待执行的 find_agent 上下文。"""
+    resolved_biz_dt = _resolve_biz_dt(biz_dt)
+    with get_session() as session:
+        contexts = load_find_demand_contexts(
+            session,
+            resolved_biz_dt,
+            grades=grades,
+        )
+
+    if demand_grade_id is not None:
+        for ctx in contexts:
+            if ctx.demand_grade_id == int(demand_grade_id):
+                return ctx
+        return None
+
+    if 0 <= index < len(contexts):
+        return contexts[index]
+    return None
+
+
+def list_find_demand_contexts(
+    biz_dt: str | None = None,
+    *,
+    grades: Iterable[str] = ("S", "A"),
+) -> tuple[str, list[FindDemandContext]]:
+    """返回解析后的业务日与全部待执行上下文。"""
+    resolved_biz_dt = _resolve_biz_dt(biz_dt)
+    with get_session() as session:
+        contexts = load_find_demand_contexts(
+            session,
+            resolved_biz_dt,
+            grades=grades,
+        )
+    return resolved_biz_dt, contexts
+
+
+def build_run_input_payload(ctx: FindDemandContext) -> dict[str, Any]:
+    """构建写入 video_discovery_run 的输入快照。"""
+    return {
+        "biz_dt": ctx.biz_dt,
+        "demand_grade_id": ctx.demand_grade_id,
+        "demand_name": ctx.demand_name,
+        "grade": ctx.grade,
+        "relevant_points": _flatten_relevant_points(ctx),
+        "reference_videos": [_serialize_video(video) for video in ctx.videos],
+    }
+
+
+def prepare_video_discovery_run(
+    ctx: FindDemandContext,
+    *,
+    force: bool = False,
+) -> tuple[str | None, str | None]:
+    """执行 Agent 前预创建 video_discovery_run,返回 (run_id, skip_reason)。"""
+    payload = build_run_input_payload(ctx)
+    primary = ctx.primary_video
+
+    with get_session() as session:
+        repo = VideoDiscoveryRepository(session)
+        existing = repo.get_by_biz_dt_and_grade(ctx.biz_dt, ctx.demand_grade_id)
+        if existing is not None and not force and existing.status in _SKIP_STATUSES:
+            return None, (
+                f"biz_dt={ctx.biz_dt} demand_grade_id={ctx.demand_grade_id} "
+                f"已执行过 run_id={existing.run_id} status={existing.status}"
+            )
+
+        run_id = existing.run_id if existing is not None else uuid.uuid4().hex
+        values = {
+            "run_id": run_id,
+            "biz_dt": ctx.biz_dt,
+            "demand_grade_id": ctx.demand_grade_id,
+            "demand_word": ctx.demand_name,
+            "seed_video_id": primary.video_id if primary else None,
+            "seed_video_title": primary.title if primary else None,
+            "relevant_points_json": json.dumps(payload, ensure_ascii=False),
+            "intent_summary": None,
+            "status": "running",
+            "stop_reason": None,
+        }
+        repo.upsert_scheduled_run(values)
+        logger.info(
+            "prepared video_discovery_run: biz_dt=%s demand_grade_id=%s run_id=%s demand=%s",
+            ctx.biz_dt,
+            ctx.demand_grade_id,
+            run_id,
+            ctx.demand_name,
+        )
+        return run_id, None
+
+
+def filter_pending_contexts(
+    contexts: list[FindDemandContext],
+    biz_dt: str,
+    *,
+    skip_finished: bool = True,
+) -> tuple[list[FindDemandContext], dict[str, int]]:
+    """过滤当天已执行过的需求,返回待执行列表与跳过统计。"""
+    stats = {"skipped_already_done": 0}
+    if not skip_finished or not contexts:
+        return contexts, stats
+
+    with get_session() as session:
+        skip_ids = VideoDiscoveryRepository(session).list_skip_grade_ids(biz_dt)
+
+    pending = [ctx for ctx in contexts if ctx.demand_grade_id not in skip_ids]
+    stats["skipped_already_done"] = len(contexts) - len(pending)
+    return pending, stats
+
+
+def _flatten_relevant_points(ctx: FindDemandContext) -> list[dict[str, Any]]:
+    """将全部视频点位拍平,并保留来源视频信息。"""
+    relevant_points: list[dict[str, Any]] = []
+    for video in ctx.videos:
+        for point in video.points:
+            item: dict[str, Any] = {
+                "point": point.point,
+                "point_type": point.point_type,
+                "video_id": video.video_id,
+                "video_title": video.title,
+            }
+            if point.point_desc:
+                item["point_desc"] = point.point_desc
+            relevant_points.append(item)
+    return relevant_points
+
+
+def build_find_agent_user_input(ctx: FindDemandContext, run_id: str) -> 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 ""
+
+    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"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。"
+    )
+
+
+def discover_videos_for_demand(
+    ctx: FindDemandContext,
+    *,
+    force: bool = False,
+) -> FindDemandExecutionResult:
+    """对单条需求记录执行 find_agent。"""
+    from agents.find_agent import run_find_agent
+
+    run_id, skip_reason = prepare_video_discovery_run(ctx, force=force)
+    if skip_reason:
+        logger.info(
+            "skip find_agent: demand_grade_id=%s demand=%s reason=%s",
+            ctx.demand_grade_id,
+            ctx.demand_name,
+            skip_reason,
+        )
+        return FindDemandExecutionResult(skipped=True, skip_reason=skip_reason)
+
+    if not run_id:
+        raise RuntimeError("prepare_video_discovery_run 未返回 run_id")
+
+    user_input = build_find_agent_user_input(ctx, run_id)
+    try:
+        agent_result = run_find_agent(user_input)
+        return FindDemandExecutionResult(run_id=run_id, agent_result=agent_result)
+    except Exception as exc:
+        with get_session() as session:
+            VideoDiscoveryRepository(session).mark_run_failed(
+                run_id,
+                stop_reason=str(exc),
+            )
+        raise
+
+
+def serialize_find_demand_context(ctx: FindDemandContext) -> dict[str, Any]:
+    """便于日志/测试脚本输出的结构化摘要。"""
+    return {
+        "biz_dt": ctx.biz_dt,
+        "demand_grade_id": ctx.demand_grade_id,
+        "demand_name": ctx.demand_name,
+        "grade": ctx.grade,
+        "video_count": ctx.video_count,
+        "point_count": ctx.point_count,
+        "videos": [_serialize_video(video) for video in ctx.videos],
+    }

+ 119 - 0
agents/find_agent/output_sync.py

@@ -0,0 +1,119 @@
+"""Persist the recommendation buckets stated by the model's final response."""
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+from supply_agent.types import AgentResult, Role
+from supply_infra.db.repositories.video_discovery_repo import (
+    VideoDiscoveryRepository,
+)
+from supply_infra.db.session import get_session
+
+_VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
+_PRIMARY_LABELS = ("主推荐", "正式推荐")
+_BACKUP_LABELS = ("补充推荐", "人工备选")
+_END_LABELS = (
+    "淘汰",
+    "最有竞争力",
+    "搜索轨迹",
+    "搜索树",
+    "缺失数据",
+    "局限",
+    "总结",
+)
+
+
+def _normalized_heading(line: str) -> str:
+    text = line.strip()
+    text = re.sub(r"^#{1,6}\s*", "", text)
+    text = re.sub(r"^\d+\s*[.、]\s*", "", text)
+    return text.replace("*", "").strip()
+
+
+def extract_model_recommendation_ids(
+    content: str,
+) -> tuple[list[str], list[str]]:
+    """Extract video ids from the model's primary and backup sections."""
+    primary: list[str] = []
+    backup: list[str] = []
+    section: str | None = None
+
+    for line in (content or "").splitlines():
+        heading = _normalized_heading(line)
+        if any(heading.startswith(label) for label in _PRIMARY_LABELS):
+            section = "primary"
+        elif any(heading.startswith(label) for label in _BACKUP_LABELS):
+            section = "backup"
+        elif any(heading.startswith(label) for label in _END_LABELS):
+            section = None
+
+        if section is None:
+            continue
+        target = primary if section == "primary" else backup
+        for aweme_id in _VIDEO_ID_PATTERN.findall(line):
+            if aweme_id not in target:
+                target.append(aweme_id)
+
+    primary_set = set(primary)
+    return primary, [aweme_id for aweme_id in backup if aweme_id not in primary_set]
+
+
+def _latest_run_id(result: AgentResult) -> str | None:
+    for message in reversed(result.messages):
+        if message.role != Role.TOOL or not message.content:
+            continue
+        try:
+            payload = json.loads(message.content)
+        except (TypeError, json.JSONDecodeError):
+            continue
+        if not isinstance(payload, dict):
+            continue
+        run_id = payload.get("run_id")
+        if run_id:
+            return str(run_id)
+    return None
+
+
+def persist_model_recommendations(result: AgentResult) -> dict[str, Any]:
+    """
+    Make the model's final recommendation sections authoritative in the database.
+
+    The final response itself is never validated or rewritten. Video ids listed
+    under primary/backup are persisted to those buckets exactly as stated.
+    """
+    run_id = _latest_run_id(result)
+    primary_ids, backup_ids = extract_model_recommendation_ids(result.content)
+    if not run_id or not (primary_ids or backup_ids):
+        return {
+            "run_id": run_id,
+            "primary_ids": primary_ids,
+            "backup_ids": backup_ids,
+            "saved_count": 0,
+        }
+
+    rows = [
+        {"aweme_id": aweme_id, "decision_bucket": "primary"}
+        for aweme_id in primary_ids
+    ]
+    rows.extend(
+        {"aweme_id": aweme_id, "decision_bucket": "backup"}
+        for aweme_id in backup_ids
+    )
+    with get_session() as session:
+        repo = VideoDiscoveryRepository(session)
+        if repo.get_run(run_id) is None:
+            return {
+                "run_id": run_id,
+                "primary_ids": primary_ids,
+                "backup_ids": backup_ids,
+                "saved_count": 0,
+            }
+        saved_count, _ = repo.save_candidate_evaluations(run_id, rows)
+    return {
+        "run_id": run_id,
+        "primary_ids": primary_ids,
+        "backup_ids": backup_ids,
+        "saved_count": saved_count,
+    }

+ 237 - 0
agents/find_agent/prompt/system_prompt.md

@@ -0,0 +1,237 @@
+# 角色与唯一目标
+
+你是短视频供给发现 Agent。用户会给出:
+
+- `demand_word`:需求词,定义这次寻找的真实意图边界;
+- `seed_video_title`:已知相关视频的标题,是理解语境的证据;
+- `relevant_points`:该视频中与需求词相关的一个或多个点,是理解用户究竟关注什么的证据。
+
+你的唯一目标是:从抖音搜索结果中找出一小组**与需求真正相关,并且老年受众更可能观看和转发**的视频。
+
+“偏老年”描述的是视频的实际或潜在受众,不是视频画面中出现老人,也不是标题中含有“老人”“养老”等词。不得把题材印象、人物年龄、作者年龄或刻板印象当作受众年龄证据。
+
+# 基本定义
+
+对候选视频 `v`,定义三个彼此独立的命题:
+
+- `R(v)`(需求相关性):视频是否满足 `demand_word` 在标题和相关点所限定的具体意图;
+- `E(v)`(老年受众倾向):视频点赞用户画像与作者粉丝画像是否支持受众偏向较高年龄段;
+- `S(v)`(分享价值):视频是否已经表现出值得转发的行为信号和内容理由。
+
+最终寻找的是联合事件:
+
+`G(v) = R(v) ∩ E(v) ∩ S(v)`
+
+主推荐要求三个命题共同成立。补充候选允许 `R(v)` 不成立,但必须同时具备很强的
+`E(v)` 与 `S(v)`。是否保留完全由 Agent 依据证据和分池规则决定,不再等待人工审核。
+主推荐不足目标数量时,可以使用补充候选,但必须显式标注相关性限制。
+
+# 决策公理与定理
+
+## 1. 需求闸门公理
+
+相关性是**主推荐池**的准入条件,不是加分项。一个高分享、老年粉丝很多但没有
+回答本次需求的视频,主推荐价值为零,但在老年倾向与分享价值双高时应由 Agent
+保留到独立的补充池,不能丢弃。
+
+`seed_video_title` 和 `relevant_points` 用来消除需求词的歧义、提炼事件/人物/场景/用途及同义表达;它们不是必须逐字匹配的搜索条件。搜索词只是召回假设,不能成为候选合格的证据。
+
+## 2. 搜索词自主权公理
+
+用户给出的 `demand_word` 不是必须原样提交给搜索接口的指令。你拥有搜索词的决定权。
+你应先从需求词、参考标题和相关点中判断真正可能受欢迎的内容对象、事件、冲突、用途、
+情绪或叙事角度,再形成多个语义不同的搜索假设。
+
+一个搜索词只代表一种召回视角。不得因为输入中出现某个词就机械搜索它,也不得因为
+第一次搜索有结果就认为已经覆盖需求。搜索词的好坏由它带来的新增有效候选衡量,而不
+由它与输入的字面相似度衡量。
+
+## 3. 搜索前沿扩展定理
+
+搜索是一个可生长的探索图,而不是一次调用:
+
+- 根节点来自需求语义、参考视频标题和相关点揭示的不同内容假设;
+- 优质候选的 `topic_list`、话题标签、标题实体和视频分析中出现的新角度,可以成为
+  子节点;只有与本次目标内容相关或可能产生高价值补充候选的标签才允许扩展;
+- `has_more=true` 与 `next_cursor` 表示同一关键词仍有搜索前沿,翻页不是重复搜索;
+- 每个新词和每一页都必须记录来源,使最终能够解释“为什么搜这个词”。
+- `demand / seed / point / mixed` 表示独立根搜索,保存时不得设置
+  `parent_search_id`;`tag / author / pagination` 表示扩展分支,才设置父搜索。
+  保存工具会按这一语义自动规范根节点和翻页节点。
+
+在保留候选不足 5 条时,优先验证不同的根搜索假设,并对产生新增候选的页面做翻页或
+标签扩展。达到 5 条后,未完成但价值较低的根搜索、翻页或标签前沿只作为 warning。
+若合理搜索前沿已经耗尽,少于 5 条也允许结束,不能为了数量扩大到明显低质内容。
+
+## 4. 分享—年龄不可替代定理
+
+- `share_count` 高,只能说明内容有传播行为,不能说明分享者是老年人;
+- 老年年龄段占比或偏好度高,只能说明受众偏老,不能说明他们愿意分享;
+- 只有同一候选同时具备分享证据和年龄证据,才允许推断“老年人可能喜欢并分享”。
+
+工具提供的是内容点赞用户画像,不是转发用户画像。结论必须表述为概率判断,不得伪称已经观测到老年分享者。
+
+## 5. 受众证据层级定理
+
+年龄判断的证据强度从高到低为:
+
+1. 候选视频自身的点赞用户年龄画像;
+2. 同一候选作者的粉丝年龄画像;
+3. 视频内容表现出的易理解、怀旧、实用、家庭沟通或公共话题等适配特征;
+4. 标题、题材或作者形象带来的直觉。
+
+第 4 层不得单独形成老年倾向结论。视频画像代表“这条内容吸引了谁”,作者画像代表“这个账号通常触达谁”;前者是直接内容证据,后者是账号先验。两者一致时增强置信度;冲突时优先视频画像并显式降置信度,不得静默平均。
+
+年龄桶中,明确覆盖 `50岁及以上` 的桶才是直接老年信号;接口实际可能用 `50-`
+表示“50岁以上”,必须用 `normalize_age_portraits` 标准化后再判断。只提供
+`40岁及以上` 时只能称为“成熟人群代理信号”。同时观察占比和偏好度/TGI:占比回答
+“人多不多”,偏好度回答“相对平台基线是否更偏爱”。若接口没有给出中性基线,不得
+臆造阈值。
+
+## 6. 相对传播定理
+
+原始分享数受曝光规模影响,不能独立代表分享效率。分享价值必须同时考虑:
+
+- 规模:`log(1 + share_count)` 在本次同类候选中的相对位置;
+- 效率:有可靠播放数时参考 `share_count / play_count`;否则参考平滑后的 `share_count / like_count`,并明确它只是替代指标;
+- 动机:内容是否有可转交给家人朋友的实用信息、情感认同、共同记忆、提醒价值或谈资价值。
+
+不能跨不同搜索语境机械比较原始分享数,也不能因分母很小造成的高比率把低样本视频排到最前。
+
+## 7. 联合短板定理
+
+对 `R、E、S` 分别作 `0~1` 的证据评分时,综合价值采用加权几何关系,而不是简单相加:
+
+`V(v) = 100 × R(v)^0.40 × E(v)^0.35 × S(v)^0.25`
+
+这意味着任一维度接近零,整体价值都会被明显压低。评分用于保持排序一致,不得制造虚假精确性;最终报告应展示整数分、原始数据和理由。
+
+`R/E/S/V` 是帮助你保持判断一致的参考量,不是程序校验线。候选最终进入
+`primary / backup / rejected` 完全由你根据全部证据判断。保存工具不会重算分数、
+设置画像上限或改写你的 `decision_bucket`。
+
+补充候选不是低质量主推荐。它表达的是“与本次需求无关或关系较弱,但已意外发现
+老年倾向和分享性都很强”。必须保留标题、链接、画像、分享数据和不相关原因。
+优先目标是保留至少 5 条质量可靠的视频,可以超过 5 条。先按 `V` 使用 `primary`;
+不足 5 条时,再按 `V` 使用 `backup`。5 条是搜索和筛选的优先目标,不是硬性准入线:
+在合理搜索、翻页和扩展后确实没有更多好视频时,允许少于 5 条,禁止为凑数保留明显
+低质或缺乏基本证据的候选。补充项必须标记“补充推荐”和限制,不能伪装成主推荐。
+
+在初筛阶段,低相关候选若原始分享规模或分享效率处于当前结果前列,不得因相关性低而
+立即丢弃;它应进入“备选探测队列”取得年龄画像,再由你结合全部证据决定保留或淘汰。
+
+## 8. 反证优先公理
+
+一个强反证比多个弱正向线索更重要。实际内容若围绕青少年校园、年轻圈层黑话、需要特定年轻文化背景,或画像明显偏年轻,应降低老年倾向;但剪辑快、使用网络表达等单个风格特征不能直接证明老年人不喜欢。
+
+缺失数据不是负证据,接口失败也不是零分。应标为“未知”并降低置信度,绝不能把未知写成不适合。
+
+## 9. 多样性边际定理
+
+高度重复的视频只保留证据更强的一条。价值接近时,优先覆盖不同的需求相关点或分享动机,使结果集提供新增价值,而不是同质内容堆叠。
+
+## 10. 信息价值停止律
+
+只有当一次额外搜索、详情、画像或视频解析有可能改变准入、排序或置信度时,它才有价值。证据已经足够区分候选时停止;证据不足以支持任何候选时返回“暂无可靠推荐”,不得为了凑数放宽公理。
+
+# 工具的证据含义
+
+- `douyin_search`:用于召回候选并取得初始互动量。搜索结果不是最终事实,重复候选按 `aweme_id` 去重。
+- `douyin_search_tikhub`:独立的 TikHub 搜索来源,返回标签、更多互动字段和
+  `cursor / search_id / backtrace`。使用它翻页时,三项状态必须原样传回;不得把
+  TikHub 和内部搜索的游标混用。若未配置 `TIKHUB_API_KEY` 或接口失败,保存失败原因
+  后改用内部搜索,不要用相同参数反复重试。
+- `douyin_user_videos`:当候选作者的粉丝画像偏老、或其视频具有较高主推荐/补充
+  潜力时,按最热或最新扩展作者作品。作者作品属于 `author` 搜索分支,仍需逐条判断
+  相关性、老年倾向和分享价值,不能因作者优秀就直接推荐。
+- `douyin_detail`:用于核验候选的最新互动数据、作者、页面链接和可分析的视频地址。
+- `batch_fetch_portraits`:用于批量取得视频点赞画像;对正式候选应设置
+  `fetch_account_portrait=true`,同时取得作者粉丝画像。批量结果会自动附带
+  `age_normalization`,无需为同一候选再单独调用标准化工具。
+- `get_content_fans_portrait` / `get_account_fans_portrait`:用于补充或复核单条画像。
+- `normalize_age_portraits`:把视频与作者画像中的 `50- / 50+ / 50岁以上 / 41-50`
+  等年龄桶统一为直接老年比例、TGI、成熟代理比例和证据强度;取得画像后必须调用,
+  不得自行猜测 `50-` 的含义。使用 `batch_fetch_portraits` 时已经自动执行同一标准化,
+  不要重复调用。
+- `qwen_video_analyze`:用于核验高潜候选的真实内容、相关性和分享动机。它不能代替年龄画像。解析提示中必须包含本次 `demand_word` 和 `relevant_points`,并要求区分“视频明确呈现”与“模型推断”。
+- `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`:原样保存你给出的详情、证据、评分和
+  `decision_bucket`。工具不会校验或修改你的判断。每个候选至少传入 `aweme_id` 和
+  你决定的 `decision_bucket`;其他证据、理由和分数尽量完整传入。
+- `query_video_discovery_state`:恢复长搜索的已探索关键词、翻页状态和候选分池,也用于
+  查看已经保存的模型决定。
+
+优先让廉价证据淘汰没有主推荐价值、也没有补充潜力的候选。详情、双画像和视频解析
+用于仍可能进入主推荐或补充候选的候选。所有工具失败都保留原始错误语义,不得编造
+缺失字段。
+
+若 `create_video_discovery_run` 明确返回数据库表未初始化或数据库不可用,只尝试一次:
+之后在当前上下文中维护同样的 searches/candidates 结构,继续完成搜索和双池判断,
+不得因持久化失败放弃找片,也不得反复调用失败的存储工具。最终必须醒目标注“结果未
+持久化”及失败原因。其他数据库错误不应被假定为可忽略。
+
+# 成本与迭代预算
+
+- 根搜索默认形成2~3个语义不同的词;每个生产性首页最多继续1页,除非第二页仍显著
+  提升主推荐或补充候选质量;
+- 搜索结果先按需求相关性、分享规模/效率和备选潜力做廉价预筛,默认最多选择8条进入
+  `douyin_detail` 和双画像阶段;
+- 默认最多选择3条最可能改变主推荐或补充候选的候选调用 `qwen_video_analyze`;
+  只有确实需要补充内容证据时才增加;
+- 执行新搜索、翻页、详情、画像或视频解析后,应重新保存受影响候选;
+- 不得用“接下来我会继续”作为最终回答。当前不依赖运行时完成守卫,Agent 必须自行
+  决定何时结束。
+- 数据库保留数量达到 5 条后,若没有明显更高价值的搜索前沿,优先结束任务。
+- 保留数量不足 5 条时,优先继续有效的搜索、翻页或扩标签;合理前沿已经耗尽,或剩余
+  候选明显不值得保留时,可以少于 5 条结束。
+
+# 完成条件
+
+一次任务优先在数据库中正确保留至少 5 条符合 `primary` 或 `backup` 规则的视频,
+可以保留更多。若经过合理搜索仍没有 5 条合格视频,则保留全部真正合格的候选后结束,
+不能降低基本质量要求硬凑数量;确实没有合格候选时可以返回空结果。
+
+硬性完成条件:
+
+- 数据库可用时,已创建发现运行且每个搜索页都已持久化;数据库不可用时,已在内存
+  结构中完整保留搜索页并在最终报告披露未持久化;
+- 推荐按联合价值排序,优先保证 5 条,可以超过 5 条;确实没有足够好视频时允许更少;
+- 数据库可用时,把你决定的候选分池和运行完成状态持久化;
+- 最终报告中列为主推荐或补充推荐的视频,会按你的最终文字同步为对应数据库状态。
+
+以下探索项在不足 5 条时应优先执行;但它们只作为 warning,不把 5 条变成硬门槛:
+
+- 独立根搜索词少于 2 个;
+- 生产性首页尚未翻页;
+- 值得扩展的标签尚未建立搜索分支。
+
+# 最终输出契约
+
+先用一句话复述你对需求意图的理解,然后分别输出“主推荐”和“补充推荐”。每条必须包含:
+
+- 排名、标题、作者、抖音页面链接、`aweme_id`;
+- 命中的需求点及相关性证据;
+- 原始 `share_count`,以及可计算时的分享率替代指标;
+- 视频点赞年龄画像证据;
+- 作者粉丝年龄画像证据;
+- 老年人可能愿意分享的内容动机;
+- `R / E / S / V` 整数分、置信度(高/中/低);
+- 一句包含正证与主要限制的推荐理由。
+
+输出顺序:
+
+1. **主推荐**:列出你最终决定推荐的视频;
+2. **补充推荐**:主推荐不足 5 条时,可以列出你认为值得保留的补充项;有更多可靠
+   候选时可以超过 5 条。明确标注每条限制;没有合格项时写“无”;
+3. 最有竞争力但被淘汰的候选及淘汰理由;
+4. 搜索树:实际搜索词、形成来源、已翻页数、标签扩展关系和新增候选数;
+5. 缺失数据、画像冲突、未继续的搜索前沿及接口失败。
+
+决定均由 Agent 作出。程序不会根据阈值重新解释或修改你的最终推荐。
+
+禁止输出没有证据支撑的年龄结论,禁止把“内容讲老人”写成“观看者是老人”,禁止为了满足数量而推荐低相关视频。

+ 2 - 2
agents/find_agent/run.py

@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 """Run find_agent interactively."""
 
-from agents.find_agent import create_find_agent
+from agents.find_agent import create_find_agent, run_find_agent
 
 
 def main() -> None:
@@ -18,7 +18,7 @@ def main() -> None:
             break
         if not user_input or user_input.lower() in ("exit", "quit", "q"):
             break
-        result = agent.run(user_input)
+        result = run_find_agent(user_input)
         print(f"\nAgent> {result.content}\n")
 
 

+ 25 - 0
agents/find_agent/tools/__init__.py

@@ -10,31 +10,56 @@ from typing import Any
 
 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,
     get_account_fans_portrait,
     get_content_fans_portrait,
 )
 from agents.find_agent.tools.qwen_video_analyze import qwen_video_analyze
+from agents.find_agent.tools.video_discovery_store import (
+    batch_save_video_candidate_evaluations,
+    create_video_discovery_run,
+    query_video_discovery_state,
+    record_video_search_page,
+)
 from supply_agent.tools.registry import ToolRegistry
 
 ALL_TOOLS: list[Callable[..., Any]] = [
     douyin_search,
+    douyin_search_tikhub,
+    douyin_user_videos,
     douyin_detail,
     get_content_fans_portrait,
     get_account_fans_portrait,
     batch_fetch_portraits,
+    normalize_age_portraits,
     qwen_video_analyze,
+    create_video_discovery_run,
+    record_video_search_page,
+    batch_save_video_candidate_evaluations,
+    query_video_discovery_state,
 ]
 
 __all__ = [
     "ALL_TOOLS",
     "douyin_search",
+    "douyin_search_tikhub",
+    "douyin_user_videos",
     "douyin_detail",
     "get_content_fans_portrait",
     "get_account_fans_portrait",
     "batch_fetch_portraits",
+    "normalize_age_portraits",
     "qwen_video_analyze",
+    "create_video_discovery_run",
+    "record_video_search_page",
+    "batch_save_video_candidate_evaluations",
+    "query_video_discovery_state",
     "register_all_tools",
 ]
 

+ 418 - 0
agents/find_agent/tools/decision_support.py

@@ -0,0 +1,418 @@
+"""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):
+        return None
+    text = str(value).strip().replace("%", "")
+    if not text:
+        return None
+    try:
+        return float(text)
+    except ValueError:
+        return None
+
+
+def _ratio(value: Any) -> float | None:
+    number = _number(value)
+    if number is None:
+        return None
+    if number > 1:
+        number /= 100
+    return min(max(number, 0.0), 1.0)
+
+
+def _age_dimension(portrait: dict[str, Any] | None) -> dict[str, Any]:
+    if not isinstance(portrait, dict):
+        return {}
+    for wrapper_key in ("portrait_data", "content", "account"):
+        wrapped = portrait.get(wrapper_key)
+        if isinstance(wrapped, dict):
+            wrapped_dimension = _age_dimension(wrapped)
+            if wrapped_dimension:
+                return wrapped_dimension
+    for key in ("年龄", "age", "Age", "年龄分布"):
+        value = portrait.get(key)
+        if isinstance(value, dict):
+            return value
+    if portrait and all(isinstance(value, dict) for value in portrait.values()):
+        return portrait
+    return {}
+
+
+def _bucket_kind(label: str) -> str:
+    compact = (
+        label.strip()
+        .lower()
+        .replace("岁", "")
+        .replace("以上", "+")
+        .replace("及", "")
+        .replace(" ", "")
+    )
+    if compact in {"50-", "50+", ">=50", "≥50", "51+", "60+", ">=60", "≥60"}:
+        return "older"
+    if re.search(r"(?:^|[^0-9])(50|51|60)\+$", compact):
+        return "older"
+
+    numbers = [int(value) for value in re.findall(r"\d+", compact)]
+    if not numbers:
+        return "unknown"
+    if len(numbers) == 1:
+        if numbers[0] >= 50 and any(
+            marker in compact for marker in ("+", ">=", "≥", "以上")
+        ):
+            return "older"
+        return "unknown"
+
+    lower, upper = min(numbers), max(numbers)
+    if lower >= 50:
+        return "older"
+    if lower >= 40 or upper >= 50:
+        return "mature"
+    return "younger"
+
+
+def _normalize_portrait(portrait: dict[str, Any] | None) -> dict[str, Any]:
+    dimension = _age_dimension(portrait)
+    buckets: list[dict[str, Any]] = []
+    older_ratio = 0.0
+    mature_ratio = 0.0
+    older_tgi_values: list[tuple[float, float]] = []
+
+    for label, raw_metrics in dimension.items():
+        metrics = raw_metrics if isinstance(raw_metrics, dict) else {}
+        percentage = _ratio(
+            metrics.get("percentage")
+            if "percentage" in metrics
+            else metrics.get("ratio")
+        )
+        tgi = _number(
+            metrics.get("preference")
+            if "preference" in metrics
+            else metrics.get("tgi")
+        )
+        kind = _bucket_kind(str(label))
+        buckets.append(
+            {
+                "label": str(label),
+                "kind": kind,
+                "ratio": percentage,
+                "tgi": tgi,
+            }
+        )
+        if percentage is None:
+            continue
+        if kind == "older":
+            older_ratio += percentage
+            if tgi is not None:
+                older_tgi_values.append((tgi, percentage))
+        elif kind == "mature":
+            mature_ratio += percentage
+
+    weighted_tgi = None
+    tgi_weight = sum(weight for _, weight in older_tgi_values)
+    if tgi_weight:
+        weighted_tgi = sum(tgi * weight for tgi, weight in older_tgi_values) / tgi_weight
+
+    if not dimension:
+        strength = "missing"
+    elif (
+        older_ratio >= 0.45
+        or (older_ratio >= 0.35 and (weighted_tgi or 0) >= 100)
+        or (older_ratio >= 0.20 and (weighted_tgi or 0) >= 130)
+    ):
+        strength = "strong"
+    elif (
+        older_ratio >= 0.20
+        or (older_ratio > 0 and (weighted_tgi or 0) >= 100)
+        or mature_ratio >= 0.30
+    ):
+        strength = "moderate"
+    else:
+        strength = "weak"
+
+    return {
+        "has_age_portrait": bool(dimension),
+        "older_ratio": round(older_ratio, 6),
+        "older_tgi": round(weighted_tgi, 4) if weighted_tgi is not None else None,
+        "mature_ratio": round(mature_ratio, 6),
+        "strength": strength,
+        "buckets": buckets,
+    }
+
+
+def normalize_age_portrait_pair(
+    content_portrait: dict[str, Any] | None,
+    account_portrait: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+    """Return the deterministic two-sided age normalization payload."""
+    content = _normalize_portrait(content_portrait)
+    account = _normalize_portrait(account_portrait)
+    content_has = content["has_age_portrait"]
+    account_has = account["has_age_portrait"]
+
+    if content_has and account_has:
+        strong_set = {"strong", "moderate"}
+        consistency = (
+            "aligned"
+            if (content["strength"] in strong_set)
+            == (account["strength"] in strong_set)
+            else "conflict"
+        )
+        cap = 1.0
+    elif account_has:
+        consistency = "account_only"
+        cap = 0.65
+    elif content_has:
+        consistency = "content_only"
+        cap = 1.0
+    else:
+        consistency = "missing"
+        cap = 0.35
+
+    return {
+        "content": content,
+        "account": account,
+        "consistency": consistency,
+        "elder_score_cap": cap,
+    }
+
+
+@tool
+def normalize_age_portraits(
+    content_portrait: dict[str, Any],
+    account_portrait: dict[str, Any] | None = None,
+) -> str:
+    """
+    标准化视频点赞画像与作者粉丝画像中的年龄桶。
+
+    能识别接口实际返回的 `50-`,以及 `50+ / 50岁以上 / >=50 / 41-50`
+    等表达,统一输出直接老年比例、TGI、成熟人群代理比例和证据强度。
+    本工具只标准化证据,不把点赞用户伪装成转发用户。
+
+    Args:
+        content_portrait: 视频 portrait_data,或其中的年龄字典。
+        account_portrait: 可选作者 portrait_data,或其中的年龄字典。
+
+    Returns:
+        JSON,包含 content、account、consistency 和 elder_score_cap。
+    """
+    normalized = normalize_age_portrait_pair(content_portrait, account_portrait)
+    content = normalized["content"]
+    account = normalized["account"]
+    consistency = normalized["consistency"]
+    cap = normalized["elder_score_cap"]
+    result = {
+        "title": "年龄画像标准化",
+        **normalized,
+        "output": (
+            f"视频侧={content['strength']},作者侧={account['strength']},"
+            f"一致性={consistency},E上限={cap}"
+        ),
+    }
+    return json.dumps(result, ensure_ascii=False)
+
+
+def _float_score(candidate: dict[str, Any], key: str) -> float | None:
+    value = candidate.get(key)
+    try:
+        number = float(value)
+    except (TypeError, ValueError):
+        return None
+    return number if 0 <= number <= 1 else None
+
+
+@tool
+def audit_video_discovery_process(
+    searches: list[dict[str, Any]],
+    candidates: list[dict[str, Any]],
+    intended_status: str = "finished",
+) -> str:
+    """
+    在结束找片前审计搜索树、翻页、标签扩展、证据完备性和双池分流。
+
+    Args:
+        searches: 已执行搜索页。建议包含 search_id、keyword、source_type、
+            parent_search_id、cursor、page_no、has_more、new_candidate_count。
+        candidates: 已评估候选。建议包含 aweme_id、decision_bucket、R/E/S 分数、
+            detail_verified、content_portrait_attempted、account_portrait_attempted、
+            content_analysis_verified、has_video_url、expansion_worthy_tags。
+        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,
+        "backup": 0,
+        "rejected": 0,
+        "pending_evaluation": 0,
+    }
+    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"
+        bucket_counts[bucket] = bucket_counts.get(bucket, 0) + 1
+        relevance = _float_score(candidate, "relevance_score")
+        elder = _float_score(candidate, "elder_score")
+        share = _float_score(candidate, "share_score")
+
+        if bucket in {"primary", "backup"}:
+            if 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 candidate.get("has_video_url") and not candidate.get(
+                "content_analysis_verified"
+            ):
+                critical.append(f"{aweme_id} 有播放地址但未核验视频内容")
+
+        if bucket == "primary" and not (
+            relevance is not None
+            and relevance >= 0.55
+            and elder is not None
+            and elder >= 0.55
+            and share is not None
+            and share >= 0.50
+        ):
+            critical.append(f"{aweme_id} 不满足 primary 阈值")
+        meets_primary = (
+            relevance is not None
+            and relevance >= 0.55
+            and elder is not None
+            and elder >= 0.55
+            and share is not None
+            and share >= 0.50
+        )
+        meets_backup = (
+            not meets_primary
+            and elder is not None
+            and elder >= 0.50
+            and share is not None
+            and share >= 0.50
+        )
+        if bucket == "backup" and not meets_backup:
+            critical.append(f"{aweme_id} 不满足 backup 阈值")
+        if bucket == "rejected" and (meets_primary or meets_backup):
+            critical.append(f"{aweme_id} 达到保留线却被错误淘汰")
+        if bucket == "pending_evaluation":
+            warnings.append(f"{aweme_id} 等待 Agent 补证和评估")
+
+    retained_count = (
+        bucket_counts.get("primary", 0)
+        + bucket_counts.get("backup", 0)
+    )
+    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)

+ 16 - 2
agents/find_agent/tools/douyin_detail.py

@@ -24,6 +24,7 @@ _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/"
 
@@ -197,8 +198,16 @@ def _build_output_summary(
     return "\n".join(lines).rstrip()
 
 
-def _error_result(error: str, *, title: str = "抖音详情获取失败") -> str:
-    return json.dumps({"error": error, "title": title}, ensure_ascii=False)
+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:
@@ -265,6 +274,11 @@ async def douyin_detail(
 
     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]] = []

+ 405 - 0
agents/find_agent/tools/douyin_search_tikhub.py

@@ -0,0 +1,405 @@
+"""通过 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 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"
+)
+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()
+
+
+@tool
+async def douyin_search_tikhub(
+    keyword: str,
+    content_type: str = "视频",
+    sort_type: str = "综合排序",
+    publish_time: str = "不限",
+    cursor: int = 0,
+    filter_duration: str = "不限",
+    search_id: str = "",
+    backtrace: str = "",
+    min_duration_seconds: int = 0,
+    timeout: float | None = None,
+) -> str:
+    """
+    使用 TikHub 搜索抖音视频,支持多关键词探索和完整分页状态。
+
+    这是 douyin_search 的独立搜索来源。首次搜索 cursor=0、search_id/backtrace 为空;
+    翻页时必须把上次返回的 next_cursor、search_id、backtrace 原样传回。
+
+    Args:
+        keyword: Agent 自主确定的实际搜索词。
+        content_type: 不限 / 视频 / 图片 / 文章,默认视频;也兼容 TikHub 数字代码。
+        sort_type: 综合排序 / 最多点赞 / 最新发布;也兼容 0 / 1 / 2。
+        publish_time: 不限 / 一天内 / 一周内 / 半年内;也兼容 0 / 1 / 7 / 180。
+        cursor: 首次为 0,翻页使用上次返回的 next_cursor。
+        filter_duration: 不限 / 一分钟内 / 1-5分钟 / 5分钟以上。
+        search_id: 翻页状态,必须使用同一搜索返回值。
+        backtrace: 翻页回溯状态,必须使用同一搜索返回值。
+        min_duration_seconds: 客户端最短时长过滤,默认 0 表示不过滤。
+        timeout: 请求超时秒数,默认 60。
+
+    Returns:
+        JSON 字符串。search_results 与 douyin_search 格式兼容,并额外包含
+        duration_ms、topics、收藏数和播放数;分页字段为 has_more、next_cursor、
+        search_id、backtrace。
+    """
+    keyword_text = str(keyword).strip()
+    if not keyword_text:
+        return _error_result("keyword 不能为空")
+
+    try:
+        content_type_value = _enum_value(content_type, _CONTENT_TYPE_MAP, "content_type")
+        sort_type_value = _enum_value(sort_type, _SORT_TYPE_MAP, "sort_type")
+        publish_time_value = _enum_value(
+            publish_time, _PUBLISH_TIME_MAP, "publish_time"
+        )
+        duration_value = _enum_value(
+            filter_duration, _DURATION_MAP, "filter_duration"
+        )
+    except ValueError as exc:
+        return _error_result(str(exc))
+
+    _ensure_env_loaded()
+    api_key = os.getenv("TIKHUB_API_KEY", "").strip()
+    if not api_key:
+        return _error_result("未设置环境变量 TIKHUB_API_KEY")
+
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    payload = {
+        "keyword": keyword_text,
+        "cursor": max(0, int(cursor)),
+        "sort_type": sort_type_value,
+        "publish_time": publish_time_value,
+        "filter_duration": duration_value,
+        "content_type": content_type_value,
+        "search_id": str(search_id or ""),
+        "backtrace": str(backtrace or ""),
+    }
+
+    try:
+        await _wait_rate_limit()
+        async with httpx.AsyncClient(
+            timeout=request_timeout,
+            trust_env=False,
+            headers={
+                "Content-Type": "application/json",
+                "Authorization": f"Bearer {api_key}",
+            },
+        ) as client:
+            response = await client.post(DOUYIN_SEARCH_TIKHUB_API, json=payload)
+            response.raise_for_status()
+            body = response.json()
+
+        data = body.get("data") if isinstance(body.get("data"), dict) else {}
+        business_data = (
+            data.get("business_data")
+            if isinstance(data.get("business_data"), list)
+            else []
+        )
+        config = (
+            data.get("business_config")
+            if isinstance(data.get("business_config"), dict)
+            else {}
+        )
+        next_page = (
+            config.get("next_page")
+            if isinstance(config.get("next_page"), dict)
+            else {}
+        )
+
+        results: list[dict[str, Any]] = []
+        seen: set[str] = set()
+        filtered_count = 0
+        minimum_ms = max(0, int(min_duration_seconds)) * 1000
+        for raw_item in business_data:
+            normalized = _normalize_aweme(_get_aweme_info(raw_item))
+            if normalized is None or normalized["aweme_id"] in seen:
+                continue
+            if (
+                minimum_ms
+                and normalized["duration_ms"]
+                and normalized["duration_ms"] < minimum_ms
+            ):
+                filtered_count += 1
+                continue
+            seen.add(normalized["aweme_id"])
+            results.append(normalized)
+
+        has_more = bool(config.get("has_more") in (1, True, "1"))
+        next_cursor = _safe_int(next_page.get("cursor"))
+        next_search_id = str(next_page.get("search_id") or search_id or "")
+        next_backtrace = str(
+            next_page.get("backtrace") or config.get("backtrace") or backtrace or ""
+        )
+        duration_ms = int((time.time() - start_time) * 1000)
+        result = {
+            "title": f"TikHub 抖音搜索: {keyword_text}",
+            "output": _summary(
+                keyword_text,
+                results,
+                filtered_count=filtered_count,
+                has_more=has_more,
+                next_cursor=next_cursor,
+                search_id=next_search_id,
+            ),
+            "provider": "tikhub",
+            "keyword": keyword_text,
+            "request_params": payload,
+            "results_count": len(results),
+            "filtered_count": filtered_count,
+            "has_more": has_more,
+            "next_cursor": next_cursor,
+            "search_id": next_search_id,
+            "backtrace": next_backtrace,
+            "search_results": results,
+            "duration_ms": duration_ms,
+        }
+        logger.info(
+            "douyin_search_tikhub completed: keyword=%s results=%d has_more=%s duration_ms=%d",
+            keyword_text,
+            len(results),
+            has_more,
+            duration_ms,
+        )
+        return json.dumps(result, ensure_ascii=False)
+    except httpx.HTTPStatusError as exc:
+        text = exc.response.text[:1000]
+        logger.error(
+            "douyin_search_tikhub HTTP error: keyword=%s status=%d",
+            keyword_text,
+            exc.response.status_code,
+        )
+        return _error_result(f"HTTP {exc.response.status_code}: {text}")
+    except httpx.TimeoutException:
+        return _error_result(f"请求超时({request_timeout}秒)")
+    except httpx.RequestError as exc:
+        return _error_result(f"网络错误: {exc}")
+    except Exception as exc:
+        logger.error(
+            "douyin_search_tikhub unexpected error: keyword=%s error=%s",
+            keyword_text,
+            exc,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {exc}")

+ 264 - 0
agents/find_agent/tools/douyin_user_videos.py

@@ -0,0 +1,264 @@
+"""查询抖音作者作品,输出 find_agent 统一候选格式。"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from typing import Any
+
+import httpx
+
+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(
+    account_id: str,
+    sort_type: str = "最热",
+    cursor: str = "",
+    min_duration_seconds: int = 0,
+    timeout: float | None = None,
+) -> str:
+    """
+    获取指定抖音作者的作品列表,支持最热/最新排序和游标翻页。
+
+    当候选作者的粉丝画像偏老或某条视频表现优秀时,可用该工具扩展同作者内容。
+    返回结构与 douyin_search.search_results 一致,可直接保存为搜索轨迹和候选。
+
+    Args:
+        account_id: author.sec_uid,必须使用完整值。
+        sort_type: 最热 / 最新,默认最热。
+        cursor: 首次为空;翻页使用上次返回的 next_cursor。
+        min_duration_seconds: 最短时长过滤,默认 0 表示不过滤。
+        timeout: 请求超时秒数,默认 60。
+
+    Returns:
+        JSON 字符串,包含 user_videos、search_results、has_more 和 next_cursor。
+        user_videos 与 search_results 是同一个统一结构化列表。
+    """
+    account_text = str(account_id).strip()
+    if not account_text:
+        return _error_result("account_id 不能为空")
+    if sort_type not in _SORT_TYPES:
+        return _error_result(f"sort_type 必须是: {sorted(_SORT_TYPES)}")
+
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    payload = {
+        "account_id": account_text,
+        "sort_type": sort_type,
+        "cursor": str(cursor or ""),
+    }
+
+    try:
+        await _wait_rate_limit()
+        async with httpx.AsyncClient(
+            timeout=request_timeout,
+            trust_env=False,
+            headers={"Content-Type": "application/json"},
+        ) as client:
+            response = await client.post(DOUYIN_USER_VIDEOS_API, json=payload)
+            response.raise_for_status()
+            body = response.json()
+
+        data = body.get("data") if isinstance(body.get("data"), dict) else {}
+        items = data.get("data") if isinstance(data.get("data"), list) else []
+        minimum_ms = max(0, int(min_duration_seconds)) * 1000
+        filtered_count = 0
+        seen: set[str] = set()
+        results: list[dict[str, Any]] = []
+        for raw_item in items:
+            if not isinstance(raw_item, dict):
+                continue
+            normalized = _normalize_video(raw_item)
+            if normalized is None or normalized["aweme_id"] in seen:
+                continue
+            if (
+                minimum_ms
+                and normalized["duration_ms"]
+                and normalized["duration_ms"] < minimum_ms
+            ):
+                filtered_count += 1
+                continue
+            seen.add(normalized["aweme_id"])
+            results.append(normalized)
+
+        has_more = bool(data.get("has_more") in (1, True, "1"))
+        next_cursor = str(data.get("next_cursor") or "")
+        duration_ms = int((time.time() - start_time) * 1000)
+        result = {
+            "title": f"抖音作者作品: {account_text}",
+            "output": _summary(
+                account_text,
+                results,
+                filtered_count=filtered_count,
+                has_more=has_more,
+                next_cursor=next_cursor,
+            ),
+            "provider": "internal_blogger",
+            "account_id": account_text,
+            "sort_type": sort_type,
+            "cursor": str(cursor or ""),
+            "results_count": len(results),
+            "filtered_count": filtered_count,
+            "has_more": has_more,
+            "next_cursor": next_cursor,
+            "user_videos": results,
+            "search_results": results,
+            "duration_ms": duration_ms,
+        }
+        logger.info(
+            "douyin_user_videos completed: account_id=%s results=%d has_more=%s duration_ms=%d",
+            account_text,
+            len(results),
+            has_more,
+            duration_ms,
+        )
+        return json.dumps(result, ensure_ascii=False)
+    except httpx.HTTPStatusError as exc:
+        text = exc.response.text[:1000]
+        logger.error(
+            "douyin_user_videos HTTP error: account_id=%s status=%d",
+            account_text,
+            exc.response.status_code,
+        )
+        return _error_result(f"HTTP {exc.response.status_code}: {text}")
+    except httpx.TimeoutException:
+        return _error_result(f"请求超时({request_timeout}秒)")
+    except httpx.RequestError as exc:
+        return _error_result(f"网络错误: {exc}")
+    except Exception as exc:
+        logger.error(
+            "douyin_user_videos unexpected error: account_id=%s error=%s",
+            account_text,
+            exc,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {exc}")

+ 48 - 12
agents/find_agent/tools/hotspot_profile.py

@@ -14,11 +14,12 @@ 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
 
 logger = logging.getLogger(__name__)
 
-BATCH_MAX_ITEMS = 30
+BATCH_MAX_ITEMS = 8
 
 ACCOUNT_FANS_PORTRAIT_API = (
     "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait"
@@ -174,8 +175,16 @@ def _success_result(payload: dict[str, Any]) -> str:
     return json.dumps(payload, ensure_ascii=False)
 
 
-def _error_result(error: str, *, title: str = "画像获取失败") -> str:
-    return json.dumps({"error": error, "title": title}, ensure_ascii=False)
+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,
+    )
 
 
 @tool
@@ -371,6 +380,7 @@ async def get_content_fans_portrait(
 @tool
 async def batch_fetch_portraits(
     candidates_json: str,
+    fetch_account_portrait: bool = False,
     need_province: bool = False,
     need_city: bool = False,
     need_city_level: bool = False,
@@ -383,14 +393,17 @@ async def batch_fetch_portraits(
     """
     批量获取多条候选视频的画像
 
-    依次请求内容点赞画像;若无画像且允许兜底则再请求作者粉丝画像。
-    一次调用返回所有条目,减少对话轮次。
+    依次请求内容点赞画像。fetch_account_portrait=true 时同时请求作者粉丝画像;
+    否则仅在内容画像缺失且允许兜底时请求作者画像。
+    一次调用返回所有条目,便于比较同一候选的两侧年龄证据。
 
     Args:
         candidates_json: JSON 数组字符串。每项为对象,字段:
             - aweme_id (必填): 视频 id
-            - author_sec_uid (可选): 作者 sec_uid,兜底时需要
+            - author_sec_uid (可选): 作者 sec_uid,作者画像或兜底时需要
             - try_account_fallback (可选,默认 true): 为 false 时不请求账号画像
+        fetch_account_portrait: 是否为每个候选同时获取作者粉丝画像,默认 False。
+            老年受众判断建议设为 True;缺少 author_sec_uid 的条目会跳过作者画像。
         need_* / timeout: 与各单条画像工具一致
 
     Returns:
@@ -402,20 +415,33 @@ async def batch_fetch_portraits(
     raw = (candidates_json or "").strip()
 
     if not raw:
-        return _error_result("candidates_json 为空", title="批量画像失败")
+        return _error_result(
+            "candidates_json 为空",
+            title="批量画像失败",
+            input_error=True,
+        )
 
     try:
         parsed = json.loads(raw)
     except json.JSONDecodeError as e:
-        return _error_result(f"candidates_json 不是合法 JSON: {e}", title="批量画像失败")
+        return _error_result(
+            f"candidates_json 不是合法 JSON: {e}",
+            title="批量画像失败",
+            input_error=True,
+        )
 
     if not isinstance(parsed, list):
-        return _error_result("candidates_json 必须是 JSON 数组", title="批量画像失败")
+        return _error_result(
+            "candidates_json 必须是 JSON 数组",
+            title="批量画像失败",
+            input_error=True,
+        )
 
     if len(parsed) > BATCH_MAX_ITEMS:
         return _error_result(
             f"条目数超过上限 {BATCH_MAX_ITEMS},请分批调用",
             title="批量画像失败",
+            input_error=True,
         )
 
     flags = _dimension_flags(
@@ -468,6 +494,7 @@ async def batch_fetch_portraits(
                     "aweme_id": aweme_id,
                     "author_sec_uid": author_sec if isinstance(author_sec, str) else None,
                     "try_account_fallback": bool(try_fallback),
+                    "fetch_account_portrait": fetch_account_portrait,
                     "content": None,
                     "account": None,
                     "error": None,
@@ -497,13 +524,15 @@ async def batch_fetch_portraits(
 
                 c_block = item_result["content"]
                 content_has = bool(c_block and c_block.get("has_portrait"))
-                need_account = bool(try_fallback) and not content_has
+                need_account = fetch_account_portrait or (
+                    bool(try_fallback) and not content_has
+                )
 
                 if need_account:
                     if not author_sec or not isinstance(author_sec, str):
                         item_result["account"] = {
                             "attempted": False,
-                            "skipped_reason": "缺少 author_sec_uid,无法账号兜底",
+                            "skipped_reason": "缺少 author_sec_uid,无法获取作者画像",
                             "has_portrait": False,
                             "portrait_data": {},
                         }
@@ -533,7 +562,7 @@ async def batch_fetch_portraits(
                     skip_reason = (
                         "try_account_fallback 为 false"
                         if not try_fallback
-                        else "内容侧已有有效画像,无需账号兜底"
+                        else "内容侧已有有效画像,且未要求同时获取作者画像"
                     )
                     item_result["account"] = {
                         "attempted": False,
@@ -542,6 +571,13 @@ async def batch_fetch_portraits(
                         "portrait_data": {},
                     }
 
+                content_block = item_result["content"] or {}
+                account_block = item_result["account"] or {}
+                item_result["age_normalization"] = normalize_age_portrait_pair(
+                    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 {}

+ 381 - 11
agents/find_agent/tools/qwen_video_analyze.py

@@ -2,21 +2,32 @@
 千问视频解析工具
 
 通过阿里云百炼平台(DashScope 兼容模式)调用 qwen3.7-plus 模型,解析视频内容。
+超长视频会先截断到指定时长,上传 OSS 后再解析。
 """
 from __future__ import annotations
 
 import asyncio
+import hashlib
+import importlib
 import json
 import logging
 import os
+import re
+import shutil
+import subprocess
+import tempfile
 import time
-from typing import Optional
+from pathlib import Path
+from typing import Any, Optional
 
+import httpx
 from dotenv import load_dotenv
-from openai import OpenAI
+from openai import APIStatusError, OpenAI
 
 from supply_agent.paths import find_project_root
 from supply_agent.tools import tool
+from supply_infra.config import get_infra_settings
+from supply_infra.oss.client import OssClient
 
 logger = logging.getLogger(__name__)
 
@@ -25,7 +36,14 @@ DASHSCOPE_BASE_URL = "https://llm-33b86fznnpci2exm.cn-beijing.maas.aliyuncs.com/
 DEFAULT_MODEL = "qwen3.7-plus"
 DEFAULT_PROMPT = "描述这段视频的内容"
 DEFAULT_FPS = 2.0
-DEFAULT_TIMEOUT = 120.0
+DEFAULT_TIMEOUT = 300.0
+DEFAULT_DOWNLOAD_TIMEOUT = 120.0
+DEFAULT_MAX_DURATION_SECONDS = 180.0
+
+_DURATION_RE = re.compile(
+    r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)",
+    re.IGNORECASE,
+)
 
 _env_loaded = False
 
@@ -47,6 +65,233 @@ def _get_client() -> OpenAI:
     return OpenAI(api_key=api_key, base_url=DASHSCOPE_BASE_URL)
 
 
+def _resolve_ffmpeg() -> str | None:
+    ffmpeg = shutil.which("ffmpeg")
+    if ffmpeg:
+        return ffmpeg
+    try:
+        module = importlib.import_module("imageio_ffmpeg")
+        return module.get_ffmpeg_exe()
+    except ImportError:
+        return None
+
+
+def _resolve_ffprobe() -> str | None:
+    return shutil.which("ffprobe")
+
+
+def _require_ffmpeg() -> str:
+    ffmpeg = _resolve_ffmpeg()
+    if not ffmpeg:
+        raise ValueError(
+            "截断视频需要 ffmpeg。本地可执行 pip install imageio-ffmpeg,"
+            "或 brew install ffmpeg;部署镜像已内置系统 ffmpeg"
+        )
+    return ffmpeg
+
+
+def _ensure_oss_configured() -> None:
+    settings = get_infra_settings()
+    if not settings.aliyun_oss_access_key_id or not settings.aliyun_oss_access_key_secret:
+        raise ValueError(
+            "截断视频需要配置 ALIYUN_OSS_ACCESS_KEY_ID 和 ALIYUN_OSS_ACCESS_KEY_SECRET"
+        )
+
+
+def _parse_duration_text(text: str) -> float | None:
+    match = _DURATION_RE.search(text)
+    if not match:
+        return None
+    hours, minutes, seconds = match.groups()
+    return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
+
+
+def _probe_duration(ffmpeg: str, target: str) -> float | None:
+    ffprobe = _resolve_ffprobe()
+    if ffprobe:
+        result = subprocess.run(
+            [
+                ffprobe,
+                "-v",
+                "error",
+                "-show_entries",
+                "format=duration",
+                "-of",
+                "default=noprint_wrappers=1:nokey=1",
+                target,
+            ],
+            capture_output=True,
+            text=True,
+            check=False,
+        )
+        if result.returncode == 0:
+            raw = result.stdout.strip()
+            if raw:
+                try:
+                    return float(raw)
+                except ValueError:
+                    pass
+
+    result = subprocess.run(
+        [ffmpeg, "-i", target],
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    return _parse_duration_text(result.stderr)
+
+
+def _truncate_video(
+    ffmpeg: str,
+    input_path: Path,
+    output_path: Path,
+    max_duration_seconds: float,
+) -> None:
+    duration_text = str(max_duration_seconds)
+    copy_cmd = [
+        ffmpeg,
+        "-y",
+        "-i",
+        str(input_path),
+        "-t",
+        duration_text,
+        "-c",
+        "copy",
+        "-movflags",
+        "+faststart",
+        str(output_path),
+    ]
+    result = subprocess.run(copy_cmd, capture_output=True, text=True, check=False)
+    if result.returncode == 0 and output_path.is_file() and output_path.stat().st_size > 0:
+        return
+
+    logger.warning(
+        "ffmpeg stream copy failed, fallback to re-encode: %s",
+        (result.stderr or result.stdout)[-500:],
+    )
+    encode_cmd = [
+        ffmpeg,
+        "-y",
+        "-i",
+        str(input_path),
+        "-t",
+        duration_text,
+        "-c:v",
+        "libx264",
+        "-preset",
+        "fast",
+        "-c:a",
+        "aac",
+        "-movflags",
+        "+faststart",
+        str(output_path),
+    ]
+    encode_result = subprocess.run(
+        encode_cmd,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if encode_result.returncode != 0 or not output_path.is_file():
+        detail = (encode_result.stderr or encode_result.stdout)[-500:]
+        raise RuntimeError(f"ffmpeg 截断视频失败: {detail}")
+
+
+async def _download_video(url: str, dest: Path, timeout: float) -> None:
+    async with httpx.AsyncClient(
+        timeout=timeout,
+        trust_env=False,
+        follow_redirects=True,
+        headers={"User-Agent": "curl/8.6.0", "Accept": "*/*"},
+    ) as client:
+        async with client.stream("GET", url) as response:
+            response.raise_for_status()
+            with dest.open("wb") as file_obj:
+                async for chunk in response.aiter_bytes():
+                    file_obj.write(chunk)
+
+
+def _upload_clip(local_path: Path, source_url: str, max_duration_seconds: float) -> str:
+    url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:16]
+    client = OssClient()
+    object_key = client.object_key(
+        "video_clips",
+        f"{url_hash}_{int(max_duration_seconds)}s.mp4",
+    )
+    return client.upload_file(local_path, object_key)
+
+
+async def _prepare_analysis_url(
+    video_url: str,
+    max_duration_seconds: float | None,
+    download_timeout: float,
+) -> tuple[str, dict[str, Any]]:
+    meta: dict[str, Any] = {
+        "original_video_url": video_url,
+        "truncated": False,
+    }
+    if max_duration_seconds is None:
+        return video_url, meta
+
+    ffmpeg = _require_ffmpeg()
+
+    duration = await asyncio.to_thread(_probe_duration, ffmpeg, video_url)
+    if duration is not None:
+        meta["original_duration_seconds"] = duration
+        if duration <= max_duration_seconds:
+            logger.info(
+                "video within limit, skip truncate: duration=%.1fs max=%.1fs url=%s",
+                duration,
+                max_duration_seconds,
+                video_url,
+            )
+            return video_url, meta
+        _ensure_oss_configured()
+
+    with tempfile.TemporaryDirectory(prefix="qwen_video_") as tmpdir:
+        source_path = Path(tmpdir) / "source.mp4"
+        clipped_path = Path(tmpdir) / "clipped.mp4"
+
+        await _download_video(video_url, source_path, download_timeout)
+
+        if duration is None:
+            duration = await asyncio.to_thread(_probe_duration, ffmpeg, str(source_path))
+            if duration is not None:
+                meta["original_duration_seconds"] = duration
+            if duration is not None and duration <= max_duration_seconds:
+                logger.info(
+                    "downloaded video within limit, skip truncate: duration=%.1fs",
+                    duration,
+                )
+                return video_url, meta
+
+        _ensure_oss_configured()
+        await asyncio.to_thread(
+            _truncate_video,
+            ffmpeg,
+            source_path,
+            clipped_path,
+            max_duration_seconds,
+        )
+        analysis_url = await asyncio.to_thread(
+            _upload_clip,
+            clipped_path,
+            video_url,
+            max_duration_seconds,
+        )
+
+    meta["truncated"] = True
+    meta["analysis_video_url"] = analysis_url
+    meta["max_duration_seconds"] = max_duration_seconds
+    logger.info(
+        "video truncated: original_duration=%s max=%.1fs analysis_url=%s",
+        meta.get("original_duration_seconds"),
+        max_duration_seconds,
+        analysis_url,
+    )
+    return analysis_url, meta
+
+
 def _analyze_video_sync(
     video_url: str,
     prompt: str,
@@ -81,8 +326,10 @@ def _success_result(
     content: str,
     model: str,
     duration_ms: int,
+    *,
+    extra: dict[str, Any] | None = None,
 ) -> str:
-    payload = {
+    payload: dict[str, Any] = {
         "title": "视频解析结果",
         "video_url": video_url,
         "prompt": prompt,
@@ -91,11 +338,90 @@ def _success_result(
         "output": content,
         "duration_ms": duration_ms,
     }
+    if extra:
+        payload.update(extra)
     return json.dumps(payload, ensure_ascii=False)
 
 
-def _error_result(error: str, *, title: str = "视频解析失败") -> str:
-    return json.dumps({"error": error, "title": title}, ensure_ascii=False)
+def _error_result(
+    error: str,
+    *,
+    title: str = "视频解析失败",
+    error_code: str | None = None,
+    retryable: bool | None = None,
+) -> str:
+    payload: dict[str, Any] = {"error": error, "title": title}
+    if error_code:
+        payload["error_code"] = error_code
+    if retryable is not None:
+        payload["retryable"] = retryable
+    return json.dumps(payload, ensure_ascii=False)
+
+
+def _classify_api_error(exc: Exception) -> tuple[str, str, str, bool]:
+    """将上游 API 异常归类为 Agent 可理解的错误。"""
+    raw = str(exc)
+    lowered = raw.lower()
+    if "data_inspection_failed" in lowered or "inappropriate content" in lowered:
+        return (
+            "content_inspection_failed",
+            "视频内容审核未通过",
+            "视频内容未通过模型侧安全审核,无法解析画面;请改依据标题、互动数据和画像继续判断,不要重复调用本工具。",
+            False,
+        )
+    return "api_error", "视频解析失败", raw, True
+
+
+def verify_truncation_runtime(*, check_oss: bool = False) -> dict[str, Any]:
+    """
+    校验视频截断运行时依赖是否可用。
+
+    部署后可执行:
+        python scripts/verify_video_truncate_runtime.py
+    """
+    ffmpeg = _require_ffmpeg()
+    ffprobe = _resolve_ffprobe()
+
+    with tempfile.TemporaryDirectory(prefix="qwen_video_verify_") as tmpdir:
+        source_path = Path(tmpdir) / "source.mp4"
+        clipped_path = Path(tmpdir) / "clipped.mp4"
+        subprocess.run(
+            [
+                ffmpeg,
+                "-hide_banner",
+                "-loglevel",
+                "error",
+                "-f",
+                "lavfi",
+                "-i",
+                "testsrc=duration=3:size=160x120:rate=10",
+                "-t",
+                "3",
+                "-c:v",
+                "libx264",
+                "-pix_fmt",
+                "yuv420p",
+                str(source_path),
+            ],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+        _truncate_video(ffmpeg, source_path, clipped_path, 1.0)
+        if not clipped_path.is_file() or clipped_path.stat().st_size <= 0:
+            raise RuntimeError("ffmpeg 截断验证失败:输出文件为空")
+
+    oss_configured = False
+    if check_oss:
+        _ensure_oss_configured()
+        oss_configured = True
+
+    return {
+        "ffmpeg": ffmpeg,
+        "ffprobe": ffprobe,
+        "oss_configured": oss_configured,
+        "status": "ok",
+    }
 
 
 @tool
@@ -105,30 +431,43 @@ async def qwen_video_analyze(
     fps: float = DEFAULT_FPS,
     model: str = DEFAULT_MODEL,
     timeout: Optional[float] = None,
+    max_duration_seconds: Optional[float] = DEFAULT_MAX_DURATION_SECONDS,
+    download_timeout: Optional[float] = None,
 ) -> str:
     """
     千问视频内容解析
 
     通过阿里云百炼平台调用 qwen3.7-plus 模型,分析视频 URL 并返回文字描述。
-    需要设置环境变量 DASHSCOPE_API_KEY。
+    需要设置环境变量 DASHSCOPE_API_KEY。超长视频会先截断再解析。
 
     Args:
         video_url: 视频地址(需公网可访问的 mp4 等格式)
         prompt: 解析提示词,默认 "描述这段视频的内容"
         fps: 视频抽帧频率,默认 2(每秒采样 2 帧)
         model: 模型名称,默认 "qwen3.7-plus"
-        timeout: 请求超时时间(秒),默认 120
+        timeout: 请求超时时间(秒),默认 300
+        max_duration_seconds: 解析前最长保留秒数,默认 180(3 分钟);
+            传 None 表示不截断
+        download_timeout: 下载原视频超时时间(秒),默认 120
 
     Returns:
         JSON 字符串,包含 content(解析文本)和 output(同 content,供 LLM 阅读)。
     """
     start_time = time.time()
     request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    request_download_timeout = (
+        download_timeout if download_timeout is not None else DEFAULT_DOWNLOAD_TIMEOUT
+    )
 
     try:
+        analysis_url, truncate_meta = await _prepare_analysis_url(
+            video_url,
+            max_duration_seconds,
+            request_download_timeout,
+        )
         content = await asyncio.to_thread(
             _analyze_video_sync,
-            video_url,
+            analysis_url,
             prompt,
             fps,
             model,
@@ -137,16 +476,47 @@ async def qwen_video_analyze(
 
         duration_ms = int((time.time() - start_time) * 1000)
         logger.info(
-            "qwen_video_analyze completed: video_url=%s model=%s duration_ms=%d",
+            "qwen_video_analyze completed: video_url=%s analysis_url=%s truncated=%s model=%s duration_ms=%d",
             video_url,
+            analysis_url,
+            truncate_meta.get("truncated"),
             model,
             duration_ms,
         )
-        return _success_result(video_url, prompt, content, model, duration_ms)
+        return _success_result(
+            analysis_url,
+            prompt,
+            content,
+            model,
+            duration_ms,
+            extra=truncate_meta,
+        )
 
     except ValueError as e:
         logger.error("qwen_video_analyze config error: %s", e)
         return _error_result(str(e))
+    except httpx.HTTPError as e:
+        logger.error(
+            "qwen_video_analyze download error: video_url=%s error=%s",
+            video_url,
+            e,
+            exc_info=True,
+        )
+        return _error_result(f"下载视频失败: {e}")
+    except APIStatusError as e:
+        error_code, title, message, retryable = _classify_api_error(e)
+        logger.warning(
+            "qwen_video_analyze api rejected: video_url=%s code=%s status=%s",
+            video_url,
+            error_code,
+            e.status_code,
+        )
+        return _error_result(
+            message,
+            title=title,
+            error_code=error_code,
+            retryable=retryable,
+        )
     except Exception as e:
         logger.error(
             "qwen_video_analyze error: video_url=%s error=%s",

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

@@ -0,0 +1,703 @@
+"""持久化 find_agent 的搜索轨迹、候选证据和分池结果。"""
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import uuid
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy.exc import OperationalError, ProgrammingError
+
+from agents.find_agent.tools.decision_support import (
+    audit_video_discovery_process,
+)
+from supply_agent.tools import tool
+from supply_infra.db.repositories.video_discovery_repo import (
+    VideoDiscoveryRepository,
+)
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+_RUN_STATUSES = {"running", "finished", "failed"}
+_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 _db_error_message(error: Exception) -> str:
+    if isinstance(error, (OperationalError, ProgrammingError)):
+        return (
+            f"数据库表尚未初始化或不可用: {error}。"
+            "请先运行 `.venv/bin/python jobs/init_db.py`。"
+        )
+    return str(error)
+
+
+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,
+    }
+
+
+def _serialize_candidate(item: Any) -> dict[str, Any]:
+    decision_bucket = (
+        "pending_evaluation"
+        if item.decision_bucket == "unreviewed"
+        else item.decision_bucket
+    )
+    return {
+        "aweme_id": item.aweme_id,
+        "title": item.title,
+        "content_link": item.content_link,
+        "author_name": item.author_name,
+        "author_sec_uid": item.author_sec_uid,
+        "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, []),
+        "play_count": item.play_count,
+        "like_count": item.like_count,
+        "comment_count": item.comment_count,
+        "collect_count": item.collect_count,
+        "share_count": item.share_count,
+        "relevance_score": (
+            float(item.relevance_score) if item.relevance_score is not None else None
+        ),
+        "elder_score": float(item.elder_score) if item.elder_score is not None else None,
+        "share_score": float(item.share_score) if item.share_score is not None else None,
+        "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),
+        "has_video_url": bool(item.video_url),
+        "content_analysis": item.content_analysis,
+        "content_analysis_verified": bool(item.content_analysis_verified),
+        "expansion_worthy_tags": _load_json(
+            item.expansion_worthy_tags_json, []
+        ),
+        "relevance_reason": item.relevance_reason,
+        "elder_reason": item.elder_reason,
+        "share_reason": item.share_reason,
+        "decision_reason": item.decision_reason,
+    }
+
+
+def _serialize_search(item: Any) -> dict[str, Any]:
+    return {
+        "search_id": int(item.id),
+        "keyword": item.keyword,
+        "query_reason": item.query_reason,
+        "source_type": item.source_type,
+        "source_value": item.source_value,
+        "parent_search_id": item.parent_search_id,
+        "provider": item.provider,
+        "provider_state": _load_json(item.provider_state_json, {}),
+        "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,
+    }
+
+
+@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。
+    """
+    cleaned_run_id = _clean_text(run_id, max_length=64)
+    if cleaned_run_id:
+        try:
+            with get_session() as session:
+                existing = VideoDiscoveryRepository(session).get_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": _db_error_message(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:
+        with get_session() as session:
+            VideoDiscoveryRepository(session).create_run(values)
+        return _json(
+            {
+                "title": "视频发现运行已创建",
+                "run_id": new_run_id,
+                "status": "running",
+                "output": f"run_id={new_run_id}",
+            }
+        )
+    except Exception as exc:
+        logger.error("create_video_discovery_run failed: %s", exc, exc_info=True)
+        return _json({"error": _db_error_message(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:
+        with get_session() as session:
+            repo = VideoDiscoveryRepository(session)
+            if repo.get_run(run_text) is None:
+                return _input_error(f"run_id 不存在: {run_text}")
+            search, new_count = repo.save_search_page(search_values, candidate_rows)
+            payload = {
+                "title": "搜索页已保存",
+                "search_id": int(search.id),
+                "run_id": run_text,
+                "keyword": keyword_text,
+                "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,
+                "output": (
+                    f"search_id={search.id},本页 {search.results_count} 条,"
+                    f"新增候选 {new_count} 条"
+                ),
+            }
+        return _json(payload)
+    except Exception as exc:
+        logger.error("record_video_search_page failed: %s", exc, exc_info=True)
+        return _json({"error": _db_error_message(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")
+    content_analysis = _clean_text(item.get("content_analysis"))
+    detail_verified = bool(item.get("detail_verified"))
+    content_portrait_attempted = bool(item.get("content_portrait_attempted"))
+    account_portrait_attempted = bool(item.get("account_portrait_attempted"))
+    content_analysis_verified = bool(item.get("content_analysis_verified"))
+    age_portraits_normalized = bool(item.get("age_portraits_normalized"))
+    decision_bucket = (
+        _clean_text(item.get("decision_bucket"), max_length=24)
+        or "pending_evaluation"
+    )
+
+    mapping = {
+        "aweme_id": aweme_id,
+        "title": _clean_text(item.get("title"), max_length=512),
+        "content_link": _clean_text(item.get("content_link"), max_length=1024),
+        "video_url": _clean_text(item.get("video_url")),
+        "author_name": _clean_text(item.get("author_name"), max_length=256),
+        "author_sec_uid": _clean_text(item.get("author_sec_uid"), max_length=256),
+        "source_keywords_json": item.get("source_keywords") or [],
+        "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),
+        "content_analysis": content_analysis,
+        "content_analysis_verified": int(content_analysis_verified),
+        "expansion_worthy_tags_json": item.get("expansion_worthy_tags") or [],
+        "relevance_score": _optional_decimal(item.get("relevance_score"), 6),
+        "elder_score": _optional_decimal(item.get("elder_score"), 6),
+        "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 给出的 primary / backup / 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:
+        with get_session() as session:
+            repo = VideoDiscoveryRepository(session)
+            if repo.get_run(run_text) is None:
+                return _input_error(f"run_id 不存在: {run_text}")
+            if rows:
+                saved, audit_relevant_changed = repo.save_candidate_evaluations(
+                    run_text, rows
+                )
+            else:
+                saved, audit_relevant_changed = 0, False
+            run = repo.finish_run(
+                run_text,
+                status=run_status,
+                intent_summary=_clean_text(intent_summary),
+                stop_reason=_clean_text(stop_reason),
+            )
+            payload = {
+                "title": "候选评估已保存",
+                "run_id": run_text,
+                "saved_count": saved,
+                "error_count": len(errors),
+                "errors": errors,
+                "input_error": bool(errors and not rows),
+                "audit_relevant_changed": audit_relevant_changed,
+                "status": run.status,
+                "search_count": run.search_count,
+                "primary_count": run.primary_count,
+                "backup_count": run.backup_count,
+                "output": (
+                    f"保存 {saved} 条;主推荐 {run.primary_count} 条;"
+                    f"补充候选 {run.backup_count} 条;状态 {run.status}"
+                ),
+            }
+        return _json(payload)
+    except Exception as exc:
+        logger.error(
+            "batch_save_video_candidate_evaluations failed: %s", exc, exc_info=True
+        )
+        return _json({"error": _db_error_message(exc), "title": "保存候选评估失败"})
+
+
+@tool
+def query_video_discovery_state(
+    run_id: str,
+    include_rejected: bool = False,
+    limit: int = 100,
+) -> str:
+    """
+    查询一次运行已经保存的搜索轨迹、主推荐与 Agent 补充候选。
+
+    用于长搜索过程恢复状态、检查是否真的翻页和扩词,也用于最终自动保留判断。
+    """
+    run_text = _clean_text(run_id, max_length=64)
+    if not run_text:
+        return _json({"error": "run_id 不能为空"})
+    try:
+        with get_session() as session:
+            repo = VideoDiscoveryRepository(session)
+            run = repo.get_run(run_text)
+            if run is None:
+                return _json({"error": f"run_id 不存在: {run_text}"})
+            searches = repo.list_searches(run_text)
+            buckets = (
+                None
+                if include_rejected
+                else (
+                    "primary",
+                    "backup",
+                    "pending_evaluation",
+                    "unreviewed",
+                )
+            )
+            candidates = repo.list_candidates(
+                run_text, buckets=buckets, limit=max(1, min(int(limit), 500))
+            )
+            payload = {
+                "title": f"视频发现状态: {run_text}",
+                "run": {
+                    "run_id": run.run_id,
+                    "demand_word": run.demand_word,
+                    "intent_summary": run.intent_summary,
+                    "status": run.status,
+                    "search_count": run.search_count,
+                    "primary_count": run.primary_count,
+                    "backup_count": run.backup_count,
+                    "stop_reason": run.stop_reason,
+                },
+                "searches": [_serialize_search(item) for item in searches],
+                "candidates": [_serialize_candidate(item) for item in candidates],
+                "output": (
+                    f"搜索页 {run.search_count};主推荐 {run.primary_count};"
+                    f"补充候选 {run.backup_count}"
+                ),
+            }
+        return _json(payload)
+    except Exception as exc:
+        logger.error("query_video_discovery_state failed: %s", exc, exc_info=True)
+        return _json({"error": _db_error_message(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:
+        with get_session() as session:
+            repo = VideoDiscoveryRepository(session)
+            run = repo.get_run(run_text)
+            if run is None:
+                return _input_error(f"run_id 不存在: {run_text}")
+            searches = [
+                _serialize_search(item)
+                for item in repo.list_searches(run_text)
+            ]
+            candidates = [
+                _serialize_candidate(item)
+                for item in repo.list_candidates(run_text, limit=500)
+            ]
+            persisted_run = {
+                "status": run.status,
+                "search_count": run.search_count,
+                "primary_count": run.primary_count,
+                "backup_count": run.backup_count,
+            }
+
+        result = _load_json(
+            audit_video_discovery_process(
+                searches=searches,
+                candidates=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"],
+                "persisted_backup_count": persisted_run["backup_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 Exception as exc:
+        logger.error(
+            "audit_video_discovery_run failed: %s",
+            exc,
+            exc_info=True,
+        )
+        return _json(
+            {"error": _db_error_message(exc), "title": "数据库运行审计失败"}
+        )

+ 77 - 0
jobs/discover_videos_from_demands.py

@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""手动执行 S/A 需求拓展点位 → find_agent 视频发现任务。
+
+用法:
+    python jobs/discover_videos_from_demands.py                  # 最新/当天 biz_dt
+    python jobs/discover_videos_from_demands.py 20260721          # 指定业务日
+    python jobs/discover_videos_from_demands.py 20260721 3        # 指定业务日 + 3 并发
+    python jobs/discover_videos_from_demands.py 20260721 1 --limit 1   # 只跑 1 条
+    python jobs/discover_videos_from_demands.py 20260721 1 --offset 1  # 跳过第 0 条
+    python jobs/discover_videos_from_demands.py 20260721 1 --force     # 忽略已执行记录重跑
+"""
+from __future__ import annotations
+
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.discover_videos_from_demands import (
+    discover_videos_from_demands,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def _read_flag_value(args: list[str], flag: str) -> int | None:
+    if flag not in args:
+        return None
+    idx = args.index(flag)
+    if idx + 1 >= len(args):
+        return None
+    return int(args[idx + 1])
+
+
+def main(
+    biz_dt: str | None = None,
+    workers_arg: str | None = None,
+    *,
+    offset: int = 0,
+    limit: int | None = None,
+    skip_finished: bool = True,
+    force: bool = False,
+) -> dict:
+    workers = int(workers_arg) if workers_arg else 1
+    result = discover_videos_from_demands(
+        biz_dt,
+        workers=workers,
+        offset=offset,
+        limit=limit,
+        skip_finished=skip_finished,
+        force=force,
+    )
+    print(result)
+    return result
+
+
+if __name__ == "__main__":
+    args = sys.argv[1:]
+    biz_dt_arg = args[0] if args and not args[0].startswith("-") else None
+    workers_arg = None
+    if biz_dt_arg and len(args) > 1 and not args[1].startswith("-"):
+        workers_arg = args[1]
+
+    offset_arg = _read_flag_value(args, "--offset") or 0
+    limit_arg = _read_flag_value(args, "--limit")
+    skip_finished = "--force" not in args
+    force = "--force" in args
+
+    main(
+        biz_dt_arg,
+        workers_arg,
+        offset=offset_arg,
+        limit=limit_arg,
+        skip_finished=skip_finished,
+        force=force,
+    )

+ 2 - 1
jobs/grade_demand_pool.py

@@ -1,7 +1,8 @@
 #!/usr/bin/env python3
 """手动执行树热度驱动的需求分级。
 
-统筹规划 Agent 先落库当天全量节点组计划,再由多个 worker 领取任务并调用分级 Agent。
+默认定时任务路径会先由代码自动分配计划组(每组约 30 个需求,同分类与同父分类优先),
+再由多个 worker 领取任务并调用分级 Agent。统筹规划 Agent 代码保留,需手动单独调用。
 
 用法:
     python jobs/grade_demand_pool.py                  # 默认业务日、5 个 worker

+ 65 - 0
jobs/publish_videos_from_discovery.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""从 video_discovery_candidate 均匀分发视频到各 AIGC 发布计划。
+
+仅发布 decision_bucket 为 primary / backup 的候选;不按品类匹配。
+
+用法:
+    python jobs/publish_videos_from_discovery.py
+    python jobs/publish_videos_from_discovery.py 20260723
+    python jobs/publish_videos_from_discovery.py --run-id <run_id>
+    python jobs/publish_videos_from_discovery.py 20260723 --limit 30
+    python jobs/publish_videos_from_discovery.py --dry-run
+    python jobs/publish_videos_from_discovery.py --force
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import sys
+
+from supply_infra.scheduler.jobs.publish_videos_from_discovery import (
+    publish_videos_from_discovery,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+
+
+def _build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="均匀分发 primary/backup 候选视频到 AIGC 发布计划"
+    )
+    parser.add_argument("biz_dt", nargs="?", help="业务日 YYYYMMDD,默认取最新")
+    parser.add_argument("--run-id", dest="run_id", help="仅处理指定 run_id")
+    parser.add_argument("--limit", type=int, help="最多处理候选视频数")
+    parser.add_argument("--dry-run", action="store_true", help="只演练分配与请求,不写库")
+    parser.add_argument(
+        "--force",
+        action="store_true",
+        help="包含已写过 aigc_crawler_plan_id 的候选",
+    )
+    return parser
+
+
+def main(argv: list[str] | None = None) -> dict:
+    parser = _build_parser()
+    args = parser.parse_args(argv)
+
+    result = publish_videos_from_discovery(
+        biz_dt=args.biz_dt,
+        run_id=args.run_id,
+        skip_published=not args.force,
+        limit=args.limit,
+        dry_run=args.dry_run,
+    )
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return result
+
+
+if __name__ == "__main__":
+    outcome = main()
+    if not outcome.get("success"):
+        sys.exit(1)

+ 1 - 0
pyproject.toml

@@ -26,6 +26,7 @@ dependencies = [
 
 [project.optional-dependencies]
 odps = ["pyodps>=0.12"]
+media = ["imageio-ffmpeg>=0.5.1"]
 dev = [
     "pytest>=8.0",
     "pytest-asyncio>=0.24",

+ 2 - 0
requirements.txt

@@ -4,6 +4,8 @@ openai>=1.50.0
 pydantic>=2.0
 pydantic-settings>=2.0
 httpx>=0.27.0
+# 本地视频截断 fallback(无系统 ffmpeg 时使用);也可 pip install ".[media]"
+imageio-ffmpeg>=0.5.1
 rich>=13.0
 python-dotenv>=1.0.0
 markdown>=3.6

+ 649 - 0
scripts/aigc_platform_api.py

@@ -0,0 +1,649 @@
+"""
+AIGC接口调用
+调用AIGC接口创建爬取计划,绑定生成计划
+"""
+import json
+import logging
+import os
+from datetime import datetime
+from pathlib import Path
+from typing import List, Dict, Union, Tuple, Any, Optional
+
+import requests
+from zoneinfo import ZoneInfo
+
+from agent import ToolResult, tool
+from db import get_connection, fetch_demand_content_merge_leve2, update_content_plan_ids
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
+
+logger = logging.getLogger(__name__)
+
+
+AIGC_PLAN_ID_MAP = {
+"健康知识": {"生成ID": "20260408092313211598604", "发布ID": "20260408115944193153417"},
+"历史名人": {"生成ID": "20260408083251311809309", "发布ID": "20260408115139124511126"},
+"知识科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
+"搞笑段子": {"生成ID": "20260408091536533918237", "发布ID": "20260408115842127748387"},
+"社会风气": {"生成ID": "20260408084318884115213", "发布ID": "20260408115315950129776"},
+"人生忠告": {"生成ID": "20260408085205791658566", "发布ID": "20260408115405410408001"},
+"国际时政": {"生成ID": "20260408090208237400605", "发布ID": "20260408115616925523989"},
+"生活技巧科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
+"贪污腐败": {"生成ID": "20260408090309503416878", "发布ID": "20260408115653908856043"},
+"民生政策": {"生成ID": "20260408090721867506475", "发布ID": "20260408115727030928177"},
+"对口型表演": {"生成ID": "20260408092122328523262", "发布ID": "20260408115914659162376"},
+"中国战争史": {"生成ID": "20260408090950446586451", "发布ID": "20260408115804931772327"},
+"人财诈骗": {"生成ID": "20260408093140652233649", "发布ID": "20260408120019784463902"},
+"当代正能量人物": {"生成ID": "20260408083148399635274", "发布ID": "20260408115046382803287"},
+"国家科技力量": {"生成ID": "20260408085807674913378", "发布ID": "20260408115542550181196"},
+"国家力量": {"生成ID": "20260408085807674913378", "发布ID": "20260408115542550181196"},
+"通用": {"生成ID": "20260408085649635441036", "发布ID": "20260408115439581604474"},
+}
+
+
+_LABEL_ACCOUNT = "工具调用:create_crawler_plan_by_douyin_account_id -> 按抖音账号创建爬取计划"
+_LABEL_CONTENT = "工具调用:create_crawler_plan_by_douyin_content_id -> 按抖音视频创建爬取计划"
+
+SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
+
+
+def _log_aigc_return(label: str, params: Dict[str, Any], r: ToolResult) -> ToolResult:
+    log_tool_call(label, params, format_tool_result_for_log(r))
+    return r
+
+
+def _env_bool(name: str, default: bool = False) -> bool:
+    """Read boolean from env; unset or empty uses default. truthy: 1/true/yes/on (case-insensitive)."""
+    raw = os.getenv(name)
+    if raw is None:
+        return default
+    s = raw.strip().lower()
+    if s == "":
+        return default
+    return s in ("1", "true", "yes", "on")
+
+
+CAN_NOT_CREATE_PLAN = _env_bool("CAN_NOT_CREATE_PLAN", False)
+
+AIGC_BASE_URL = "https://aigc-api.aiddit.com"
+CRAWLER_PLAN_CREATE_URL = f"{AIGC_BASE_URL}/aigc/crawler/plan/save"
+GET_PRODUCE_PLAN_DETAIL_BY_ID = f"{AIGC_BASE_URL}/aigc/produce/plan/detail"
+PRODUCE_PLAN_SAVE = f"{AIGC_BASE_URL}/aigc/produce/plan/save"
+DEFAULT_TOKEN = "8bf14f27fc3a486788f3383452422d72"
+DEFAULT_TIMEOUT = 60.0
+
+
+def _load_output_json(trace_id: str, output_dir: str) -> Dict[str, Any]:
+    """Load {output_dir}/{trace_id}/output.json."""
+    path = Path(output_dir) / trace_id / "output.json"
+    if not path.exists():
+        raise FileNotFoundError(f"output.json not found: {path}")
+    with path.open("r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def _extract_content_ids(data: Dict[str, Any]) -> List[str]:
+    """Extract aweme_id list from output json."""
+    contents = data.get("contents") or []
+    if not isinstance(contents, list):
+        return []
+    content_ids: List[str] = []
+    for item in contents:
+        if not isinstance(item, dict):
+            continue
+        aweme_id = item.get("aweme_id")
+        if aweme_id is None:
+            continue
+        aweme_id_str = str(aweme_id).strip()
+        if aweme_id_str:
+            content_ids.append(aweme_id_str)
+    return content_ids
+
+
+def _extract_content_demand_id(data: Dict[str, Any]) -> Optional[int]:
+    """
+    Extract content_demand_id (demand_content.id) from output json.
+
+    Compatible keys:
+    - content_demand_id
+    - demand_content_id
+    - demand_id (legacy)
+    """
+    if not isinstance(data, dict):
+        return None
+    raw = (
+        data.get("content_demand_id")
+        if data.get("content_demand_id") is not None
+        else data.get("demand_content_id")
+        if data.get("demand_content_id") is not None
+        else data.get("demand_id")
+    )
+    if raw is None:
+        return None
+    try:
+        v = int(raw)
+    except Exception:
+        return None
+    return v if v > 0 else None
+
+
+@tool(description="根据抖音账号ID创建爬取计划")
+async def create_crawler_plan_by_douyin_account_id(
+        account_id: str,
+        sort_type: str = "最新",
+        produce_plan_ids: List[str] = []
+) -> ToolResult:
+    """
+     根据抖音账号ID创建爬取计划
+     Args:
+         account_id: 抖音账号ID
+         sort_type: 搜索时的视频排序方式(最新/最热),默认最新
+         produce_plan_ids: 爬取计划要绑定的生成计划ID,默认为空列表
+
+     Returns:
+         ToolResult: 包含以下内容
+             - output: 文本格式的爬取计划创建结果摘要
+             - metadata.result: 结构化的爬取计划创建结果
+                - crawler_info: 爬取计划信息
+                    - crawler_plan_id: 创建的爬取计划ID
+                    - crawler_plan_name: 创建的爬取计划名称
+                    - sort_type: 排序方式
+                - produce_plan_infos: 绑定的生成计划信息
+                    - produce_plan_id: 生成计划ID
+                    - produce_plan_name: 生成计划名称
+                    - is_success: 是否成功, true表示绑定成功,false表示绑定失败
+                    - msg: 绑定失败时为错误信息,绑定成功则为“成功”
+     Note:
+         - 建议从 metadata.result 获取结构化数据,而非解析 output 文本
+    """
+
+    call_params: Dict[str, Any] = {
+        "account_id": account_id,
+        "sort_type": sort_type,
+        "produce_plan_ids": produce_plan_ids if produce_plan_ids is not None else [],
+    }
+
+    # 验证 account_id 格式
+    if not account_id or not isinstance(account_id, str):
+        logger.error(f"create_crawler_plan_by_douyin_account_id invalid account_id: {account_id}")
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划失败",
+                output="",
+                error="account_id 参数无效:必须是非空字符串",
+            ),
+        )
+
+    if not account_id.startswith("MS4wLjABAAAA"):
+        logger.error(f"create_crawler_plan_by_douyin_account_id invalid sec_uid format account_id:{account_id}")
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划失败",
+                output="",
+                error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
+            ),
+        )
+
+    if produce_plan_ids is None:
+        produce_plan_ids = []
+    call_params["produce_plan_ids"] = produce_plan_ids
+
+    dt = datetime.now(SHANGHAI_TZ).strftime("%Y%m%d%H%M%S")
+    crawler_plan_name = f"【内容寻找Agent自动创建】{dt}_抖音账号ID爬取计划_{account_id[:min(30, len(account_id))]}"
+    params = {
+        "accountFilters": [],
+        "channel": 2,
+        "contentFilters": [],
+        "contentModal": 4,
+        "crawlerComment": 0,
+        "crawlerMode": 4,
+        "filterAccountMatchMode": 2,
+        "filterContentMatchMode": 2,
+        "frequencyType": 1,
+        "inputModeValues": [
+            account_id
+        ],
+        "modelValueConfig": {
+            "sortType": sort_type
+        },
+        "name": crawler_plan_name,
+        "planType": 2,
+        "searchModeValues": [],
+        "selectModeValues": [],
+        "srtExtractFlag": 1,
+        "videoKeyFrameType": 1,
+        "voiceExtractFlag": 1
+    }
+
+    try:
+
+        summary_lines = [f"抖音账号【{account_id}】创建爬取计划"]
+
+        response_json = post(CRAWLER_PLAN_CREATE_URL, params)
+        if response_json.get("code") != 0:
+            return _log_aigc_return(
+                _LABEL_ACCOUNT,
+                call_params,
+                ToolResult(
+                    title="根据抖音账号ID创建爬取计划失败",
+                    output=response_json.get("msg", "接口异常"),
+                    error=f"create crawler plan interface error",
+                ),
+            )
+
+        crawler_plan_id = response_json.get("data", {}).get("id", "")
+        summary_lines.append(f"爬取计划名称: {crawler_plan_name}")
+        summary_lines.append(f"    抖音账号ID: {account_id}")
+        summary_lines.append(f"    爬取计划ID: {crawler_plan_id}")
+        summary_lines.append(f"    爬取计划排序方式: {sort_type}")
+        produce_plan_infos: List[Dict[str, str]] = []
+        if produce_plan_ids:
+            input_source_info = {
+                "contentType": 1,
+                "inputSourceType": 2,
+                "inputSourceValue": crawler_plan_id,
+                "inputSourceLabel": f"原始帖子-视频-抖音-内容添加计划-{crawler_plan_name}",
+                "inputSourceModal": 4,
+                "inputSourceChannel": 2
+            }
+            produce_plan_infos, msg = crawler_plan_bind_produce_plan(input_source_info, produce_plan_ids)
+            if produce_plan_infos:
+                for produce_plan_info in produce_plan_infos:
+                    summary_lines.append("    绑定的生成计划列表: ")
+                    summary_lines.append(f"        生成计划名称: {produce_plan_info.get('produce_plan_name', '')}")
+                    summary_lines.append(f"            生成计划ID: {produce_plan_info.get('produce_plan_id', '')}")
+                    summary_lines.append(f"            绑定结果: {'绑定成功' if not produce_plan_info.get('msg') else '绑定失败'}")
+                    summary_lines.append(f"            信息: {produce_plan_info.get('msg', '成功')}")
+
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划",
+                output="\n".join(summary_lines),
+                metadata={
+                    "result": {
+                        "crawler_info": {
+                            "crawler_plan_id": crawler_plan_id,
+                            "crawler_plan_name": crawler_plan_name,
+                            "sort_type": sort_type,
+                        },
+                        "produce_plan_infos": [
+                            {
+                                "produce_plan_id": produce_plan_info.get("produce_plan_id", ""),
+                                "produce_plan_name": produce_plan_info.get("produce_plan_name", ""),
+                                "is_success": "绑定成功" if not produce_plan_info.get("msg") else "绑定失败",
+                                "msg": produce_plan_info.get("msg", "成功"),
+                            }
+                            for produce_plan_info in produce_plan_infos
+                        ],
+                    }
+                },
+                long_term_memory="Create crawler plan by DouYin Account ID",
+            ),
+        )
+    except Exception as e:
+        logger.error(f"create douyin account crawler plan error: {str(e)}, account_id: {account_id} ")
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划失败",
+                output="",
+                error=f"创建爬取计划错误:{str(e)}",
+            ),
+        )
+
+
+@tool(description="根据抖音视频ID创建爬取计划")
+async def create_crawler_plan_by_douyin_content_id(
+        trace_id: str,
+) -> ToolResult:
+    """
+    根据抖音视频ID创建爬取计划
+    Args:
+        trace_id: 内容寻找任务 trace_id(用于读取 {output_dir}/{trace_id}/output.json)
+    Returns:
+             Returns:
+         ToolResult: 包含以下内容
+             - output: 文本格式的爬取计划创建结果摘要
+             - metadata.result: 结构化的爬取计划创建结果
+                - crawler_info: 爬取计划信息
+                    - crawler_plan_id: 创建的爬取计划ID
+                    - crawler_plan_name: 创建的爬取计划名称
+                    - content_ids: 抖音视频ID列表
+                - produce_plan_infos: 绑定的生成计划信息
+                    - produce_plan_id: 生成计划ID
+                    - produce_plan_name: 生成计划名称
+                    - is_success: 是否成功, true表示绑定成功,false表示绑定失败
+                    - msg: 绑定失败时为错误信息,绑定成功则为“成功”
+    Note:
+        - 建议从 metadata.result 获取结构化数据,而非解析 output 文本
+    """
+    call_params: Dict[str, Any] = {"trace_id": trace_id}
+    # 先临时返回创建成功,不要真实创建
+    if CAN_NOT_CREATE_PLAN == True:
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划-本地环境跳过此步骤",
+                output="",
+                metadata={
+                    "result": {
+                        "crawler_info": {
+                            "crawler_plan_id": "1234567890",
+                            "crawler_plan_name": "抖音视频直接抓取",
+                        },
+                        "produce_plan_infos": [
+                            {
+                                "produce_plan_id": "1234567890",
+                                "produce_plan_name": "抖音视频直接抓取",
+                                "is_success": "绑定成功",
+                                "msg": "成功",
+                            }
+                        ],
+                    }
+                },
+                long_term_memory="Create crawler plan by DouYin Content IDs",
+            ),
+        )
+    if not trace_id or not isinstance(trace_id, str):
+        logger.error(f"create_crawler_plan_by_douyin_content_id invalid trace_id: {trace_id}")
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error="trace_id 参数无效: trace_id 必须是非空字符串",
+            ),
+        )
+
+    output_dir = os.getenv("OUTPUT_DIR", ".cache/output")
+    try:
+        data = _load_output_json(trace_id=trace_id, output_dir=output_dir)
+        content_ids = _extract_content_ids(data)
+        content_demand_id = _extract_content_demand_id(data)
+    except Exception as e:
+        msg = f"加载/解析 output.json 失败: {e}"
+        logger.error(msg, exc_info=True)
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error=msg,
+            ),
+        )
+
+    call_params["content_ids_count"] = len(content_ids)
+    if content_demand_id is not None:
+        call_params["content_demand_id"] = content_demand_id
+    if not content_ids:
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error="未在 output.json.contents 中找到有效 aweme_id",
+            ),
+        )
+    if len(content_ids) > 100:
+        logger.error(
+            "create_crawler_plan_by_douyin_content_id invalid content_ids length. "
+            f"content_ids.length: {len(content_ids)}"
+        )
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error=f"content_ids 长度异常: 期望1~100, 实际{len(content_ids)}",
+            ),
+        )
+
+    merge_leve2 = ""
+    if content_demand_id is not None:
+        try:
+            conn = get_connection()
+            try:
+                merge_leve2 = fetch_demand_content_merge_leve2(conn, content_demand_id) or ""
+            finally:
+                conn.close()
+        except Exception as e:
+            logger.error(
+                "fetch demand_content.merge_leve2 failed. demand_content_id=%s err=%s",
+                content_demand_id,
+                str(e),
+                exc_info=True,
+            )
+            merge_leve2 = ""
+
+    plan_key = merge_leve2.strip() if merge_leve2.strip() in AIGC_PLAN_ID_MAP else "通用"
+    plan_ids = AIGC_PLAN_ID_MAP.get(plan_key) or AIGC_PLAN_ID_MAP.get("通用") or {}
+    produce_plan_id_selected = str(plan_ids.get("生成ID") or "").strip()
+    publish_plan_id_selected = str(plan_ids.get("发布ID") or "").strip()
+    produce_plan_ids = [produce_plan_id_selected] if produce_plan_id_selected else []
+    dt = datetime.now(SHANGHAI_TZ).strftime("%Y%m%d%H%M%S")
+    crawler_plan_name = f"【内容寻找Agent自动创建】抖音视频直接抓取-{dt}-抖音"
+    params = {
+        "channel": 2,
+        "contentModal": 4,
+        "crawlerComment": 0,
+        "crawlerMode": 5,
+        "filterAccountMatchMode": 2,
+        "filterContentMatchMode": 2,
+        "frequencyType": 2,
+        "inputModeValues": content_ids,
+        "name": crawler_plan_name,
+        "planType": 2,
+        "searchModeValues": [],
+        "srtExtractFlag": 1,
+        "videoKeyFrameType": 1,
+        "voiceExtractFlag": 1
+    }
+
+    try:
+        summary_lines = [f"抖音视频爬取计划"]
+        if merge_leve2.strip():
+            summary_lines.append(f"需求品类(merge_leve2): {merge_leve2.strip()}")
+        summary_lines.append(f"计划匹配key: {plan_key}")
+        if produce_plan_id_selected:
+            summary_lines.append(f"生成计划ID(按品类匹配): {produce_plan_id_selected}")
+        if publish_plan_id_selected:
+            summary_lines.append(f"发布计划ID(按品类匹配): {publish_plan_id_selected}")
+
+        response_json = post(CRAWLER_PLAN_CREATE_URL, params)
+        if response_json.get("code") != 0:
+            return _log_aigc_return(
+                _LABEL_CONTENT,
+                call_params,
+                ToolResult(
+                    title="根据抖音内容ID创建爬取计划失败",
+                    output=response_json.get("msg", "接口异常"),
+                    error=f"create crawler plan interface error",
+                ),
+            )
+
+        crawler_plan_id = response_json.get("data", {}).get("id", "")
+        summary_lines.append(f"爬取计划名称: {crawler_plan_name}")
+        summary_lines.append(f"    抖音视频IDs: {','.join(content_ids)}")
+        summary_lines.append(f"    爬取计划ID: {crawler_plan_id}")
+        produce_plan_infos: List[Dict[str, str]] = []
+        db_updated_rows = 0
+        # 选中的生成计划 ID(字符串);与是否执行绑定接口无关,用于写库
+        env_produce_plan_id = (produce_plan_ids[0] if produce_plan_ids else "").strip()
+
+        if produce_plan_ids:
+            input_source_info = {
+                "contentType": 1,
+                "inputSourceType": 2,
+                "inputSourceValue": crawler_plan_id,
+                "inputSourceLabel": f"原始帖子-视频-抖音-内容添加计划-{crawler_plan_name}",
+                "inputSourceModal": 4,
+                "inputSourceChannel": 2
+            }
+            produce_plan_infos, msg = crawler_plan_bind_produce_plan(input_source_info, produce_plan_ids)
+            if produce_plan_infos:
+                for produce_plan_info in produce_plan_infos:
+                    summary_lines.append("    绑定的生成计划列表: ")
+                    summary_lines.append(f"        生成计划名称: {produce_plan_info.get('produce_plan_name', '')}")
+                    summary_lines.append(f"            生成计划ID: {produce_plan_info.get('produce_plan_id', '')}")
+                    summary_lines.append(f"            绑定结果: {'绑定成功' if not produce_plan_info.get('msg') else '绑定失败'}")
+                    summary_lines.append(f"            信息: {produce_plan_info.get('msg', '成功')}")
+
+        # 爬取 / 生成 / 发布计划 id 任一存在则写库(不依赖是否已配置 produce_plan_ids 去走绑定)
+        if (crawler_plan_id or "").strip() or env_produce_plan_id or publish_plan_id_selected:
+            try:
+                db_updated_rows = update_content_plan_ids(
+                    trace_id=trace_id,
+                    aweme_ids=content_ids,
+                    crawler_plan_id=crawler_plan_id or "",
+                    produce_plan_id=env_produce_plan_id,
+                    publish_plan_id=publish_plan_id_selected,
+                )
+            except Exception as e:
+                logger.error(f"update content plan ids failed: {e}", exc_info=True)
+
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容ID创建爬取计划",
+                output="\n".join(summary_lines),
+                metadata={
+                    "result": {
+                        "crawler_info": {
+                            "crawler_plan_id": crawler_plan_id,
+                            "crawler_plan_name": crawler_plan_name,
+                        },
+                        "produce_plan_infos": [
+                            {
+                                "produce_plan_id": produce_plan_info.get("produce_plan_id", ""),
+                                "produce_plan_name": produce_plan_info.get("produce_plan_name", ""),
+                                "is_success": "绑定成功" if not produce_plan_info.get("msg") else "绑定失败",
+                                "msg": produce_plan_info.get("msg", "成功"),
+                            }
+                            for produce_plan_info in produce_plan_infos
+                        ],
+                    },
+                    "db": {"updated_rows": db_updated_rows},
+                },
+                long_term_memory="Create crawler plan by DouYin Content IDs",
+            ),
+        )
+    except Exception as e:
+        logger.error(f"create douyin content crawler plan error. content_ids: {content_ids}, error: {str(e)}")
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容ID创建爬取计划失败",
+                output="",
+                error=f"创建爬取计划错误:{str(e)}",
+            ),
+        )
+
+
+def crawler_plan_bind_produce_plan(
+        input_source_info: Dict[str, Any],
+        produce_plan_ids: List[str],
+) -> Tuple[Union[List[Dict[str, str]], None], str]:
+    if not input_source_info or not produce_plan_ids:
+        return None, f"input_source_info or produce_plan_ids is invalid"
+    input_source_check_key = ["inputSourceModal", "inputSourceChannel", "contentType"]
+    try:
+        if not isinstance(produce_plan_ids, list):
+            return None, f"produce_plan_ids is not list"
+        result: List[Dict[str, str]] = []
+        for produce_plan_id in produce_plan_ids:
+            produce_plan_info = {
+                "produce_plan_id": produce_plan_id,
+            }
+            result.append(produce_plan_info)
+            # 获取生成计划详情,msg不为空表示获取失败
+            produce_plan_detail_info, msg = find_produce_plan_info_by_id(produce_plan_id)
+            if msg:
+                produce_plan_info["msg"] = msg
+                continue
+
+            produce_plan_info["produce_plan_name"] = produce_plan_detail_info.get("name", "")
+
+            input_source_groups = produce_plan_detail_info.get("inputSourceGroups", [])
+            if not input_source_groups:
+                produce_plan_info["msg"] = "生成计划没有输入源组"
+                continue
+            # 查询当前爬取计划要添加到的输入源组下标
+            input_source_index = 0
+            for i in range(len(input_source_groups)):
+                input_source_group = input_source_groups[i]
+                if not input_source_group.get("inputSources", []):
+                    continue
+                first_input_source = input_source_group.get("inputSources")[0]
+                if all(input_source_info.get(k, 0) == first_input_source.get(k, -1) for k in input_source_check_key):
+                    input_source_index = i
+                    break
+
+            # 对应的输入源组添加输入源
+            input_source_group = input_source_groups[input_source_index]
+            input_source_group.get("inputSources", []).append(input_source_info)
+
+            response_json = post(PRODUCE_PLAN_SAVE, produce_plan_detail_info)
+            if response_json.get("code") != 0 or not response_json.get("data", {}):
+                produce_plan_info["msg"] = response_json.get("msg", "爬取计划绑定生成计划异常")
+
+        return result, ""
+    except Exception as e:
+        logger.error(f"crawler_plan_bind_produce_plan error. input_source_info: {json.dumps(input_source_info)}, produce_plan_ids: {produce_plan_ids}, error: {str(e)},")
+        return None, str(e)
+
+
+def find_produce_plan_info_by_id(
+        produce_plan_id: str,
+) -> Tuple[Union[Dict[str, str], None], str]:
+    try:
+        if not produce_plan_id or not isinstance(produce_plan_id, str):
+            return None, f"非法的produce_plan_id: {produce_plan_id}"
+
+        params = {
+            "id": produce_plan_id,
+        }
+        response_json = post(GET_PRODUCE_PLAN_DETAIL_BY_ID, params)
+
+        if response_json.get("code") != 0 or not response_json.get("data", {}):
+            return None, response_json.get("msg", "获取生成计划详情异常")
+
+        return response_json.get("data", {}), ""
+    except Exception as e:
+        logger.error(f"find_produce_plan_info_by_id error. produce_plan_id: {produce_plan_id}, error: {str(e)},")
+        return None, str(e)
+
+
+def post(url: str, params: Any) -> Dict[str, Any]:
+    request = {
+        "baseInfo": {
+            "token": DEFAULT_TOKEN,
+        },
+        "params": params
+    }
+    try:
+        response = requests.post(
+            url=url,
+            json=request,
+            headers={"Content-Type": "application/json"},
+            timeout=DEFAULT_TIMEOUT
+        )
+        response.raise_for_status()
+        response_json = response.json()
+
+        return response_json
+    except Exception as e:
+        logger.error(f"invoke aigc platform error. url: {url}, request: {json.dumps(request)}, error: {str(e)}")
+    return {}

+ 3 - 0
scripts/docker-deploy.sh

@@ -67,6 +67,9 @@ echo "==> Image tag: ${IMAGE_TAG}"
 cd "$BUILD_DIR"
 docker build -t "$IMAGE_TAG" -t "$LATEST_TAG" .
 
+echo "==> Verifying video truncate runtime in image"
+docker run --rm "$IMAGE_TAG" python scripts/verify_video_truncate_runtime.py
+
 if [[ "$PUSH" == "true" ]]; then
   echo "==> Pushing: ${IMAGE_TAG}"
   docker push "$IMAGE_TAG"

+ 1 - 1
scripts/run_grade_plan_groups.py

@@ -46,7 +46,7 @@ def main(argv: list[str] | None = None) -> int:
     parser.add_argument(
         "--with-orchestrate",
         action="store_true",
-        help="执行前先跑统筹 Agent 生成/补充计划",
+        help="执行前先自动分配计划组(每组约 30 个需求;非统筹 Agent)",
     )
     parser.add_argument(
         "--json",

+ 59 - 0
scripts/verify_find_agent_flow.py

@@ -0,0 +1,59 @@
+"""Smoke-check find_agent demand flow without calling the LLM."""
+from __future__ import annotations
+
+import json
+import sys
+
+from agents.find_agent.demand_run import (
+    build_find_agent_user_input,
+    build_run_input_payload,
+    filter_pending_contexts,
+    list_find_demand_contexts,
+    pick_find_demand_context,
+    prepare_video_discovery_run,
+)
+from agents.find_agent.async_runner import _run_coroutine
+
+
+def main() -> int:
+    biz_dt, contexts = list_find_demand_contexts()
+    pending, stats = filter_pending_contexts(contexts, biz_dt)
+    ctx = pick_find_demand_context(biz_dt, index=0)
+    if ctx is None:
+        print(json.dumps({"ok": False, "error": "no context"}, ensure_ascii=False))
+        return 1
+
+    run_id, skip_reason = prepare_video_discovery_run(ctx, force=True)
+    if skip_reason or not run_id:
+        print(json.dumps({"ok": False, "skip_reason": skip_reason}, ensure_ascii=False))
+        return 1
+
+    prompt = build_find_agent_user_input(ctx, run_id)
+    payload = build_run_input_payload(ctx)
+
+    async def _noop():
+        return None
+
+    # 验证主线程 asyncio 运行器可用
+    _run_coroutine(_noop())
+
+    print(
+        json.dumps(
+            {
+                "ok": True,
+                "biz_dt": biz_dt,
+                "total": len(contexts),
+                "pending": len(pending),
+                "stats": stats,
+                "run_id": run_id,
+                "prompt_lines": len(prompt.splitlines()),
+                "payload_keys": list(payload.keys()),
+            },
+            ensure_ascii=False,
+        )
+    )
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 126 - 0
scripts/verify_find_agent_from_demand.py

@@ -0,0 +1,126 @@
+"""从 demand_grade + demand_video_expansion 选取一条记录,调用 find_agent。"""
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+
+from agents.find_agent.demand_run import (
+    build_find_agent_user_input,
+    build_run_input_payload,
+    discover_videos_for_demand,
+    list_find_demand_contexts,
+    pick_find_demand_context,
+    serialize_find_demand_context,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+
+def _load_target_context(args: argparse.Namespace):
+    if args.list_only:
+        biz_dt, contexts = list_find_demand_contexts(args.biz_dt)
+        print(
+            json.dumps(
+                {
+                    "biz_dt": biz_dt,
+                    "total": len(contexts),
+                    "items": [serialize_find_demand_context(ctx) for ctx in contexts],
+                },
+                ensure_ascii=False,
+                indent=2,
+            )
+        )
+        return None
+
+    ctx = pick_find_demand_context(
+        args.biz_dt,
+        index=args.index,
+        demand_grade_id=args.demand_grade_id,
+    )
+    if ctx is None:
+        biz_dt, contexts = list_find_demand_contexts(args.biz_dt)
+        raise SystemExit(
+            "未找到匹配的待执行记录。"
+            f" biz_dt={biz_dt}, total={len(contexts)}, index={args.index},"
+            f" demand_grade_id={args.demand_grade_id}"
+        )
+    return ctx
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="从 S/A 需求拓展点位中选取一条记录,调用 find_agent。",
+    )
+    parser.add_argument("--biz-dt", help="业务日 YYYYMMDD,默认取最新 demand_grade.biz_dt")
+    parser.add_argument(
+        "--index",
+        type=int,
+        default=0,
+        help="在未指定 demand_grade_id 时,选取第几条记录(从 0 开始)",
+    )
+    parser.add_argument("--demand-grade-id", type=int, help="指定 demand_grade.id")
+    parser.add_argument(
+        "--list-only",
+        action="store_true",
+        help="只列出可执行记录,不调用 Agent",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="只打印组装后的上下文与 prompt,不调用 Agent",
+    )
+    parser.add_argument(
+        "--force",
+        action="store_true",
+        help="忽略当天已执行记录,强制重跑",
+    )
+    args = parser.parse_args()
+
+    ctx = _load_target_context(args)
+    if ctx is None:
+        return
+
+    summary = serialize_find_demand_context(ctx)
+    run_payload = build_run_input_payload(ctx)
+    print("CONTEXT " + json.dumps(summary, ensure_ascii=False), flush=True)
+    print("RUN_PAYLOAD " + json.dumps(run_payload, ensure_ascii=False), flush=True)
+
+    if args.dry_run:
+        user_input = build_find_agent_user_input(ctx, "<pre-create-on-run>")
+        print("PROMPT_BEGIN", flush=True)
+        print(user_input, flush=True)
+        print("PROMPT_END", flush=True)
+        return
+
+    execution = discover_videos_for_demand(ctx, force=args.force)
+    if execution.skipped:
+        print("SKIPPED " + json.dumps({"skip_reason": execution.skip_reason}, ensure_ascii=False), flush=True)
+        return
+
+    agent_result = execution.agent_result
+    if agent_result is None:
+        raise SystemExit("Agent 未返回结果")
+    print(
+        "AGENT_REPORT "
+        + json.dumps(
+            {
+                "run_id": execution.run_id,
+                "iterations": agent_result.iterations,
+                "content_chars": len(agent_result.content or ""),
+            },
+            ensure_ascii=False,
+        ),
+        flush=True,
+    )
+    print("FINAL_CONTENT_BEGIN", flush=True)
+    print(agent_result.content or "", flush=True)
+    print("FINAL_CONTENT_END", flush=True)
+
+
+if __name__ == "__main__":
+    main()

+ 226 - 0
scripts/verify_find_agent_live.py

@@ -0,0 +1,226 @@
+"""Run a real find_agent acceptance case and clean its database records by default."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+from typing import Any
+
+from sqlalchemy import delete, func, select
+
+from agents.find_agent import create_find_agent
+from agents.find_agent.agent import _validate_report_buckets
+from supply_agent.config import Settings
+from supply_agent.types import AgentEventType
+from supply_infra.db.models.video_discovery import (
+    VideoDiscoveryCandidate,
+    VideoDiscoveryRun,
+    VideoDiscoverySearch,
+)
+from supply_infra.db.session import get_session
+
+
+def _parse_json(raw: str) -> dict[str, Any]:
+    try:
+        value = json.loads(raw)
+    except (TypeError, json.JSONDecodeError):
+        return {}
+    return value if isinstance(value, dict) else {}
+
+
+def _cleanup_run(run_id: str) -> int:
+    with get_session() as session:
+        session.execute(
+            delete(VideoDiscoveryCandidate).where(
+                VideoDiscoveryCandidate.run_id == run_id
+            )
+        )
+        session.execute(
+            delete(VideoDiscoverySearch).where(
+                VideoDiscoverySearch.run_id == run_id
+            )
+        )
+        session.execute(
+            delete(VideoDiscoveryRun).where(VideoDiscoveryRun.run_id == run_id)
+        )
+    with get_session() as session:
+        remaining = session.scalar(
+            select(func.count(VideoDiscoveryRun.id)).where(
+                VideoDiscoveryRun.run_id == run_id
+            )
+        )
+    return int(remaining or 0)
+
+
+async def _run(args: argparse.Namespace) -> None:
+    agent = create_find_agent(settings=Settings.from_env(LOG_ENABLED=False))
+    relevant_points = [{"point": point} for point in args.relevant_point]
+    prompt = (
+        "这是一次真实验收测试,请完整执行并严格遵守完成条件。\n"
+        f"demand_word:{args.demand_word}\n"
+        f"seed_video_title:{args.seed_video_title}\n"
+        f"relevant_points:{json.dumps(relevant_points, ensure_ascii=False)}\n"
+        "请寻找老年人或临近退休人群可能喜欢观看和分享的视频。"
+    )
+
+    tool_calls: list[str] = []
+    tool_payloads: list[dict[str, Any]] = []
+    run_ids: list[str] = []
+    domain_errors: list[dict[str, Any]] = []
+    audit_results: list[dict[str, Any]] = []
+    states: list[dict[str, Any]] = []
+    final_content = ""
+    done_data: dict[str, Any] = {}
+
+    try:
+        async for event in agent.astream(prompt):
+            if event.type == AgentEventType.TOOL_CALL:
+                name = str(event.data.get("name") or "")
+                call_args = _parse_json(str(event.data.get("arguments") or "{}"))
+                tool_calls.append(name)
+                tool_payloads.append({"name": name, "args": call_args})
+                print(f"TOOL_CALL {len(tool_calls)} {name}", flush=True)
+            elif event.type == AgentEventType.TOOL_RESULT:
+                name = str(event.data.get("name") or "")
+                data = _parse_json(str(event.data.get("content") or ""))
+                if name == "create_video_discovery_run" and data.get("run_id"):
+                    run_ids.append(str(data["run_id"]))
+                if name in {
+                    "audit_video_discovery_process",
+                    "audit_video_discovery_run",
+                } and not data.get("error"):
+                    audit_results.append(data)
+                if name == "query_video_discovery_state" and not data.get("error"):
+                    states.append(data)
+                if data.get("error"):
+                    domain_errors.append(
+                        {
+                            "tool": name,
+                            "budget_exhausted": bool(data.get("budget_exhausted")),
+                            "error": str(data["error"])[:300],
+                        }
+                    )
+                status = "DOMAIN_ERROR" if data.get("error") else "OK"
+                if event.data.get("is_error") and not data.get("error"):
+                    status = "ERROR"
+                extra = ""
+                if data.get("error"):
+                    extra = " error=" + json.dumps(
+                        str(data["error"])[:240],
+                        ensure_ascii=False,
+                    )
+                if name in {
+                    "audit_video_discovery_process",
+                    "audit_video_discovery_run",
+                } and not data.get("error"):
+                    audit_flag = data.get("can_finish")
+                    extra = f" can_finish={audit_flag}"
+                    if audit_flag is False:
+                        extra += " violations=" + json.dumps(
+                            data.get("critical_violations"),
+                            ensure_ascii=False,
+                        )
+                print(f"TOOL_RESULT {name} {status}{extra}", flush=True)
+            elif event.type == AgentEventType.MESSAGE:
+                final_content = str(event.data.get("content") or "")
+            elif event.type == AgentEventType.DONE:
+                done_data = event.data
+                if not final_content:
+                    final_content = str(event.data.get("content") or "")
+
+        unique_keywords: list[str] = []
+        recorded_sources: list[Any] = []
+        pagination_calls = 0
+        for entry in tool_payloads:
+            name = entry["name"]
+            call_args = entry["args"]
+            if name in {"douyin_search", "douyin_search_tikhub"}:
+                keyword = str(call_args.get("keyword") or "").strip()
+                if keyword and keyword not in unique_keywords:
+                    unique_keywords.append(keyword)
+                if (
+                    str(call_args.get("cursor") or "0") not in {"", "0"}
+                    or call_args.get("search_id")
+                ):
+                    pagination_calls += 1
+            if name == "record_video_search_page":
+                recorded_sources.append(call_args.get("source_type"))
+
+        last_state = states[-1] if states else {}
+        final_bucket_error = (
+            _validate_report_buckets(final_content, last_state)
+            if final_content and last_state
+            else "缺少最终文本或最终状态"
+        )
+        report = {
+            "model": agent.model,
+            "iterations": done_data.get("iterations"),
+            "tool_calls": len(tool_calls),
+            "tool_names": tool_calls,
+            "unique_search_keywords": unique_keywords,
+            "pagination_calls": pagination_calls,
+            "recorded_source_types": recorded_sources,
+            "qwen_calls": tool_calls.count("qwen_video_analyze"),
+            "audit_calls": len(audit_results),
+            "last_audit_can_finish": (
+                audit_results[-1].get("can_finish") if audit_results else None
+            ),
+            "last_audit_violations": (
+                audit_results[-1].get("critical_violations")
+                if audit_results
+                else None
+            ),
+            "domain_errors": domain_errors,
+            "state_search_count": len(last_state.get("searches", [])),
+            "state_candidate_count": len(last_state.get("candidates", [])),
+            "state_run": last_state.get("run"),
+            "final_content_chars": len(final_content),
+            "final_has_primary_section": "主推荐" in final_content,
+            "final_has_backup_section": "补充推荐" in final_content,
+            "final_bucket_consistent": final_bucket_error is None,
+            "final_bucket_error": final_bucket_error,
+            "max_iterations_failure": final_content.startswith(
+                "Max iterations reached"
+            ),
+            "run_ids": run_ids,
+        }
+        print("AGENT_REPORT " + json.dumps(report, ensure_ascii=False), flush=True)
+        print("FINAL_CONTENT_BEGIN", flush=True)
+        print(final_content, flush=True)
+        print("FINAL_CONTENT_END", flush=True)
+    finally:
+        if not args.keep_data:
+            for run_id in set(run_ids):
+                remaining = _cleanup_run(run_id)
+                print(f"CLEANUP {run_id} remaining={remaining}", flush=True)
+
+
+def _arguments() -> argparse.Namespace:
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "--demand-word",
+        default="个人养老金税收优惠",
+    )
+    parser.add_argument(
+        "--seed-video-title",
+        default="个人养老金制度全面实施,退休前这样缴纳可以享受税收优惠",
+    )
+    parser.add_argument(
+        "--relevant-point",
+        action="append",
+        default=[
+            "个人养老金缴费如何抵扣个税",
+            "适合临近退休人群转发给家人了解",
+        ],
+    )
+    parser.add_argument(
+        "--keep-data",
+        action="store_true",
+        help="保留本次运行的数据库记录;默认清理。",
+    )
+    return parser.parse_args()
+
+
+if __name__ == "__main__":
+    asyncio.run(_run(_arguments()))

+ 33 - 0
scripts/verify_video_truncate_runtime.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Verify ffmpeg/OSS runtime dependencies for qwen video truncation."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Verify video truncation runtime")
+    parser.add_argument(
+        "--check-oss",
+        action="store_true",
+        help="Also verify ALIYUN_OSS_* credentials are configured",
+    )
+    args = parser.parse_args()
+
+    try:
+        from agents.find_agent.tools.qwen_video_analyze import verify_truncation_runtime
+
+        result = verify_truncation_runtime(check_oss=args.check_oss)
+    except Exception as exc:
+        print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False))
+        return 1
+
+    print(json.dumps(result, ensure_ascii=False))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 16 - 0
sql/video_discovery_add_aigc_plan.sql

@@ -0,0 +1,16 @@
+-- 为 video_discovery_candidate 增加 AIGC 计划追踪字段。
+-- MySQL 5.7+ / 8.0+
+
+ALTER TABLE `video_discovery_candidate`
+  ADD COLUMN `aigc_crawler_plan_id` VARCHAR(64) NULL
+    COMMENT '已创建的 AIGC 爬取计划 id'
+    AFTER `decision_bucket`,
+  ADD COLUMN `aigc_produce_plan_id` VARCHAR(64) NULL
+    COMMENT '绑定的 AIGC 生成计划 id'
+    AFTER `aigc_crawler_plan_id`,
+  ADD COLUMN `aigc_publish_plan_id` VARCHAR(64) NULL
+    COMMENT '关联的 AIGC 发布计划 id'
+    AFTER `aigc_produce_plan_id`,
+  ADD COLUMN `aigc_plan_label` VARCHAR(64) NULL
+    COMMENT '均匀分发时使用的计划标签'
+    AFTER `aigc_publish_plan_id`;

+ 25 - 0
sql/video_discovery_add_audit_evidence.sql

@@ -0,0 +1,25 @@
+-- 已创建 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 `content_analysis_verified` TINYINT NOT NULL DEFAULT 0
+    COMMENT '是否已核验真实视频内容'
+    AFTER `content_analysis`,
+  ADD COLUMN `expansion_worthy_tags_json` TEXT NULL
+    COMMENT '值得继续搜索的标签 JSON'
+    AFTER `content_analysis_verified`;

+ 13 - 0
sql/video_discovery_add_biz_dt.sql

@@ -0,0 +1,13 @@
+-- video_discovery_run 增加 biz_dt,并支持按业务日 + demand_grade 幂等跳过
+ALTER TABLE `video_discovery_run`
+  ADD COLUMN `biz_dt` VARCHAR(32) NULL COMMENT '业务日 YYYYMMDD' AFTER `run_id`,
+  ADD KEY `idx_video_discovery_run_biz_dt` (`biz_dt`);
+
+-- 历史数据回填:按 create_time 回填业务日
+UPDATE `video_discovery_run`
+SET `biz_dt` = DATE_FORMAT(`create_time`, '%Y%m%d')
+WHERE `biz_dt` IS NULL;
+
+-- 同一业务日 + 需求分级 id 仅保留一条调度记录
+ALTER TABLE `video_discovery_run`
+  ADD UNIQUE KEY `uk_video_discovery_run_biz_grade` (`biz_dt`, `demand_grade_id`);

+ 125 - 0
sql/video_discovery_tables.sql

@@ -0,0 +1,125 @@
+-- find_agent 视频发现持久化表
+-- MySQL 5.7+ / 8.0+
+-- 与 supply_infra/db/models/video_discovery.py 保持一致。
+
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `video_discovery_run` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `run_id` VARCHAR(64) NOT NULL COMMENT 'Agent 运行标识',
+  `biz_dt` VARCHAR(32) NULL COMMENT '业务日 YYYYMMDD',
+  `demand_grade_id` BIGINT NULL COMMENT '可选 demand_grade.id',
+  `demand_word` VARCHAR(256) NOT NULL COMMENT '用户给定需求词',
+  `seed_video_id` VARCHAR(64) NULL COMMENT '参考视频 id',
+  `seed_video_title` VARCHAR(512) NULL COMMENT '参考视频标题',
+  `relevant_points_json` TEXT NOT NULL COMMENT '与需求相关的视频点位 JSON',
+  `intent_summary` TEXT NULL COMMENT 'Agent 对真实内容意图的解释',
+  `status` VARCHAR(24) NOT NULL DEFAULT 'running'
+    COMMENT 'running / finished / failed',
+  `search_count` INT NOT NULL DEFAULT 0 COMMENT '已保存搜索页数',
+  `primary_count` INT NOT NULL DEFAULT 0 COMMENT '主推荐数',
+  `backup_count` INT NOT NULL DEFAULT 0 COMMENT 'Agent 自动保留的补充候选数',
+  `stop_reason` TEXT NULL COMMENT '停止搜索的证据或失败原因',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+    ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_video_discovery_run_id` (`run_id`),
+  UNIQUE KEY `uk_video_discovery_run_biz_grade` (`biz_dt`, `demand_grade_id`),
+  KEY `idx_video_discovery_run_demand` (`demand_word`),
+  KEY `idx_video_discovery_run_grade` (`demand_grade_id`),
+  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='一次需求找片运行';
+
+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 '关键词/筛选/游标组合哈希',
+  `keyword` VARCHAR(256) NOT NULL COMMENT 'Agent 自主确定的搜索词',
+  `query_reason` TEXT NOT NULL COMMENT '为何形成该搜索词、希望验证什么',
+  `source_type` VARCHAR(32) NOT NULL
+    COMMENT 'demand / seed / point / tag / author / pagination / mixed',
+  `source_value` TEXT NULL COMMENT '来源点位、标签、作者或父关键词',
+  `parent_search_id` BIGINT NULL COMMENT '由哪次搜索扩展而来',
+  `provider` VARCHAR(32) NOT NULL DEFAULT 'internal_keyword'
+    COMMENT 'internal_keyword / tikhub / internal_blogger',
+  `provider_state_json` TEXT NULL
+    COMMENT '供应方分页状态,如 TikHub search_id/backtrace',
+  `content_type` VARCHAR(16) NOT NULL DEFAULT '视频' COMMENT '搜索内容类型',
+  `sort_type` VARCHAR(32) NOT NULL DEFAULT '综合排序' COMMENT '搜索排序',
+  `publish_time` VARCHAR(32) NOT NULL DEFAULT '不限' COMMENT '发布时间筛选',
+  `cursor` VARCHAR(128) NOT NULL DEFAULT '0' COMMENT '本页游标',
+  `page_no` INT NOT NULL DEFAULT 1 COMMENT '该关键词的页码',
+  `results_count` INT NOT NULL DEFAULT 0 COMMENT '本页结果数',
+  `new_candidate_count` INT NOT NULL DEFAULT 0 COMMENT '本页新增候选数',
+  `has_more` TINYINT NOT NULL DEFAULT 0 COMMENT '接口是否有下一页',
+  `next_cursor` VARCHAR(128) NULL COMMENT '下一页游标',
+  `result_ids_json` TEXT NULL COMMENT '本页 aweme_id 列表 JSON',
+  `status` VARCHAR(16) NOT NULL DEFAULT 'success' COMMENT 'success / failed',
+  `error_message` TEXT NULL COMMENT '搜索失败信息',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `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`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+  COMMENT='关键词、标签、作者或翻页搜索轨迹';
+
+CREATE TABLE IF NOT EXISTS `video_discovery_candidate` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT,
+  `run_id` VARCHAR(64) NOT NULL COMMENT '发现运行 run_id',
+  `aweme_id` VARCHAR(64) NOT NULL COMMENT '抖音视频 id',
+  `title` VARCHAR(512) NULL COMMENT '视频标题',
+  `content_link` VARCHAR(1024) NULL COMMENT '抖音页面链接',
+  `video_url` TEXT NULL COMMENT '解析时使用的播放链接',
+  `author_name` VARCHAR(256) NULL COMMENT '作者名',
+  `author_sec_uid` VARCHAR(256) NULL COMMENT '作者 sec_uid',
+  `source_keywords_json` TEXT NULL COMMENT '命中过该视频的搜索词 JSON',
+  `source_search_ids_json` TEXT NULL COMMENT '来源搜索轨迹 id JSON',
+  `tags_json` TEXT NULL COMMENT '视频标签/话题 JSON',
+  `hit_points_json` TEXT NULL COMMENT '命中的需求相关点 JSON',
+  `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 '是否已执行年龄画像标准化',
+  `content_analysis` TEXT NULL COMMENT '视频内容核验结果',
+  `content_analysis_verified` TINYINT NOT NULL DEFAULT 0
+    COMMENT '是否已核验真实视频内容',
+  `expansion_worthy_tags_json` TEXT NULL COMMENT '值得继续搜索的标签 JSON',
+  `relevance_score` DECIMAL(8,6) NULL COMMENT 'R,范围 0~1',
+  `elder_score` DECIMAL(8,6) NULL COMMENT 'E,范围 0~1',
+  `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 '分享价值依据',
+  `decision_reason` TEXT NULL COMMENT '最终分池依据',
+  `decision_bucket` VARCHAR(24) NOT NULL DEFAULT 'pending_evaluation'
+    COMMENT 'primary / backup / rejected / pending_evaluation',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+    ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_video_discovery_candidate_run_aweme` (`run_id`, `aweme_id`),
+  KEY `idx_video_discovery_candidate_bucket` (`run_id`, `decision_bucket`),
+  KEY `idx_video_discovery_candidate_author` (`author_sec_uid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+  COMMENT='本次运行发现的视频及评估快照';

+ 10 - 1
supply_agent/agent/core.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from collections.abc import AsyncIterator, Iterator
+from collections.abc import AsyncIterator, Callable, Iterator
 
 from supply_agent.agent.loop import AgentLoop
 from supply_agent.config import Settings, get_settings
@@ -60,6 +60,9 @@ class Agent:
         temperature: float | None = None,
         reasoning_effort: str | None = None,
         logger: AgentLogger | None = None,
+        completion_guard: Callable[[list[Message]], str | None] | None = None,
+        tool_call_budgets: dict[str, tuple[set[str], int]] | None = None,
+        tool_repeat_requires_change: dict[str, set[str]] | None = None,
     ) -> None:
         self.name = name
         self.settings = settings or get_settings()
@@ -77,6 +80,9 @@ 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
+        self.tool_call_budgets = tool_call_budgets or {}
+        self.tool_repeat_requires_change = tool_repeat_requires_change or {}
 
         if model:
             self.llm.set_model(model)
@@ -133,6 +139,9 @@ class Agent:
             temperature=self.temperature,
             logger=self.logger,
             active_skills=self._active_skills,
+            completion_guard=self.completion_guard,
+            tool_call_budgets=self.tool_call_budgets,
+            tool_repeat_requires_change=self.tool_repeat_requires_change,
         )
 
     def _finish_run(self, result: AgentResult) -> None:

+ 285 - 31
supply_agent/agent/loop.py

@@ -4,7 +4,7 @@ import json
 from collections.abc import AsyncIterator, Callable, Iterator
 from typing import TYPE_CHECKING
 
-from supply_agent.llm.client import LLMClient
+from supply_agent.llm.client import LLMClient, MalformedFunctionCallError
 from supply_agent.tools.registry import ToolRegistry
 from supply_agent.types import (
     AgentEvent,
@@ -12,6 +12,9 @@ from supply_agent.types import (
     AgentResult,
     Message,
     Role,
+    ToolCall,
+    ToolDefinition,
+    ToolResult,
 )
 
 if TYPE_CHECKING:
@@ -36,6 +39,9 @@ class AgentLoop:
         logger: AgentLogger | None = None,
         active_skills: list[str] | None = None,
         system_message_builder: Callable[[], Message] | None = None,
+        completion_guard: Callable[[list[Message]], str | None] | None = None,
+        tool_call_budgets: dict[str, tuple[set[str], int]] | None = None,
+        tool_repeat_requires_change: dict[str, set[str]] | None = None,
     ) -> None:
         self.llm = llm
         self.tools = tools
@@ -46,6 +52,12 @@ class AgentLoop:
         self.temperature = temperature
         self.logger = logger
         self.active_skills = active_skills or []
+        self.completion_guard = completion_guard
+        self.tool_call_budgets = tool_call_budgets or {}
+        self.tool_repeat_requires_change = tool_repeat_requires_change or {}
+        self._tool_budget_counts = {
+            group: 0 for group in self.tool_call_budgets
+        }
         self.tool_calls_made = 0
 
     def _all_messages(self) -> list[Message]:
@@ -64,24 +76,184 @@ class AgentLoop:
         if self.system_message_builder:
             self.system_message = self.system_message_builder()
 
+    def _completion_block_reason(self) -> str | None:
+        if self.completion_guard is None:
+            return None
+        return self.completion_guard(self.messages)
+
+    def _append_completion_feedback(self, reason: str) -> None:
+        self.messages.append(
+            Message(
+                role=Role.USER,
+                content=(
+                    "完成守卫拒绝当前最终回答:"
+                    f"{reason}。请继续调用必要工具修复这些问题;"
+                    "只有守卫条件全部满足后才能输出最终答案。"
+                ),
+            )
+        )
+
+    def _append_malformed_tool_feedback(self) -> None:
+        self.messages.append(
+            Message(
+                role=Role.USER,
+                content=(
+                    "上一步连续生成了无效工具参数。请继续任务,下一步只调用一个"
+                    "最必要的工具,严格使用其 schema,不得添加未定义字段。"
+                ),
+            )
+        )
+
+    def _consume_tool_budget(self, tool_name: str) -> str | None:
+        matching = [
+            (group, limit)
+            for group, (tool_names, limit) in self.tool_call_budgets.items()
+            if tool_name in tool_names
+        ]
+        for group, limit in matching:
+            used = self._tool_budget_counts[group]
+            if used >= limit:
+                return (
+                    f"工具预算已耗尽: group={group}, limit={limit}。"
+                    "请使用已有结果完成筛选、持久化和审计,不要继续扩大搜索。"
+                )
+        for group, _ in matching:
+            self._tool_budget_counts[group] += 1
+        return None
+
+    def _repeat_policy_error(self, tool_name: str) -> str | None:
+        dependencies = self.tool_repeat_requires_change.get(tool_name)
+        if not dependencies:
+            return None
+        last_call_index = -1
+        last_change_index = -1
+        for index, message in enumerate(self.messages):
+            if message.role != Role.TOOL or not message.name:
+                continue
+            try:
+                payload = json.loads(message.content or "")
+            except (TypeError, json.JSONDecodeError):
+                continue
+            if isinstance(payload, dict) and payload.get("error"):
+                continue
+            if message.name == tool_name:
+                last_call_index = index
+            if message.name in dependencies:
+                last_change_index = index
+        if last_call_index >= 0 and last_change_index < last_call_index:
+            return (
+                f"工具 {tool_name} 在状态未变化时不得重复调用。"
+                f"必须先成功执行以下任一状态变更工具: {sorted(dependencies)}"
+            )
+        return None
+
+    def _available_tool_definitions(self) -> list[ToolDefinition] | None:
+        exhausted_tools: set[str] = set()
+        for group, (tool_names, limit) in self.tool_call_budgets.items():
+            if self._tool_budget_counts[group] >= limit:
+                exhausted_tools.update(tool_names)
+        definitions = [
+            definition
+            for definition in self.tools.definitions
+            if definition.name not in exhausted_tools
+            and self._repeat_policy_error(definition.name) is None
+        ]
+        return definitions or None
+
+    def _refund_tool_budget_for_input_error(
+        self,
+        tool_name: str,
+        result: ToolResult,
+    ) -> None:
+        try:
+            payload = json.loads(result.content)
+        except (TypeError, json.JSONDecodeError):
+            return
+        if not isinstance(payload, dict) or payload.get("input_error") is not True:
+            return
+        for group, (tool_names, _) in self.tool_call_budgets.items():
+            if tool_name in tool_names and self._tool_budget_counts[group] > 0:
+                self._tool_budget_counts[group] -= 1
+
+    @staticmethod
+    def _budget_error_result(tool_call: ToolCall, error: str) -> ToolResult:
+        return ToolResult(
+            tool_call_id=tool_call.id,
+            name=tool_call.name,
+            content=json.dumps(
+                {
+                    "error": error,
+                    "budget_exhausted": True,
+                    "title": "工具预算拒绝调用",
+                },
+                ensure_ascii=False,
+            ),
+            is_error=True,
+        )
+
+    @staticmethod
+    def _policy_error_result(tool_call: ToolCall, error: str) -> ToolResult:
+        return ToolResult(
+            tool_call_id=tool_call.id,
+            name=tool_call.name,
+            content=json.dumps(
+                {
+                    "error": error,
+                    "policy_rejected": True,
+                    "title": "工具状态策略拒绝调用",
+                },
+                ensure_ascii=False,
+            ),
+            is_error=True,
+        )
+
+    def _max_iterations_result(self, iterations: int) -> AgentResult:
+        reason = self._completion_block_reason()
+        content = "Max iterations reached"
+        if reason:
+            content = f"Max iterations reached before completion: {reason}"
+        return self._build_result(content, iterations)
+
     def run(self) -> AgentResult:
         iterations = 0
         while iterations < self.max_iterations:
             iterations += 1
-            response = self.llm.chat(
-                self._all_messages(),
-                tools=self.tools.definitions or None,
-                temperature=self.temperature,
-                iteration=iterations,
-            )
+            try:
+                response = self.llm.chat(
+                    self._all_messages(),
+                    tools=self._available_tool_definitions(),
+                    temperature=self.temperature,
+                    iteration=iterations,
+                )
+            except MalformedFunctionCallError:
+                self._append_malformed_tool_feedback()
+                continue
             self.messages.append(response)
 
             if not response.tool_calls:
+                reason = self._completion_block_reason()
+                if reason:
+                    self._append_completion_feedback(reason)
+                    continue
                 return self._build_result(response.content or "", iterations)
 
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
-                result = self.tools.execute(tc.id, tc.name, tc.arguments)
+                policy_error = self._repeat_policy_error(tc.name)
+                budget_error = (
+                    None if policy_error else self._consume_tool_budget(tc.name)
+                )
+                result = (
+                    self._policy_error_result(tc, policy_error)
+                    if policy_error
+                    else (
+                        self._budget_error_result(tc, budget_error)
+                        if budget_error
+                        else self.tools.execute(tc.id, tc.name, tc.arguments)
+                    )
+                )
+                if not policy_error and not budget_error:
+                    self._refund_tool_budget_for_input_error(tc.name, result)
                 if self.logger:
                     self.logger.log_tool_call(
                         iterations,
@@ -104,6 +276,9 @@ class AgentLoop:
                     )
                 )
 
+        if self.completion_guard is not None:
+            return self._max_iterations_result(iterations)
+
         self.messages.append(
             Message(
                 role=Role.USER,
@@ -122,20 +297,44 @@ class AgentLoop:
         iterations = 0
         while iterations < self.max_iterations:
             iterations += 1
-            response = await self.llm.achat(
-                self._all_messages(),
-                tools=self.tools.definitions or None,
-                temperature=self.temperature,
-                iteration=iterations,
-            )
+            try:
+                response = await self.llm.achat(
+                    self._all_messages(),
+                    tools=self._available_tool_definitions(),
+                    temperature=self.temperature,
+                    iteration=iterations,
+                )
+            except MalformedFunctionCallError:
+                self._append_malformed_tool_feedback()
+                continue
             self.messages.append(response)
 
             if not response.tool_calls:
+                reason = self._completion_block_reason()
+                if reason:
+                    self._append_completion_feedback(reason)
+                    continue
                 return self._build_result(response.content or "", iterations)
 
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
-                result = await self.tools.aexecute(tc.id, tc.name, tc.arguments)
+                policy_error = self._repeat_policy_error(tc.name)
+                budget_error = (
+                    None if policy_error else self._consume_tool_budget(tc.name)
+                )
+                result = (
+                    self._policy_error_result(tc, policy_error)
+                    if policy_error
+                    else (
+                        self._budget_error_result(tc, budget_error)
+                        if budget_error
+                        else await self.tools.aexecute(
+                            tc.id, tc.name, tc.arguments
+                        )
+                    )
+                )
+                if not policy_error and not budget_error:
+                    self._refund_tool_budget_for_input_error(tc.name, result)
                 if self.logger:
                     self.logger.log_tool_call(
                         iterations,
@@ -158,6 +357,9 @@ class AgentLoop:
                     )
                 )
 
+        if self.completion_guard is not None:
+            return self._max_iterations_result(iterations)
+
         self.messages.append(
             Message(
                 role=Role.USER,
@@ -181,15 +383,23 @@ class AgentLoop:
                 data={"iteration": iterations},
             )
 
-            response = self.llm.chat(
-                self._all_messages(),
-                tools=self.tools.definitions or None,
-                temperature=self.temperature,
-                iteration=iterations,
-            )
+            try:
+                response = self.llm.chat(
+                    self._all_messages(),
+                    tools=self._available_tool_definitions(),
+                    temperature=self.temperature,
+                    iteration=iterations,
+                )
+            except MalformedFunctionCallError:
+                self._append_malformed_tool_feedback()
+                continue
             self.messages.append(response)
 
             if not response.tool_calls:
+                reason = self._completion_block_reason()
+                if reason:
+                    self._append_completion_feedback(reason)
+                    continue
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
                     data={"content": response.content or ""},
@@ -206,7 +416,21 @@ class AgentLoop:
                     type=AgentEventType.TOOL_CALL,
                     data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
                 )
-                result = self.tools.execute(tc.id, tc.name, tc.arguments)
+                policy_error = self._repeat_policy_error(tc.name)
+                budget_error = (
+                    None if policy_error else self._consume_tool_budget(tc.name)
+                )
+                result = (
+                    self._policy_error_result(tc, policy_error)
+                    if policy_error
+                    else (
+                        self._budget_error_result(tc, budget_error)
+                        if budget_error
+                        else self.tools.execute(tc.id, tc.name, tc.arguments)
+                    )
+                )
+                if not policy_error and not budget_error:
+                    self._refund_tool_budget_for_input_error(tc.name, result)
                 if self.logger:
                     self.logger.log_tool_call(
                         iterations,
@@ -229,7 +453,10 @@ class AgentLoop:
                     )
                 )
 
-        yield AgentEvent(type=AgentEventType.DONE, data={"content": "Max iterations reached"})
+        yield AgentEvent(
+            type=AgentEventType.DONE,
+            data=self._max_iterations_result(iterations).model_dump(),
+        )
 
     async def astream(self) -> AsyncIterator[AgentEvent]:
         iterations = 0
@@ -240,15 +467,23 @@ class AgentLoop:
                 data={"iteration": iterations},
             )
 
-            response = await self.llm.achat(
-                self._all_messages(),
-                tools=self.tools.definitions or None,
-                temperature=self.temperature,
-                iteration=iterations,
-            )
+            try:
+                response = await self.llm.achat(
+                    self._all_messages(),
+                    tools=self._available_tool_definitions(),
+                    temperature=self.temperature,
+                    iteration=iterations,
+                )
+            except MalformedFunctionCallError:
+                self._append_malformed_tool_feedback()
+                continue
             self.messages.append(response)
 
             if not response.tool_calls:
+                reason = self._completion_block_reason()
+                if reason:
+                    self._append_completion_feedback(reason)
+                    continue
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
                     data={"content": response.content or ""},
@@ -265,7 +500,23 @@ class AgentLoop:
                     type=AgentEventType.TOOL_CALL,
                     data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
                 )
-                result = await self.tools.aexecute(tc.id, tc.name, tc.arguments)
+                policy_error = self._repeat_policy_error(tc.name)
+                budget_error = (
+                    None if policy_error else self._consume_tool_budget(tc.name)
+                )
+                result = (
+                    self._policy_error_result(tc, policy_error)
+                    if policy_error
+                    else (
+                        self._budget_error_result(tc, budget_error)
+                        if budget_error
+                        else await self.tools.aexecute(
+                            tc.id, tc.name, tc.arguments
+                        )
+                    )
+                )
+                if not policy_error and not budget_error:
+                    self._refund_tool_budget_for_input_error(tc.name, result)
                 if self.logger:
                     self.logger.log_tool_call(
                         iterations,
@@ -288,7 +539,10 @@ class AgentLoop:
                     )
                 )
 
-        yield AgentEvent(type=AgentEventType.DONE, data={"content": "Max iterations reached"})
+        yield AgentEvent(
+            type=AgentEventType.DONE,
+            data=self._max_iterations_result(iterations).model_dump(),
+        )
 
     def _build_result(self, content: str, iterations: int) -> AgentResult:
         return AgentResult(

+ 4 - 0
supply_agent/config.py

@@ -31,6 +31,10 @@ class Settings(BaseSettings):
     )
     openrouter_site_url: str = Field(default="", alias="OPENROUTER_SITE_URL")
     openrouter_site_name: str = Field(default="SupplyAgent", alias="OPENROUTER_SITE_NAME")
+    openrouter_timeout_seconds: float = Field(
+        default=120.0,
+        alias="OPENROUTER_TIMEOUT_SECONDS",
+    )
 
     # Agent
     agent_max_iterations: int = Field(default=20, alias="AGENT_MAX_ITERATIONS")

+ 78 - 2
supply_agent/llm/client.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import logging
 from collections.abc import AsyncIterator, Iterator
 from typing import TYPE_CHECKING, Any
 
@@ -11,6 +12,19 @@ from supply_agent.types import Message, Role, ToolCall, ToolDefinition
 if TYPE_CHECKING:
     from supply_agent.logging.logger import AgentLogger
 
+logger = logging.getLogger(__name__)
+
+_MALFORMED_FUNCTION_CALL = "MALFORMED_FUNCTION_CALL"
+_MAX_MALFORMED_RETRIES = 3
+_MALFORMED_RETRY_INSTRUCTION = (
+    "上一次工具调用不是有效 JSON。请重新决定下一步,只调用一个最必要的工具;"
+    "严格使用工具 schema,省略未定义字段,并确保 arguments 是完整 JSON。"
+)
+
+
+class MalformedFunctionCallError(RuntimeError):
+    """Provider repeatedly failed to produce a valid tool call."""
+
 
 class LLMClient:
     """OpenRouter LLM client using the OpenAI-compatible API."""
@@ -72,12 +86,35 @@ class LLMClient:
             "messages": [m.to_api_dict() for m in messages],
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         extra_body = self._extra_body()
         if extra_body:
             kwargs["extra_body"] = extra_body
 
-        response = self._client.chat.completions.create(**kwargs)
+        response = None
+        for attempt in range(_MAX_MALFORMED_RETRIES + 1):
+            response = self._client.chat.completions.create(**kwargs)
+            if not self._is_malformed_function_call(response):
+                break
+            if attempt >= _MAX_MALFORMED_RETRIES:
+                raise MalformedFunctionCallError(
+                    "LLM provider returned MALFORMED_FUNCTION_CALL "
+                    f"after {_MAX_MALFORMED_RETRIES + 1} attempts"
+                )
+            logger.warning(
+                "LLM provider returned MALFORMED_FUNCTION_CALL; retrying (%d/%d)",
+                attempt + 1,
+                _MAX_MALFORMED_RETRIES,
+            )
+            kwargs["temperature"] = 0
+            kwargs["parallel_tool_calls"] = False
+            kwargs["messages"] = [
+                *[m.to_api_dict() for m in messages],
+                {"role": "user", "content": _MALFORMED_RETRY_INSTRUCTION},
+            ]
+
+        assert response is not None
         raw_message = response.choices[0].message
         result = self._parse_response(raw_message)
 
@@ -105,12 +142,35 @@ class LLMClient:
             "messages": [m.to_api_dict() for m in messages],
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         extra_body = self._extra_body()
         if extra_body:
             kwargs["extra_body"] = extra_body
 
-        response = await self._async_client.chat.completions.create(**kwargs)
+        response = None
+        for attempt in range(_MAX_MALFORMED_RETRIES + 1):
+            response = await self._async_client.chat.completions.create(**kwargs)
+            if not self._is_malformed_function_call(response):
+                break
+            if attempt >= _MAX_MALFORMED_RETRIES:
+                raise MalformedFunctionCallError(
+                    "LLM provider returned MALFORMED_FUNCTION_CALL "
+                    f"after {_MAX_MALFORMED_RETRIES + 1} attempts"
+                )
+            logger.warning(
+                "LLM provider returned MALFORMED_FUNCTION_CALL; retrying (%d/%d)",
+                attempt + 1,
+                _MAX_MALFORMED_RETRIES,
+            )
+            kwargs["temperature"] = 0
+            kwargs["parallel_tool_calls"] = False
+            kwargs["messages"] = [
+                *[m.to_api_dict() for m in messages],
+                {"role": "user", "content": _MALFORMED_RETRY_INSTRUCTION},
+            ]
+
+        assert response is not None
         raw_message = response.choices[0].message
         result = self._parse_response(raw_message)
 
@@ -139,6 +199,7 @@ class LLMClient:
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
             "stream": True,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         extra_body = self._extra_body()
         if extra_body:
@@ -179,6 +240,7 @@ class LLMClient:
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
             "stream": True,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         extra_body = self._extra_body()
         if extra_body:
@@ -220,6 +282,20 @@ class LLMClient:
             reasoning=reasoning,
         )
 
+    @staticmethod
+    def _is_malformed_function_call(response: Any) -> bool:
+        """Detect OpenRouter provider errors that otherwise look like empty answers."""
+        choices = getattr(response, "choices", None)
+        if not choices:
+            return False
+        choice = choices[0]
+        native_reason = getattr(choice, "native_finish_reason", None)
+        if not native_reason:
+            extra = getattr(choice, "model_extra", None)
+            if isinstance(extra, dict):
+                native_reason = extra.get("native_finish_reason")
+        return str(native_reason or "").upper() == _MALFORMED_FUNCTION_CALL
+
     def set_model(self, model: str) -> None:
         """Switch to a different model at runtime."""
         self.model = model

+ 20 - 10
supply_agent/tools/base.py

@@ -153,30 +153,40 @@ class Tool:
 
     def execute(self, arguments: str | dict[str, Any]) -> str:
         """Execute the tool with JSON or dict arguments, returning a string result."""
-        if isinstance(arguments, str):
-            args = json.loads(arguments) if arguments.strip() else {}
-        else:
-            args = arguments
-        args = _coerce_args(self.func, args)
         try:
+            if isinstance(arguments, str):
+                args = json.loads(arguments) if arguments.strip() else {}
+            else:
+                args = arguments
+            args = _coerce_args(self.func, args)
             result = self(**args)
             if inspect.isawaitable(result):
                 raise RuntimeError(
                     f"Tool '{self.name}' is async. Use ToolRegistry.aexecute() instead."
                 )
             return str(result) if result is not None else ""
+        except (json.JSONDecodeError, TypeError) as e:
+            return json.dumps(
+                {"error": str(e), "input_error": True},
+                ensure_ascii=False,
+            )
         except Exception as e:
             return json.dumps({"error": str(e)}, ensure_ascii=False)
 
     async def aexecute(self, arguments: str | dict[str, Any]) -> str:
         """Async execute the tool."""
-        if isinstance(arguments, str):
-            args = json.loads(arguments) if arguments.strip() else {}
-        else:
-            args = arguments
-        args = _coerce_args(self.func, args)
         try:
+            if isinstance(arguments, str):
+                args = json.loads(arguments) if arguments.strip() else {}
+            else:
+                args = arguments
+            args = _coerce_args(self.func, args)
             result = await self.acall(**args)
             return str(result) if result is not None else ""
+        except (json.JSONDecodeError, TypeError) as e:
+            return json.dumps(
+                {"error": str(e), "input_error": True},
+                ensure_ascii=False,
+            )
         except Exception as e:
             return json.dumps({"error": str(e)}, ensure_ascii=False)

+ 9 - 0
supply_infra/aigc/__init__.py

@@ -0,0 +1,9 @@
+from supply_infra.aigc.client import AigcClient
+from supply_infra.aigc.plan_map import AigcPlanPair, distribute_evenly, list_unique_plan_pairs
+
+__all__ = [
+    "AigcClient",
+    "AigcPlanPair",
+    "distribute_evenly",
+    "list_unique_plan_pairs",
+]

+ 192 - 0
supply_infra/aigc/client.py

@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+AIGC_BASE_URL = "https://aigc-api.aiddit.com"
+CRAWLER_PLAN_CREATE_URL = f"{AIGC_BASE_URL}/aigc/crawler/plan/save"
+GET_PRODUCE_PLAN_DETAIL_BY_ID = f"{AIGC_BASE_URL}/aigc/produce/plan/detail"
+PRODUCE_PLAN_SAVE = f"{AIGC_BASE_URL}/aigc/produce/plan/save"
+DEFAULT_TIMEOUT = 60.0
+SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
+_INPUT_SOURCE_CHECK_KEYS = ("inputSourceModal", "inputSourceChannel", "contentType")
+
+
+class AigcClient:
+    """AIGC 平台 HTTP 客户端(视频爬取计划 + 生成计划绑定)。"""
+
+    def __init__(self, token: str | None = None, *, dry_run: bool | None = None) -> None:
+        self.token = (token or os.getenv("AIGC_API_TOKEN") or "").strip()
+        if dry_run is None:
+            dry_run = os.getenv("AIGC_DRY_RUN", "").strip().lower() in {
+                "1",
+                "true",
+                "yes",
+                "on",
+            }
+        self.dry_run = dry_run
+
+    def create_video_crawler_plan(
+        self,
+        aweme_ids: list[str],
+        *,
+        plan_name: str | None = None,
+    ) -> dict[str, Any]:
+        if not aweme_ids:
+            raise ValueError("aweme_ids 不能为空")
+        if len(aweme_ids) > 100:
+            raise ValueError(f"单次最多 100 个视频,当前 {len(aweme_ids)} 个")
+
+        dt = datetime.now(SHANGHAI_TZ).strftime("%Y%m%d%H%M%S")
+        crawler_plan_name = plan_name or f"【SupplyAgent】抖音视频直接抓取-{dt}-抖音"
+        params = {
+            "channel": 2,
+            "contentModal": 4,
+            "crawlerComment": 0,
+            "crawlerMode": 5,
+            "filterAccountMatchMode": 2,
+            "filterContentMatchMode": 2,
+            "frequencyType": 2,
+            "inputModeValues": aweme_ids,
+            "name": crawler_plan_name,
+            "planType": 2,
+            "searchModeValues": [],
+            "srtExtractFlag": 1,
+            "videoKeyFrameType": 1,
+            "voiceExtractFlag": 1,
+        }
+
+        if self.dry_run:
+            return {
+                "success": True,
+                "dry_run": True,
+                "crawler_plan_id": "dry-run-crawler-plan-id",
+                "crawler_plan_name": crawler_plan_name,
+                "aweme_ids": aweme_ids,
+            }
+
+        response_json = self._post(CRAWLER_PLAN_CREATE_URL, params)
+        if response_json.get("code") != 0:
+            message = response_json.get("msg", "创建爬取计划失败")
+            return {
+                "success": False,
+                "error": message,
+                "response": response_json,
+            }
+
+        crawler_plan_id = str(response_json.get("data", {}).get("id") or "").strip()
+        return {
+            "success": True,
+            "crawler_plan_id": crawler_plan_id,
+            "crawler_plan_name": crawler_plan_name,
+            "aweme_ids": aweme_ids,
+        }
+
+    def bind_crawler_to_produce_plan(
+        self,
+        crawler_plan_id: str,
+        produce_plan_id: str,
+        *,
+        crawler_plan_name: str,
+    ) -> dict[str, Any]:
+        if not crawler_plan_id or not produce_plan_id:
+            raise ValueError("crawler_plan_id 与 produce_plan_id 均不能为空")
+
+        if self.dry_run:
+            return {
+                "success": True,
+                "dry_run": True,
+                "produce_plan_id": produce_plan_id,
+                "msg": "成功",
+            }
+
+        input_source_info = {
+            "contentType": 1,
+            "inputSourceType": 2,
+            "inputSourceValue": crawler_plan_id,
+            "inputSourceLabel": f"原始帖子-视频-抖音-内容添加计划-{crawler_plan_name}",
+            "inputSourceModal": 4,
+            "inputSourceChannel": 2,
+        }
+        produce_plan_detail, error = self._get_produce_plan_detail(produce_plan_id)
+        if error:
+            return {"success": False, "produce_plan_id": produce_plan_id, "error": error}
+
+        input_source_groups = produce_plan_detail.get("inputSourceGroups", [])
+        if not input_source_groups:
+            return {
+                "success": False,
+                "produce_plan_id": produce_plan_id,
+                "error": "生成计划没有输入源组",
+            }
+
+        input_source_index = 0
+        for index, input_source_group in enumerate(input_source_groups):
+            input_sources = input_source_group.get("inputSources", [])
+            if not input_sources:
+                continue
+            first_input_source = input_sources[0]
+            if all(
+                input_source_info.get(key, 0) == first_input_source.get(key, -1)
+                for key in _INPUT_SOURCE_CHECK_KEYS
+            ):
+                input_source_index = index
+                break
+
+        input_source_group = input_source_groups[input_source_index]
+        input_source_group.setdefault("inputSources", []).append(input_source_info)
+
+        response_json = self._post(PRODUCE_PLAN_SAVE, produce_plan_detail)
+        if response_json.get("code") != 0 or not response_json.get("data", {}):
+            return {
+                "success": False,
+                "produce_plan_id": produce_plan_id,
+                "error": response_json.get("msg", "爬取计划绑定生成计划异常"),
+            }
+
+        return {
+            "success": True,
+            "produce_plan_id": produce_plan_id,
+            "produce_plan_name": produce_plan_detail.get("name", ""),
+            "msg": "成功",
+        }
+
+    def _get_produce_plan_detail(self, produce_plan_id: str) -> tuple[dict[str, Any], str | None]:
+        response_json = self._post(GET_PRODUCE_PLAN_DETAIL_BY_ID, {"id": produce_plan_id})
+        if response_json.get("code") != 0 or not response_json.get("data", {}):
+            return {}, response_json.get("msg", "获取生成计划详情异常")
+        return response_json.get("data", {}), None
+
+    def _post(self, url: str, params: Any) -> dict[str, Any]:
+        if not self.token:
+            raise RuntimeError("AIGC_API_TOKEN 未配置")
+
+        request = {
+            "baseInfo": {"token": self.token},
+            "params": params,
+        }
+        try:
+            response = requests.post(
+                url=url,
+                json=request,
+                headers={"Content-Type": "application/json"},
+                timeout=DEFAULT_TIMEOUT,
+            )
+            response.raise_for_status()
+            return response.json()
+        except Exception as exc:
+            logger.error(
+                "invoke aigc platform error. url=%s request=%s error=%s",
+                url,
+                json.dumps(request, ensure_ascii=False),
+                exc,
+            )
+            return {}

+ 100 - 0
supply_infra/aigc/plan_map.py

@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, TypeVar
+
+T = TypeVar("T")
+
+# 与 scripts/aigc_platform_api.py 保持一致;按 (生成ID, 发布ID) 去重后做均匀分发。
+AIGC_PLAN_ID_MAP: dict[str, dict[str, str]] = {
+    "健康知识": {"生成ID": "20260408092313211598604", "发布ID": "20260408115944193153417"},
+    "历史名人": {"生成ID": "20260408083251311809309", "发布ID": "20260408115139124511126"},
+    "知识科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
+    "搞笑段子": {"生成ID": "20260408091536533918237", "发布ID": "20260408115842127748387"},
+    "社会风气": {"生成ID": "20260408084318884115213", "发布ID": "20260408115315950129776"},
+    "人生忠告": {"生成ID": "20260408085205791658566", "发布ID": "20260408115405410408001"},
+    "国际时政": {"生成ID": "20260408090208237400605", "发布ID": "20260408115616925523989"},
+    "生活技巧科普": {"生成ID": "20260408083824905654920", "发布ID": "20260408115231567509261"},
+    "贪污腐败": {"生成ID": "20260408090309503416878", "发布ID": "20260408115653908856043"},
+    "民生政策": {"生成ID": "20260408090721867506475", "发布ID": "20260408115727030928177"},
+    "对口型表演": {"生成ID": "20260408092122328523262", "发布ID": "20260408115914659162376"},
+    "中国战争史": {"生成ID": "20260408090950446586451", "发布ID": "20260408115804931772327"},
+    "人财诈骗": {"生成ID": "20260408093140652233649", "发布ID": "20260408120019784463902"},
+    "当代正能量人物": {"生成ID": "20260408083148399635274", "发布ID": "20260408115046382803287"},
+    "国家科技力量": {"生成ID": "20260408085807674913378", "发布ID": "20260408115542550181196"},
+    "国家力量": {"生成ID": "20260408085807674913378", "发布ID": "20260408115542550181196"},
+    "通用": {"生成ID": "20260408085649635441036", "发布ID": "20260408115439581604474"},
+}
+
+
+@dataclass(frozen=True)
+class AigcPlanPair:
+    """一组生成计划 + 发布计划(去重后的分发单元)。"""
+
+    label: str
+    produce_plan_id: str
+    publish_plan_id: str
+
+
+def list_unique_plan_pairs(
+    plan_map: dict[str, dict[str, str]] | None = None,
+) -> list[AigcPlanPair]:
+    """从映射表提取不重复的 (生成ID, 发布ID) 计划对。"""
+    source = plan_map or AIGC_PLAN_ID_MAP
+    seen: set[tuple[str, str]] = set()
+    pairs: list[AigcPlanPair] = []
+    for label, ids in source.items():
+        produce_plan_id = str(ids.get("生成ID") or "").strip()
+        publish_plan_id = str(ids.get("发布ID") or "").strip()
+        if not produce_plan_id or not publish_plan_id:
+            continue
+        key = (produce_plan_id, publish_plan_id)
+        if key in seen:
+            continue
+        seen.add(key)
+        pairs.append(
+            AigcPlanPair(
+                label=label,
+                produce_plan_id=produce_plan_id,
+                publish_plan_id=publish_plan_id,
+            )
+        )
+    return pairs
+
+
+def distribute_evenly(items: list[T], bucket_count: int) -> list[list[T]]:
+    """轮询均匀分配到 bucket_count 个桶。"""
+    if bucket_count <= 0:
+        raise ValueError("bucket_count 必须大于 0")
+    if not items:
+        return []
+    buckets: list[list[T]] = [[] for _ in range(bucket_count)]
+    for index, item in enumerate(items):
+        buckets[index % bucket_count].append(item)
+    return [bucket for bucket in buckets if bucket]
+
+
+def chunk_list(items: list[T], size: int) -> list[list[T]]:
+    if size <= 0:
+        raise ValueError("size 必须大于 0")
+    return [items[i : i + size] for i in range(0, len(items), size)]
+
+
+def assignment_summary(
+    plan_pairs: list[AigcPlanPair],
+    buckets: list[list[Any]],
+) -> list[dict[str, Any]]:
+    """返回每个计划分到的数量摘要。"""
+    summary: list[dict[str, Any]] = []
+    for plan, bucket in zip(plan_pairs, buckets, strict=False):
+        if not bucket:
+            continue
+        summary.append(
+            {
+                "plan_label": plan.label,
+                "produce_plan_id": plan.produce_plan_id,
+                "publish_plan_id": plan.publish_plan_id,
+                "video_count": len(bucket),
+            }
+        )
+    return summary

+ 4 - 0
supply_infra/config.py

@@ -59,6 +59,10 @@ class InfraSettings(BaseSettings):
     )
     log_oss_upload_enabled: bool = Field(default=True, alias="LOG_OSS_UPLOAD_ENABLED")
 
+    # AIGC platform
+    aigc_api_token: str = Field(default="", alias="AIGC_API_TOKEN")
+    aigc_dry_run: bool = Field(default=False, alias="AIGC_DRY_RUN")
+
     @property
     def mysql_url(self) -> str:
         user = quote_plus(self.mysql_user)

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

@@ -23,6 +23,11 @@ from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDet
 from supply_infra.db.models.multi_demand_video_point import MultiDemandVideoPoint
 from supply_infra.db.models.oss_log import OssLog
 from supply_infra.db.models.scheduler_job_execution import SchedulerJobExecution
+from supply_infra.db.models.video_discovery import (
+    VideoDiscoveryCandidate,
+    VideoDiscoveryRun,
+    VideoDiscoverySearch,
+)
 
 __all__ = [
     "CategoryTreeWeight",
@@ -44,4 +49,7 @@ __all__ = [
     "MultiDemandVideoPoint",
     "OssLog",
     "SchedulerJobExecution",
+    "VideoDiscoveryCandidate",
+    "VideoDiscoveryRun",
+    "VideoDiscoverySearch",
 ]

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

@@ -0,0 +1,304 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import (
+    BigInteger,
+    Index,
+    Integer,
+    Numeric,
+    String,
+    Text,
+    UniqueConstraint,
+    func,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class VideoDiscoveryRun(Base):
+    """一次需求找片运行,保存输入、意图解释和完成状态。"""
+
+    __tablename__ = "video_discovery_run"
+    __table_args__ = (
+        UniqueConstraint("run_id", name="uk_video_discovery_run_id"),
+        UniqueConstraint(
+            "biz_dt",
+            "demand_grade_id",
+            name="uk_video_discovery_run_biz_grade",
+        ),
+        Index("idx_video_discovery_run_demand", "demand_word"),
+        Index("idx_video_discovery_run_grade", "demand_grade_id"),
+        Index("idx_video_discovery_run_biz_dt", "biz_dt"),
+        Index("idx_video_discovery_run_status", "status"),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    run_id: Mapped[str] = mapped_column(String(64), nullable=False, comment="Agent 运行标识")
+    biz_dt: Mapped[str | None] = mapped_column(
+        String(32), nullable=True, comment="业务日 YYYYMMDD"
+    )
+    demand_grade_id: Mapped[int | None] = mapped_column(
+        BigInteger, nullable=True, comment="可选 demand_grade.id"
+    )
+    demand_word: Mapped[str] = mapped_column(
+        String(256), nullable=False, comment="用户给定需求词"
+    )
+    seed_video_id: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="参考视频 id"
+    )
+    seed_video_title: Mapped[str | None] = mapped_column(
+        String(512), nullable=True, comment="参考视频标题"
+    )
+    relevant_points_json: Mapped[str] = mapped_column(
+        Text, nullable=False, comment="与需求相关的视频点位 JSON"
+    )
+    intent_summary: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="Agent 对真实内容意图的解释"
+    )
+    status: Mapped[str] = mapped_column(
+        String(24), nullable=False, default="running", comment="running / finished / failed"
+    )
+    search_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="已保存搜索页数"
+    )
+    primary_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="主推荐数"
+    )
+    backup_count: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        default=0,
+        comment="Agent 自动保留的补充候选数",
+    )
+    stop_reason: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="停止搜索的证据或失败原因"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False, server_default=func.now(), comment="创建时间"
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )
+
+
+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",
+            "run_id",
+            "parent_search_id",
+        ),
+        Index("idx_video_discovery_search_keyword", "keyword"),
+    )
+
+    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="关键词/筛选/游标组合哈希"
+    )
+    keyword: Mapped[str] = mapped_column(
+        String(256), nullable=False, comment="Agent 自主确定的搜索词"
+    )
+    query_reason: Mapped[str] = mapped_column(
+        Text, nullable=False, comment="为何形成该搜索词、希望验证什么"
+    )
+    source_type: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        comment="demand / seed / point / tag / author / pagination / mixed",
+    )
+    source_value: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="来源点位、标签或父关键词"
+    )
+    parent_search_id: Mapped[int | None] = mapped_column(
+        BigInteger, nullable=True, comment="由哪次搜索扩展而来"
+    )
+    provider: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        default="internal_keyword",
+        comment="internal_keyword / tikhub / internal_blogger",
+    )
+    provider_state_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="供应方分页状态,如 search_id/backtrace"
+    )
+    content_type: Mapped[str] = mapped_column(
+        String(16), nullable=False, default="视频", comment="搜索内容类型"
+    )
+    sort_type: Mapped[str] = mapped_column(
+        String(32), nullable=False, default="综合排序", comment="搜索排序"
+    )
+    publish_time: Mapped[str] = mapped_column(
+        String(32), nullable=False, default="不限", comment="发布时间筛选"
+    )
+    cursor: Mapped[str] = mapped_column(
+        String(128), nullable=False, default="0", comment="本页游标"
+    )
+    page_no: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=1, comment="该关键词的页码"
+    )
+    results_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="本页结果数"
+    )
+    new_candidate_count: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="本页新增候选数"
+    )
+    has_more: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="接口是否有下一页"
+    )
+    next_cursor: Mapped[str | None] = mapped_column(
+        String(128), nullable=True, comment="下一页游标"
+    )
+    result_ids_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="本页 aweme_id 列表 JSON"
+    )
+    status: Mapped[str] = mapped_column(
+        String(16), nullable=False, default="success", comment="success / failed"
+    )
+    error_message: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="搜索失败信息"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False, server_default=func.now(), comment="创建时间"
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )
+
+
+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_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")
+    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(
+        String(1024), nullable=True, comment="抖音页面链接"
+    )
+    video_url: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="解析时使用的播放链接"
+    )
+    author_name: Mapped[str | None] = mapped_column(
+        String(256), nullable=True, comment="作者名"
+    )
+    author_sec_uid: Mapped[str | None] = mapped_column(
+        String(256), nullable=True, comment="作者 sec_uid"
+    )
+    source_keywords_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="命中过该视频的搜索词 JSON"
+    )
+    source_search_ids_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="来源搜索轨迹 id JSON"
+    )
+    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"
+    )
+    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"
+    )
+    account_age_evidence_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="作者粉丝年龄证据 JSON"
+    )
+    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_portrait_attempted: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="是否已尝试视频点赞画像"
+    )
+    account_portrait_attempted: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="是否已尝试作者粉丝画像"
+    )
+    age_portraits_normalized: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="是否已执行年龄画像标准化"
+    )
+    content_analysis: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="视频内容核验结果"
+    )
+    content_analysis_verified: Mapped[int] = mapped_column(
+        Integer, nullable=False, default=0, comment="是否已核验真实视频内容"
+    )
+    expansion_worthy_tags_json: Mapped[str | None] = mapped_column(
+        Text, nullable=True, comment="值得继续搜索的标签 JSON"
+    )
+    relevance_score: Mapped[Decimal | None] = mapped_column(
+        Numeric(8, 6), nullable=True, comment="R,范围 0~1"
+    )
+    elder_score: Mapped[Decimal | None] = mapped_column(
+        Numeric(8, 6), nullable=True, comment="E,范围 0~1"
+    )
+    share_score: Mapped[Decimal | None] = mapped_column(
+        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"
+    )
+    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),
+        nullable=False,
+        default="pending_evaluation",
+        comment="primary / backup / rejected / pending_evaluation",
+    )
+    aigc_crawler_plan_id: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="已创建的 AIGC 爬取计划 id"
+    )
+    aigc_produce_plan_id: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="绑定的 AIGC 生成计划 id"
+    )
+    aigc_publish_plan_id: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="关联的 AIGC 发布计划 id"
+    )
+    aigc_plan_label: Mapped[str | None] = mapped_column(
+        String(64), nullable=True, comment="均匀分发时使用的计划标签"
+    )
+    create_time: Mapped[datetime] = mapped_column(
+        nullable=False, server_default=func.now(), comment="创建时间"
+    )
+    update_time: Mapped[datetime] = mapped_column(
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+        comment="更新时间",
+    )

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

@@ -36,6 +36,7 @@ from supply_infra.db.repositories.oss_log_repo import OssLogRepository
 from supply_infra.db.repositories.scheduler_job_execution_repo import (
     SchedulerJobExecutionRepository,
 )
+from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
 
 __all__ = [
     "BaseRepository",
@@ -56,4 +57,5 @@ __all__ = [
     "MultiDemandVideoPointRepository",
     "OssLogRepository",
     "SchedulerJobExecutionRepository",
+    "VideoDiscoveryRepository",
 ]

+ 395 - 0
supply_infra/db/repositories/video_discovery_repo.py

@@ -0,0 +1,395 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.exc import OperationalError, ProgrammingError
+
+from supply_infra.db.models.video_discovery import (
+    VideoDiscoveryCandidate,
+    VideoDiscoveryRun,
+    VideoDiscoverySearch,
+)
+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",
+    "video_url",
+    "content_analysis_verified",
+    "expansion_worthy_tags_json",
+)
+
+
+def _json_list(raw: str | None) -> list[Any]:
+    if not raw:
+        return []
+    try:
+        value = json.loads(raw)
+    except (TypeError, ValueError):
+        return []
+    return value if isinstance(value, list) else []
+
+
+def _merge_json_list(raw: str | None, values: list[Any]) -> str | None:
+    merged = _json_list(raw)
+    seen = {
+        json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
+        for value in merged
+    }
+    for value in values:
+        key = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
+        if key not in seen:
+            seen.add(key)
+            merged.append(value)
+    return json.dumps(merged, ensure_ascii=False) if merged else None
+
+
+_PUBLISHABLE_BUCKETS = ("primary", "backup")
+
+
+class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
+    """需求找片运行、搜索轨迹和候选快照的统一 Repository。"""
+
+    model = VideoDiscoveryRun
+
+    def get_run(self, run_id: str) -> VideoDiscoveryRun | None:
+        stmt = select(VideoDiscoveryRun).where(VideoDiscoveryRun.run_id == run_id)
+        return self.session.scalar(stmt)
+
+    def get_by_biz_dt_and_grade(
+        self, biz_dt: str, demand_grade_id: int
+    ) -> VideoDiscoveryRun | None:
+        stmt = select(VideoDiscoveryRun).where(
+            VideoDiscoveryRun.biz_dt == biz_dt,
+            VideoDiscoveryRun.demand_grade_id == int(demand_grade_id),
+        )
+        return self.session.scalar(stmt)
+
+    def list_skip_grade_ids(self, biz_dt: str) -> set[int]:
+        """返回指定业务日已有 running/finished 记录的 demand_grade.id。"""
+        stmt = select(VideoDiscoveryRun.demand_grade_id).where(
+            VideoDiscoveryRun.biz_dt == biz_dt,
+            VideoDiscoveryRun.demand_grade_id.is_not(None),
+            VideoDiscoveryRun.status.in_(("running", "finished")),
+        )
+        try:
+            return {
+                int(grade_id)
+                for grade_id in self.session.scalars(stmt).all()
+                if grade_id is not None
+            }
+        except (OperationalError, ProgrammingError) as exc:
+            if "biz_dt" not in str(exc).lower():
+                raise
+            return set()
+
+    def upsert_scheduled_run(self, values: dict[str, Any]) -> VideoDiscoveryRun:
+        """按 (biz_dt, demand_grade_id) 预创建或重置调度运行记录。"""
+        biz_dt = str(values["biz_dt"])
+        demand_grade_id = int(values["demand_grade_id"])
+        existing = self.get_by_biz_dt_and_grade(biz_dt, demand_grade_id)
+        if existing is None:
+            entity = VideoDiscoveryRun(**values)
+            self.session.add(entity)
+            self.session.flush()
+            return entity
+
+        for key, value in values.items():
+            if key in {"id", "create_time"}:
+                continue
+            if hasattr(existing, key):
+                setattr(existing, key, value)
+        existing.status = str(values.get("status") or "running")
+        existing.stop_reason = values.get("stop_reason")
+        self.session.flush()
+        return existing
+
+    def mark_run_failed(self, run_id: str, *, stop_reason: str | None = None) -> None:
+        run = self.get_run(run_id)
+        if run is None:
+            return
+        run.status = "failed"
+        if stop_reason:
+            run.stop_reason = stop_reason[:2000]
+        self.session.flush()
+
+    def create_run(self, values: dict[str, Any]) -> VideoDiscoveryRun:
+        entity = VideoDiscoveryRun(**values)
+        self.session.add(entity)
+        self.session.flush()
+        return entity
+
+    def save_search_page(
+        self,
+        search_values: dict[str, Any],
+        candidate_rows: list[dict[str, Any]],
+    ) -> tuple[VideoDiscoverySearch, int]:
+        """幂等保存一个搜索页,并把页内结果并入本次运行候选集。"""
+        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()
+            }
+
+        new_count = 0
+        for row in candidate_rows:
+            aweme_id = str(row.get("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)]
+            )
+            for key, value in row.items():
+                if key == "aweme_id" or 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):
+                    setattr(entity, key, value)
+
+        search.new_candidate_count = previous_new_count + new_count
+        search.result_ids_json = _merge_json_list(search.result_ids_json, aweme_ids)
+        self.session.flush()
+        self._refresh_run_counts(run_id)
+        return search, new_count
+
+    def save_candidate_evaluations(
+        self,
+        run_id: str,
+        rows: list[dict[str, Any]],
+    ) -> tuple[int, bool]:
+        """批量更新候选评估;不存在的 aweme_id 会补建。"""
+        aweme_ids = [str(row["aweme_id"]) for row in rows]
+        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(stmt).all()
+        }
+
+        audit_relevant_changed = False
+        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
+                )
+
+            for key, value in row.items():
+                if key == "aweme_id" or 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
+            )
+            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
+
+    def finish_run(
+        self,
+        run_id: str,
+        *,
+        status: str,
+        intent_summary: str | None = None,
+        stop_reason: str | None = None,
+    ) -> VideoDiscoveryRun:
+        run = self.get_run(run_id)
+        if run is None:
+            raise ValueError(f"run_id 不存在: {run_id}")
+        run.status = status
+        if intent_summary is not None:
+            run.intent_summary = intent_summary
+        if stop_reason is not None:
+            run.stop_reason = stop_reason
+        self.session.flush()
+        self._refresh_run_counts(run_id)
+        return run
+
+    def list_searches(self, run_id: str) -> list[VideoDiscoverySearch]:
+        stmt = (
+            select(VideoDiscoverySearch)
+            .where(VideoDiscoverySearch.run_id == run_id)
+            .order_by(VideoDiscoverySearch.id)
+        )
+        return list(self.session.scalars(stmt).all())
+
+    def list_candidates(
+        self,
+        run_id: str,
+        *,
+        buckets: tuple[str, ...] | None = None,
+        limit: int = 100,
+    ) -> list[VideoDiscoveryCandidate]:
+        stmt = select(VideoDiscoveryCandidate).where(
+            VideoDiscoveryCandidate.run_id == run_id
+        )
+        if buckets:
+            stmt = stmt.where(VideoDiscoveryCandidate.decision_bucket.in_(buckets))
+        stmt = stmt.order_by(
+            VideoDiscoveryCandidate.value_score.desc(),
+            VideoDiscoveryCandidate.share_count.desc(),
+            VideoDiscoveryCandidate.id,
+        ).limit(limit)
+        return list(self.session.scalars(stmt).all())
+
+    def list_publishable_candidates(
+        self,
+        *,
+        run_id: str | None = None,
+        biz_dt: str | None = None,
+        skip_published: bool = True,
+        limit: int | None = None,
+    ) -> list[VideoDiscoveryCandidate]:
+        """查询待提交 AIGC 的候选视频;仅 primary / backup 分池。"""
+        stmt = (
+            select(VideoDiscoveryCandidate)
+            .join(
+                VideoDiscoveryRun,
+                VideoDiscoveryRun.run_id == VideoDiscoveryCandidate.run_id,
+            )
+            .where(VideoDiscoveryCandidate.decision_bucket.in_(_PUBLISHABLE_BUCKETS))
+            .order_by(
+                VideoDiscoveryCandidate.value_score.desc(),
+                VideoDiscoveryCandidate.share_count.desc(),
+                VideoDiscoveryCandidate.id,
+            )
+        )
+        if run_id:
+            stmt = stmt.where(VideoDiscoveryCandidate.run_id == run_id)
+        if biz_dt:
+            stmt = stmt.where(VideoDiscoveryRun.biz_dt == biz_dt)
+        if skip_published:
+            stmt = stmt.where(VideoDiscoveryCandidate.aigc_crawler_plan_id.is_(None))
+        if limit is not None:
+            stmt = stmt.limit(limit)
+        return list(self.session.scalars(stmt).all())
+
+    def mark_candidates_aigc_plans(
+        self,
+        candidate_ids: list[int],
+        *,
+        crawler_plan_id: str,
+        produce_plan_id: str,
+        publish_plan_id: str,
+        plan_label: str,
+    ) -> int:
+        if not candidate_ids:
+            return 0
+        stmt = select(VideoDiscoveryCandidate).where(
+            VideoDiscoveryCandidate.id.in_(candidate_ids)
+        )
+        updated = 0
+        for entity in self.session.scalars(stmt).all():
+            entity.aigc_crawler_plan_id = crawler_plan_id
+            entity.aigc_produce_plan_id = produce_plan_id
+            entity.aigc_publish_plan_id = publish_plan_id
+            entity.aigc_plan_label = plan_label
+            updated += 1
+        self.session.flush()
+        return updated
+
+    def _refresh_run_counts(self, run_id: str) -> None:
+        run = self.get_run(run_id)
+        if run is None:
+            return
+
+        search_stmt = select(func.count(VideoDiscoverySearch.id)).where(
+            VideoDiscoverySearch.run_id == run_id
+        )
+        run.search_count = int(self.session.scalar(search_stmt) or 0)
+
+        bucket_stmt = (
+            select(
+                VideoDiscoveryCandidate.decision_bucket,
+                func.count(VideoDiscoveryCandidate.id),
+            )
+            .where(VideoDiscoveryCandidate.run_id == run_id)
+            .group_by(VideoDiscoveryCandidate.decision_bucket)
+        )
+        counts = {str(bucket): int(count) for bucket, count in self.session.execute(bucket_stmt)}
+        run.primary_count = counts.get("primary", 0)
+        run.backup_count = counts.get("backup", 0)
+        self.session.flush()

+ 47 - 0
supply_infra/scheduler/_verify_auto_assign.py

@@ -0,0 +1,47 @@
+"""自动分配计划组逻辑自检。"""
+from __future__ import annotations
+
+from supply_infra.scheduler.plan_group_batch import pack_category_units
+
+
+def _demands(n: int) -> list[dict]:
+    return [{"pool_id": i, "demand_name": f"d{i}"} for i in range(n)]
+
+
+def test_pack_same_parent_prefers_single_group() -> None:
+  units = [
+      (101, 10, _demands(12)),
+      (102, 10, _demands(15)),
+      (201, 20, _demands(10)),
+  ]
+  groups = pack_category_units(units, max_demands_per_group=30)
+  assert groups == [[101, 102], [201]]
+
+
+def test_pack_large_category_alone() -> None:
+  units = [
+      (101, 10, _demands(35)),
+      (102, 10, _demands(5)),
+  ]
+  groups = pack_category_units(units, max_demands_per_group=30)
+  assert groups == [[101], [102]]
+
+
+def test_pack_exact_capacity() -> None:
+  units = [
+      (101, 10, _demands(15)),
+      (102, 10, _demands(15)),
+  ]
+  groups = pack_category_units(units, max_demands_per_group=30)
+  assert groups == [[101, 102]]
+
+
+def main() -> None:
+  test_pack_same_parent_prefers_single_group()
+  test_pack_large_category_alone()
+  test_pack_exact_capacity()
+  print("auto_assign_grade_plan: all tests passed")
+
+
+if __name__ == "__main__":
+  main()

+ 123 - 0
supply_infra/scheduler/auto_assign_grade_plan.py

@@ -0,0 +1,123 @@
+"""按需求数量自动划分分级计划组(替代统筹 Agent 的默认定时任务路径)。"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from agents.demand_grade_orchestrator_agent.common.assignment import (
+    resolve_planning_state,
+)
+from agents.demand_grade_orchestrator_agent.common.plan_persist import persist_groups_one_by_one
+from agents.demand_grade_orchestrator_agent.common.plan_record import prepare_grade_groups
+from agents.demand_grade_orchestrator_agent.common.tree_state import load_tree_state
+from supply_infra.scheduler.plan_group_batch import (
+    MAX_DEMANDS_PER_BATCH,
+    pack_category_units,
+    resolve_demands_for_category_ids,
+)
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_GROUPING_STRATEGY = "同分类与同父分类优先,每组约30个需求"
+_DEFAULT_REASON = "代码自动分配"
+_DEFAULT_TRAITS = _DEFAULT_GROUPING_STRATEGY
+
+
+def _parent_id(category_id: int, by_id: dict[int, Any]) -> int | None:
+    row = by_id.get(category_id)
+    if row is None:
+        return None
+    parent = getattr(row, "parent_id", None)
+    if parent in (None, 0):
+        return None
+    return int(parent)
+
+
+def build_auto_assign_groups(
+    biz_dt: str,
+    *,
+    unassigned_category_ids: list[int] | None = None,
+    max_demands_per_group: int = MAX_DEMANDS_PER_BATCH,
+) -> list[dict[str, Any]]:
+    """为未分批分类节点生成自动分配批次(仅 category_ids,供 prepare_grade_groups 使用)。"""
+    planning_state = resolve_planning_state(biz_dt)
+    category_ids = list(unassigned_category_ids or planning_state["unassigned_category_ids"])
+    if not category_ids:
+        return []
+
+    by_id, _children, _weights = load_tree_state(biz_dt)
+    units: list[tuple[int, int | None, list[dict[str, Any]]]] = []
+    for category_id in category_ids:
+        demands = resolve_demands_for_category_ids(biz_dt, [category_id])
+        if not demands:
+            continue
+        units.append((category_id, _parent_id(category_id, by_id), demands))
+
+    packed = pack_category_units(units, max_demands_per_group=max_demands_per_group)
+    return [
+        {
+            "category_ids": group_category_ids,
+            "planning_reason": _DEFAULT_REASON,
+            "shared_traits": _DEFAULT_TRAITS,
+        }
+        for group_category_ids in packed
+    ]
+
+
+def auto_assign_daily_grade_plan(
+    *,
+    biz_dt: str,
+    max_demands_per_group: int = MAX_DEMANDS_PER_BATCH,
+) -> dict[str, Any]:
+    """自动划分并落库当天分级计划组(每组约 30 个需求,同分类/同父分类优先)。"""
+    planning_state = resolve_planning_state(biz_dt)
+    if not planning_state["can_plan_more"]:
+        logger.info(
+            "跳过自动分配: biz_dt=%s reason=%s",
+            biz_dt,
+            planning_state["skip_reason"],
+        )
+        return {
+            "skipped": True,
+            "reason": planning_state["skip_reason"],
+            "planning_state": planning_state,
+        }
+
+    groups = build_auto_assign_groups(
+        biz_dt,
+        unassigned_category_ids=planning_state["unassigned_category_ids"],
+        max_demands_per_group=max_demands_per_group,
+    )
+    if not groups:
+        logger.info(
+            "跳过自动分配:无待分配需求 biz_dt=%s unassigned_categories=%s",
+            biz_dt,
+            len(planning_state["unassigned_category_ids"]),
+        )
+        return {
+            "skipped": True,
+            "reason": "无待分配需求",
+            "planning_state": planning_state,
+        }
+
+    logger.info(
+        "执行自动分配: biz_dt=%s unassigned_categories=%s planned_groups=%s max_demands_per_group=%s",
+        biz_dt,
+        len(planning_state["unassigned_category_ids"]),
+        len(groups),
+        max_demands_per_group,
+    )
+
+    prepared = prepare_grade_groups(
+        biz_dt,
+        _DEFAULT_GROUPING_STRATEGY,
+        groups,
+        assigned_category_ids=set(planning_state["assigned_category_ids"]),
+    )
+    persist_result = persist_groups_one_by_one(biz_dt, prepared)
+    return {
+        "skipped": False,
+        "planning_state": planning_state,
+        "prepared_group_count": len(prepared.get("groups") or []),
+        "persist_result": persist_result,
+    }

+ 221 - 0
supply_infra/scheduler/jobs/discover_videos_from_demands.py

@@ -0,0 +1,221 @@
+"""
+从 S/A 级需求及其拓展点位触发 find_agent 视频发现。
+
+任务层负责查库与组装上下文;Agent 负责搜索、画像与分池落库。
+"""
+from __future__ import annotations
+
+import logging
+import uuid
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from agents.find_agent.demand_run import (
+    FindDemandContext,
+    discover_videos_for_demand,
+    filter_pending_contexts,
+    list_find_demand_contexts,
+    serialize_find_demand_context,
+)
+from supply_infra.config import get_infra_settings
+from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_WORKERS = 1
+
+
+def _resolve_biz_dt(biz_dt: str | None) -> str:
+    if biz_dt:
+        text = str(biz_dt).strip()
+        if len(text) == 8 and text.isdigit():
+            return text
+        raise ValueError(f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}")
+
+    with get_session() as session:
+        latest = DemandGradeRepository(session).get_latest_biz_dt()
+    if latest:
+        return str(latest)
+
+    timezone = ZoneInfo(get_infra_settings().scheduler_timezone)
+    return datetime.now(timezone).strftime("%Y%m%d")
+
+
+def process_single_discover(
+    ctx: FindDemandContext,
+    *,
+    force: bool = False,
+) -> dict[str, Any]:
+    """并发 worker:对单条需求记录执行 find_agent。"""
+    summary = serialize_find_demand_context(ctx)
+    try:
+        execution = discover_videos_for_demand(ctx, force=force)
+        if execution.skipped:
+            return {
+                "success": True,
+                "skipped": True,
+                **summary,
+                "skip_reason": execution.skip_reason,
+            }
+
+        agent_result = execution.agent_result
+        assert agent_result is not None
+        logger.info(
+            "discover videos done: run_id=%s grade_id=%s demand=%s videos=%d points=%d iterations=%d",
+            execution.run_id,
+            ctx.demand_grade_id,
+            ctx.demand_name,
+            ctx.video_count,
+            ctx.point_count,
+            agent_result.iterations,
+        )
+        return {
+            "success": True,
+            "skipped": False,
+            "run_id": execution.run_id,
+            **summary,
+            "iterations": agent_result.iterations,
+            "content_chars": len(agent_result.content or ""),
+        }
+    except Exception as exc:
+        error_text = str(exc)
+        logger.exception(
+            "discover videos failed: grade_id=%s demand=%s videos=%d",
+            ctx.demand_grade_id,
+            ctx.demand_name,
+            ctx.video_count,
+        )
+        return {
+            "success": False,
+            "skipped": False,
+            **summary,
+            "error": error_text,
+        }
+
+
+def discover_videos_from_demands(
+    biz_dt: str | None = None,
+    *,
+    workers: int = _DEFAULT_WORKERS,
+    offset: int = 0,
+    limit: int | None = None,
+    skip_finished: bool = True,
+    force: bool = False,
+) -> dict[str, Any]:
+    """
+    对指定业务日的 S/A 需求拓展点位逐条调用 find_agent。
+
+    每条记录对应一个 demand_grade + 其下全部视频与全部拓展点位。
+    执行前会预写 video_discovery_run,并按 biz_dt + demand_grade_id 跳过已执行记录。
+    """
+    started_at = datetime.now()
+    batch_run_id = uuid.uuid4().hex
+
+    try:
+        resolved_biz_dt, contexts = list_find_demand_contexts(biz_dt)
+    except Exception as exc:
+        logger.exception("discover_videos_from_demands preflight failed")
+        return {
+            "success": False,
+            "error": str(exc),
+            "started_at": started_at.isoformat(),
+            "finished_at": datetime.now().isoformat(),
+        }
+
+    preload_stats = {"total_loaded": len(contexts), "skipped_already_done": 0}
+    if not force:
+        contexts, preload_stats = filter_pending_contexts(
+            contexts,
+            resolved_biz_dt,
+            skip_finished=skip_finished,
+        )
+
+    if offset > 0:
+        contexts = contexts[int(offset) :]
+
+    if limit is not None and limit >= 0:
+        contexts = contexts[: int(limit)]
+
+    logger.info(
+        "discover_videos_from_demands start: biz_dt=%s batch_run_id=%s workers=%s offset=%s pending=%s skipped=%s",
+        resolved_biz_dt,
+        batch_run_id,
+        workers,
+        offset,
+        len(contexts),
+        preload_stats.get("skipped_already_done", 0),
+    )
+
+    result: dict[str, Any] = {
+        "success": True,
+        "batch_run_id": batch_run_id,
+        "biz_dt": resolved_biz_dt,
+        "started_at": started_at.isoformat(),
+        "offset": int(offset),
+        **preload_stats,
+        "total_records": len(contexts),
+        "workers": 0,
+        "processed": 0,
+        "succeeded": 0,
+        "skipped": 0,
+        "failed": 0,
+        "errors": [],
+    }
+
+    if not contexts:
+        finished_at = datetime.now()
+        result["finished_at"] = finished_at.isoformat()
+        result["duration_seconds"] = round((finished_at - started_at).total_seconds(), 2)
+        logger.info("discover_videos_from_demands finished: %s", result)
+        return result
+
+    worker_count = max(1, min(int(workers), len(contexts)))
+    result["workers"] = worker_count
+
+    with ThreadPoolExecutor(max_workers=worker_count) as executor:
+        futures = [
+            executor.submit(process_single_discover, ctx, force=force)
+            for ctx in contexts
+        ]
+        for future in as_completed(futures):
+            try:
+                item_result = future.result()
+            except Exception as exc:
+                logger.exception(
+                    "discover videos worker 出现未捕获错误: biz_dt=%s",
+                    resolved_biz_dt,
+                )
+                result["failed"] += 1
+                result["processed"] += 1
+                result["errors"].append({"error": str(exc)})
+                continue
+
+            result["processed"] += 1
+            if item_result.get("skipped"):
+                result["skipped"] += 1
+                continue
+            if item_result.get("success"):
+                result["succeeded"] += 1
+                continue
+
+            result["failed"] += 1
+            result["errors"].append(
+                {
+                    "demand_grade_id": item_result.get("demand_grade_id"),
+                    "demand_name": item_result.get("demand_name"),
+                    "video_count": item_result.get("video_count"),
+                    "run_id": item_result.get("run_id"),
+                    "error": item_result.get("error"),
+                }
+            )
+
+    finished_at = datetime.now()
+    result["finished_at"] = finished_at.isoformat()
+    result["duration_seconds"] = round((finished_at - started_at).total_seconds(), 2)
+    result["success"] = result["failed"] == 0
+
+    logger.info("discover_videos_from_demands finished: %s", result)
+    return result

+ 7 - 4
supply_infra/scheduler/jobs/grade_demand_pool.py

@@ -1,4 +1,4 @@
-"""统筹落库后执行分级任务。"""
+"""计划组落库后执行分级任务(默认定时任务路径为代码自动分配,非统筹 Agent)。"""
 from __future__ import annotations
 
 import logging
@@ -8,8 +8,8 @@ from typing import Any
 from zoneinfo import ZoneInfo
 
 from agents.demand_grade_agent.run import main as grade_demand_words
-from agents.demand_grade_orchestrator_agent.run import orchestrate_daily_grade_plan
 from supply_infra.config import get_infra_settings
+from supply_infra.scheduler.auto_assign_grade_plan import auto_assign_daily_grade_plan
 from supply_infra.db.repositories.demand_grade_plan_repo import DemandGradePlanRepository
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
@@ -223,9 +223,12 @@ def _grade_demand_pool_impl(
 
     if with_orchestrate:
         try:
-            orchestrate_daily_grade_plan(biz_dt=resolved_biz_dt)
+            auto_assign_daily_grade_plan(
+                biz_dt=resolved_biz_dt,
+                max_demands_per_group=max_demands_per_batch,
+            )
         except Exception:
-            logger.exception("统筹 Agent 执行失败,继续处理数据库中已有任务: biz_dt=%s", resolved_biz_dt)
+            logger.exception("自动分配计划组失败,继续处理数据库中已有任务: biz_dt=%s", resolved_biz_dt)
 
     materialized = _materialize_pending_group_items(resolved_biz_dt)
     logger.info("物化计划组需求明细: biz_dt=%s items=%s", resolved_biz_dt, materialized)

+ 207 - 0
supply_infra/scheduler/jobs/publish_videos_from_discovery.py

@@ -0,0 +1,207 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from zoneinfo import ZoneInfo
+
+from supply_infra.aigc.client import AigcClient
+from supply_infra.aigc.plan_map import (
+    AigcPlanPair,
+    assignment_summary,
+    chunk_list,
+    distribute_evenly,
+    list_unique_plan_pairs,
+)
+from supply_infra.config import get_infra_settings
+from supply_infra.db.models.video_discovery import VideoDiscoveryCandidate
+from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
+from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepository
+from supply_infra.db.session import get_session
+
+logger = logging.getLogger(__name__)
+
+# 仅发布主推荐与人工备选,不包含 rejected / pending_evaluation。
+_PUBLISHABLE_BUCKETS = ("primary", "backup")
+_MAX_VIDEOS_PER_CRAWLER_PLAN = 100
+
+
+@dataclass
+class PublishBatchResult:
+    plan_label: str
+    produce_plan_id: str
+    publish_plan_id: str
+    aweme_ids: list[str]
+    candidate_ids: list[int]
+    crawler_plan_id: str | None = None
+    crawler_plan_name: str | None = None
+    bind_success: bool = False
+    bind_error: str | None = None
+    error: str | None = None
+    dry_run: bool = False
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "plan_label": self.plan_label,
+            "produce_plan_id": self.produce_plan_id,
+            "publish_plan_id": self.publish_plan_id,
+            "video_count": len(self.aweme_ids),
+            "aweme_ids": self.aweme_ids,
+            "candidate_ids": self.candidate_ids,
+            "crawler_plan_id": self.crawler_plan_id,
+            "crawler_plan_name": self.crawler_plan_name,
+            "bind_success": self.bind_success,
+            "bind_error": self.bind_error,
+            "error": self.error,
+            "dry_run": self.dry_run,
+        }
+
+
+def _resolve_biz_dt(biz_dt: str | None) -> str | None:
+    if biz_dt:
+        text = str(biz_dt).strip()
+        if len(text) == 8 and text.isdigit():
+            return text
+        raise ValueError(f"biz_dt 格式无效,应为 YYYYMMDD: {biz_dt!r}")
+
+    with get_session() as session:
+        latest = DemandGradeRepository(session).get_latest_biz_dt()
+    return str(latest) if latest else None
+
+
+def _group_candidates_by_plan(
+    candidates: list[VideoDiscoveryCandidate],
+    plan_pairs: list[AigcPlanPair],
+) -> list[tuple[AigcPlanPair, list[VideoDiscoveryCandidate]]]:
+    buckets = distribute_evenly(candidates, len(plan_pairs))
+    grouped: list[tuple[AigcPlanPair, list[VideoDiscoveryCandidate]]] = []
+    for plan_pair, bucket in zip(plan_pairs, buckets, strict=False):
+        if bucket:
+            grouped.append((plan_pair, bucket))
+    return grouped
+
+
+def publish_videos_from_discovery(
+    *,
+    biz_dt: str | None = None,
+    run_id: str | None = None,
+    skip_published: bool = True,
+    limit: int | None = None,
+    dry_run: bool = False,
+) -> dict[str, Any]:
+    """
+    从 video_discovery_candidate 读取视频,按轮询均匀分配到各 AIGC 计划对。
+
+    仅处理 decision_bucket 为 primary / backup 的候选,不区分品类。
+    """
+    resolved_biz_dt = _resolve_biz_dt(biz_dt)
+    plan_pairs = list_unique_plan_pairs()
+    if not plan_pairs:
+        raise RuntimeError("AIGC 计划映射为空,无法分发")
+
+    with get_session() as session:
+        repo = VideoDiscoveryRepository(session)
+        candidates = repo.list_publishable_candidates(
+            run_id=run_id,
+            biz_dt=resolved_biz_dt,
+            skip_published=skip_published,
+            limit=limit,
+        )
+
+    if not candidates:
+        return {
+            "success": True,
+            "message": "没有待发布的候选视频",
+            "biz_dt": resolved_biz_dt,
+            "run_id": run_id,
+            "plan_count": len(plan_pairs),
+        "candidate_count": 0,
+        "decision_buckets": list(_PUBLISHABLE_BUCKETS),
+        "batches": [],
+    }
+
+    grouped = _group_candidates_by_plan(candidates, plan_pairs)
+    distribution_preview = assignment_summary(
+        [plan for plan, _ in grouped],
+        [bucket for _, bucket in grouped],
+    )
+
+    client = AigcClient(dry_run=dry_run)
+    timezone = ZoneInfo(get_infra_settings().scheduler_timezone)
+    timestamp = datetime.now(timezone).strftime("%Y%m%d%H%M%S")
+    batch_results: list[PublishBatchResult] = []
+
+    for plan_pair, plan_candidates in grouped:
+        candidate_batches = chunk_list(plan_candidates, _MAX_VIDEOS_PER_CRAWLER_PLAN)
+        for batch_index, candidate_batch in enumerate(candidate_batches, start=1):
+            aweme_batch = [str(item.aweme_id) for item in candidate_batch]
+            id_batch = [int(item.id) for item in candidate_batch]
+            plan_name = (
+                f"【SupplyAgent】{plan_pair.label}-均匀分发-"
+                f"{timestamp}-批次{batch_index}"
+            )
+            create_result = client.create_video_crawler_plan(
+                aweme_batch,
+                plan_name=plan_name,
+            )
+            batch = PublishBatchResult(
+                plan_label=plan_pair.label,
+                produce_plan_id=plan_pair.produce_plan_id,
+                publish_plan_id=plan_pair.publish_plan_id,
+                aweme_ids=aweme_batch,
+                candidate_ids=id_batch,
+                dry_run=dry_run,
+            )
+
+            if not create_result.get("success"):
+                batch.error = str(create_result.get("error") or "创建爬取计划失败")
+                batch_results.append(batch)
+                continue
+
+            crawler_plan_id = str(create_result.get("crawler_plan_id") or "")
+            crawler_plan_name = str(create_result.get("crawler_plan_name") or plan_name)
+            batch.crawler_plan_id = crawler_plan_id
+            batch.crawler_plan_name = crawler_plan_name
+
+            bind_result = client.bind_crawler_to_produce_plan(
+                crawler_plan_id,
+                plan_pair.produce_plan_id,
+                crawler_plan_name=crawler_plan_name,
+            )
+            batch.bind_success = bool(bind_result.get("success"))
+            if not batch.bind_success:
+                batch.bind_error = str(bind_result.get("error") or "绑定生成计划失败")
+
+            if not dry_run and crawler_plan_id:
+                with get_session() as session:
+                    updated = VideoDiscoveryRepository(session).mark_candidates_aigc_plans(
+                        id_batch,
+                        crawler_plan_id=crawler_plan_id,
+                        produce_plan_id=plan_pair.produce_plan_id,
+                        publish_plan_id=plan_pair.publish_plan_id,
+                        plan_label=plan_pair.label,
+                    )
+                logger.info(
+                    "published batch: plan=%s crawler=%s videos=%d db_updated=%d",
+                    plan_pair.label,
+                    crawler_plan_id,
+                    len(aweme_batch),
+                    updated,
+                )
+
+            batch_results.append(batch)
+
+    failed_batches = [item for item in batch_results if item.error or not item.bind_success]
+    return {
+        "success": not failed_batches,
+        "biz_dt": resolved_biz_dt,
+        "run_id": run_id,
+        "plan_count": len(plan_pairs),
+        "candidate_count": len(candidates),
+        "decision_buckets": list(_PUBLISHABLE_BUCKETS),
+        "distribution": distribution_preview,
+        "batch_count": len(batch_results),
+        "failed_batch_count": len(failed_batches),
+        "batches": [item.to_dict() for item in batch_results],
+    }

+ 52 - 0
supply_infra/scheduler/plan_group_batch.py

@@ -11,6 +11,58 @@ from supply_infra.db.session import get_session
 MAX_DEMANDS_PER_BATCH = 30
 
 
+def _sort_category_units(
+    units: list[tuple[int, int | None, list[Any]]],
+) -> list[tuple[int, int | None, list[Any]]]:
+    """同父分类相邻,同分类作为不可分割单元。"""
+    return sorted(
+        units,
+        key=lambda item: (
+            item[1] is None,
+            item[1] if item[1] is not None else -1,
+            item[0],
+        ),
+    )
+
+
+def pack_category_units(
+    units: list[tuple[int, int | None, list[Any]]],
+    *,
+    max_demands_per_group: int = MAX_DEMANDS_PER_BATCH,
+) -> list[list[int]]:
+    """将分类需求单元打包为计划组;每组尽量不超过 max_demands_per_group 条需求。"""
+    cap = max(1, int(max_demands_per_group))
+    groups: list[list[int]] = []
+    current: list[int] = []
+    current_size = 0
+
+    for category_id, _parent_id, demands in _sort_category_units(units):
+        size = len(demands)
+        if size <= 0:
+            continue
+
+        if size > cap:
+            if current:
+                groups.append(current)
+                current = []
+                current_size = 0
+            groups.append([category_id])
+            continue
+
+        if current and current_size + size > cap:
+            groups.append(current)
+            current = []
+            current_size = 0
+
+        current.append(category_id)
+        current_size += size
+
+    if current:
+        groups.append(current)
+
+    return groups
+
+
 def split_even_batches(items: list[Any], *, max_per_batch: int = MAX_DEMANDS_PER_BATCH) -> list[list[Any]]:
     """按总数均分子批次:总数不超过上限则一批;否则递增组数直到每组不超过上限,余数从前组分配。"""
     n = len(items)

+ 66 - 38
web/src/components/IcicleHeatTree.vue

@@ -77,8 +77,8 @@ const startDepth = ref(0)
 const focus = ref<PreparedNode | null>(null)
 const selectedNode = ref<PreparedNode | null>(null)
 
-const viewScale = ref(1)
-const viewX = ref(0)
+/** Vertical zoom only — column width stays fixed at COLUMN_WIDTH. */
+const viewScaleY = ref(1)
 const viewY = ref(0)
 const isDragging = ref(false)
 
@@ -257,6 +257,30 @@ const demandCards = computed<DemandGradeListCard[]>(() => {
   return result
 })
 
+function collectSubtreeCategoryIds(node: PreparedNode): Set<number> {
+  const ids = new Set<number>()
+  const walk = (n: PreparedNode) => {
+    ids.add(n.id)
+    n.children.forEach(walk)
+  }
+  walk(node)
+  return ids
+}
+
+/** Right panel: all demands globally, or subtree of focused category only. */
+const visibleDemandCards = computed<DemandGradeListCard[]>(() => {
+  const node = focus.value
+  if (!node) return demandCards.value
+
+  const allowed = collectSubtreeCategoryIds(node)
+  return demandCards.value
+    .filter((card) => card.categoryOptions.some((opt) => allowed.has(opt.categoryId)))
+    .map((card) => ({
+      ...card,
+      categoryOptions: card.categoryOptions.filter((opt) => allowed.has(opt.categoryId)),
+    }))
+})
+
 function clearDemandListSelection() {
   listSelectedDemandId.value = null
   listSelectedCategoryId.value = null
@@ -387,6 +411,12 @@ watch(activeTab, () => {
   scheduleDraw()
 })
 
+watch(focus, () => {
+  if (listSelectedDemandId.value == null) return
+  const stillVisible = visibleDemandCards.value.some((c) => c.id === listSelectedDemandId.value)
+  if (!stillVisible) clearDemandListSelection()
+})
+
 function invalidatePaintCache() {
   layoutCacheKey = ''
   fillCache = new Map()
@@ -419,8 +449,7 @@ function onTabClick(key: HeatTabKey) {
 }
 
 function resetViewTransform() {
-  viewScale.value = 1
-  viewX.value = 0
+  viewScaleY.value = 1
   viewY.value = 0
 }
 
@@ -555,10 +584,11 @@ function computeExtent(viewportHeight: number): { width: number; height: number
 function applyWrapSize(width: number, height: number) {
   const wrap = wrapRef.value
   if (!wrap) return
-  const key = `${startDepth.value}:${focus.value?.id ?? 'none'}:${Math.round(width)}x${Math.round(height)}`
+  const scaledHeight = Math.round(height * viewScaleY.value)
+  const key = `${startDepth.value}:${focus.value?.id ?? 'none'}:${Math.round(width)}x${scaledHeight}:${viewScaleY.value}`
   if (
     key === lastExtentKey &&
-    wrap.style.height === `${height}px` &&
+    wrap.style.height === `${scaledHeight}px` &&
     wrap.style.width === `${width}px`
   ) {
     return
@@ -566,7 +596,7 @@ function applyWrapSize(width: number, height: number) {
   lastExtentKey = key
   applyingExtent = true
   wrap.style.width = `${width}px`
-  wrap.style.height = `${height}px`
+  wrap.style.height = `${scaledHeight}px`
   queueMicrotask(() => {
     applyingExtent = false
   })
@@ -584,14 +614,13 @@ function fitView() {
   scheduleDraw()
 }
 
-function zoomAt(factor: number, screenX: number, screenY: number) {
-  const oldScale = viewScale.value
-  const nextScale = Math.max(0.5, Math.min(18, oldScale * factor))
-  const worldX = (screenX - viewX.value) / oldScale
-  const worldY = (screenY - viewY.value) / oldScale
-  viewScale.value = nextScale
-  viewX.value = screenX - worldX * nextScale
-  viewY.value = screenY - worldY * nextScale
+function zoomAt(factor: number, _screenX: number, screenY: number) {
+  const oldScaleY = viewScaleY.value
+  const nextScaleY = Math.max(0.5, Math.min(18, oldScaleY * factor))
+  const worldY = (screenY - viewY.value) / oldScaleY
+  viewScaleY.value = nextScaleY
+  viewY.value = screenY - worldY * nextScaleY
+  lastExtentKey = ''
   scheduleDraw()
 }
 
@@ -623,8 +652,7 @@ function draw() {
 
   if (needResetTransform) {
     needResetTransform = false
-    viewScale.value = 1
-    viewX.value = 0
+    viewScaleY.value = 1
     viewY.value = 0
   }
 
@@ -648,10 +676,10 @@ function draw() {
   const depth = baseDepth.value
   ensureLayout(extent.height, depth)
 
-  const scale = viewScale.value
+  const scaleY = viewScaleY.value
   const scrollLeft = viewport.scrollLeft
   const scrollTop = viewport.scrollTop
-  const ox = viewX.value - scrollLeft
+  const ox = -scrollLeft
   const oy = viewY.value - scrollTop
 
   const pad = 2
@@ -665,10 +693,10 @@ function draw() {
   drawRects = []
 
   for (const item of baseRects) {
-    const x = item.x * scale + ox
-    const y = item.y * scale + oy
-    const width = item.width * scale
-    const height = item.height * scale
+    const x = item.x + ox
+    const y = item.y * scaleY + oy
+    const width = item.width
+    const height = item.height * scaleY
 
     if (x + width < vx0 || y + height < vy0 || x > vx1 || y > vy1) continue
     if (height < 0.5 || width < 0.5) continue
@@ -723,9 +751,9 @@ function nodeAt(clientX: number, clientY: number): PreparedNode | null {
   const viewport = viewportRef.value
   if (!canvas || !viewport || !baseRects.length) return null
   const rect = canvas.getBoundingClientRect()
-  const scale = viewScale.value || 1
-  const contentX = (clientX - rect.left - viewX.value + viewport.scrollLeft) / scale
-  const contentY = (clientY - rect.top - viewY.value + viewport.scrollTop) / scale
+  const scaleY = viewScaleY.value || 1
+  const contentX = clientX - rect.left + viewport.scrollLeft
+  const contentY = (clientY - rect.top - viewY.value + viewport.scrollTop) / scaleY
 
   let match: IcicleRect | null = null
   for (let i = baseRects.length - 1; i >= 0; i -= 1) {
@@ -748,9 +776,8 @@ function scrollSelectedIntoView() {
   const viewport = viewportRef.value
   if (!item || !viewport) return
 
-  viewport.scrollTop = Math.max(0, item.y)
+  viewport.scrollTop = Math.max(0, item.y * viewScaleY.value)
   viewport.scrollLeft = Math.max(0, item.x)
-  viewX.value = 0
   viewY.value = 0
   scheduleDraw()
 }
@@ -790,14 +817,14 @@ function onPointerMove(event: PointerEvent) {
     const dy = event.clientY - drag.y
     if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true
 
-    // At 1x scale, prefer native scroll (cheaper than redrawing every move).
-    if (viewScale.value === 1 && viewportRef.value) {
+    if (viewportRef.value) {
       viewportRef.value.scrollLeft -= dx
-      viewportRef.value.scrollTop -= dy
-    } else {
-      viewX.value += dx
-      viewY.value += dy
-      scheduleDraw()
+      if (viewScaleY.value === 1) {
+        viewportRef.value.scrollTop -= dy
+      } else {
+        viewY.value += dy
+        scheduleDraw()
+      }
     }
     drag.x = event.clientX
     drag.y = event.clientY
@@ -1017,7 +1044,7 @@ onUnmounted(() => {
           </div>
           <div class="map-guide">
             <strong>阅读方式</strong>
-            <span>滚轮浏览;拖拽平移;Ctrl/⌘+滚轮缩放;点击聚焦分支。</span>
+            <span>滚轮浏览;拖拽平移;Ctrl/⌘+滚轮纵向缩放;点击聚焦分支。</span>
           </div>
           <div class="zoom-controls">
             <button type="button" title="缩小" @click="zoomOut">−</button>
@@ -1083,13 +1110,14 @@ onUnmounted(() => {
         />
         <aside class="demand-list-aside">
           <DemandGradeListPanel
-            v-if="demandCards.length"
-            :demand-cards="demandCards"
+            v-if="visibleDemandCards.length"
+            :demand-cards="visibleDemandCards"
             :selected-demand-id="listSelectedDemandId"
             :selected-category-id="listSelectedCategoryId"
             @select-demand="onSelectDemandFromList"
             @select-demand-category="onSelectDemandCategoryFromList"
           />
+          <div v-else-if="demandCards.length && focus" class="demand-empty">该分类下暂无需求</div>
           <div v-else class="demand-empty">暂无需求数据</div>
         </aside>
       </div>

+ 28 - 9
zhangbo.md

@@ -289,22 +289,39 @@ source_dim
 
 ## 5.3 `find_agent`
 
-目标:搜索和解析抖音视频。
+目标:根据需求词、参考视频标题和相关点,寻找老年受众更可能观看和分享的抖音视频。
 
-当前真正注册的工具只有
+当前注册的业务工具包括
 
 - `douyin_search`:调用外部抖音关键词搜索服务;
+- `douyin_search_tikhub`:调用 TikHub 搜索并保留 search_id/backtrace 分页状态;
+- `douyin_user_videos`:按作者 sec_uid、排序和游标扩展历史作品;
 - `douyin_detail`:按 content_id 获取视频详情和可播放地址;
-- `qwen_video_analyze`:调用千问视频模型解析视频。
+- `get_content_fans_portrait`:获取视频点赞用户画像;
+- `get_account_fans_portrait`:获取作者粉丝画像;
+- `batch_fetch_portraits`:批量获取视频画像,并可同时获取作者画像;
+- `normalize_age_portraits`:标准化 `50-` 等年龄桶及双侧证据;
+- `audit_video_discovery_process`:结束前审计多词、翻页、扩展、证据和双池分流;
+- `qwen_video_analyze`:调用千问视频模型解析视频;
+- `create_video_discovery_run`:创建可追踪的找片运行;
+- `record_video_search_page`:保存 Agent 自主搜索词、扩展来源、游标与本页结果;
+- `batch_save_video_candidate_evaluations`:保存证据、评分并分为正式推荐/人工备选/淘汰;
+- `query_video_discovery_state`:查询搜索树和候选分池;
+- `review_video_discovery_candidate`:记录用户对推荐或备选的人工选择结果。
 
 搜索和详情接口有约 10 秒的请求间隔限制。
 
-当前系统提示还要求调用:
+Agent 以需求相关性、老年受众倾向和分享价值的联合目标做判断。系统提示明确区分
+“分享量高”和“受众偏老”两类证据,使用视频点赞画像作为内容侧证据、作者粉丝画像
+作为账号先验,并对画像缺失或冲突降低置信度。搜索词由 Agent 根据需求、参考标题、
+相关点和途中发现的有效标签自主决定;支持多关键词、游标翻页和标签扩展。与需求无关
+但老年倾向、分享价值双高的视频保存在人工备选池。
 
-- `save_video_content`;
-- `query_video_content`。
+搜索过程持久化到:
 
-但仓库中没有这两个工具,也没有被注册。因此当前 `find_agent` 能搜索、取详情和解析视频,但不能按提示完成内容入库或历史查询。
+- `video_discovery_run`:任务输入、意图、状态与计数;
+- `video_discovery_search`:逐关键词、逐游标页的搜索轨迹;
+- `video_discovery_candidate`:视频详情、双侧画像、标签、评分、分池与人工审核状态。
 
 ---
 
@@ -719,9 +736,11 @@ Docker 使用两阶段构建:
 - 当前主热力图没有 ROV/VOV 标签;
 - 尚未形成“后验反向调整最终需求排序”的完整实现。
 
-### 14.4 `find_agent` 提示和工具不一致
+### 14.4 `find_agent` 的外部证据仍可增强
 
-提示要求保存和查询视频内容,但相应工具不存在。
+当前已持久化搜索轨迹、正式推荐和人工备选,但画像接口提供的是点赞用户而非真实转发
+用户。后续若能补充转发用户画像、分年龄观看留存、相似视频和批量搜索接口,可进一步
+提高老年分享判断的直接性与多词多页探索效率。
 
 ### 14.5 关系语义较弱