Procházet zdrojové kódy

Merge branch 'master' into feature/zhangbo

zhang před 2 dny
rodič
revize
693fe6b552
100 změnil soubory, kde provedl 7331 přidání a 1401 odebrání
  1. binární
      .DS_Store
  2. 3 0
      .dockerignore
  3. 41 4
      .env.example
  4. 14 2
      .gitignore
  5. 20 14
      ARCHITECTURE.md
  6. 33 5
      Dockerfile
  7. 841 0
      PRD.md
  8. 3 0
      README.md
  9. 1 1
      agents/README.md
  10. 6 2
      agents/demand_belong_category_agent/agent.py
  11. 2 8
      agents/demand_belong_category_agent/prompt/system_prompt.md
  12. 1 1
      agents/demand_grade_agent/__init__.py
  13. 5 1
      agents/demand_grade_agent/agent.py
  14. 14 7
      agents/demand_grade_agent/prompt/system_prompt.md
  15. 1 1
      agents/demand_grade_agent/tools/batch_save_demand_grades.py
  16. 15 3
      agents/demand_grade_agent/tools/demand_priority.py
  17. 1 1
      agents/demand_grade_agent/tools/query_category_local_heat.py
  18. 2 2
      agents/demand_grade_agent/tools/query_demand_category_and_weight.py
  19. 4 4
      agents/demand_grade_agent/tools/query_demand_popularity_by_word.py
  20. 10 3
      agents/demand_grade_agent/tools/query_score_distribution.py
  21. 5 4
      agents/demand_grade_agent/tools/search_related_pool_demands.py
  22. 10 4
      agents/demand_grade_agent/tools/shared.py
  23. 3 2
      agents/demand_grade_agent/tools/tree_local.py
  24. 0 134
      agents/demand_grade_orchestrator_agent/_verify_logic.py
  25. 10 2
      agents/demand_grade_orchestrator_agent/common/tree_state.py
  26. 3 1
      agents/demand_grade_orchestrator_agent/prompt/system_prompt.md
  27. 10 3
      agents/demand_grade_orchestrator_agent/tools/query_heat_node_group.py
  28. 5 1
      agents/demand_video_expand_agent/agent.py
  29. 331 0
      agents/find_agent/PRD.md
  30. 135 0
      agents/find_agent/README.md
  31. 119 0
      agents/find_agent/VALIDATION.md
  32. 49 3
      agents/find_agent/__init__.py
  33. 7 13
      agents/find_agent/agent.py
  34. 131 0
      agents/find_agent/async_runner.py
  35. 423 0
      agents/find_agent/demand_run.py
  36. 270 0
      agents/find_agent/prompt/system_prompt.md
  37. 2 2
      agents/find_agent/run.py
  38. 30 0
      agents/find_agent/run_outcome.py
  39. 15 0
      agents/find_agent/runtime.py
  40. 39 3
      agents/find_agent/tools/__init__.py
  41. 385 0
      agents/find_agent/tools/decision_support.py
  42. 16 2
      agents/find_agent/tools/douyin_detail.py
  43. 405 0
      agents/find_agent/tools/douyin_search_tikhub.py
  44. 264 0
      agents/find_agent/tools/douyin_user_videos.py
  45. 663 0
      agents/find_agent/tools/hotspot_profile.py
  46. 479 16
      agents/find_agent/tools/qwen_video_analyze.py
  47. 572 0
      agents/find_agent/tools/video_discovery_store.py
  48. 39 0
      alembic.ini
  49. 58 0
      alembic/env.py
  50. 24 0
      alembic/script.py.mako
  51. 219 0
      alembic/versions/20260727_01_pipeline_control_plane.py
  52. 49 0
      alembic/versions/20260727_02_nullable_manual_deadline.py
  53. 75 0
      alembic/versions/20260727_03_agent_document_injection.py
  54. 75 0
      alembic/versions/20260728_01_drop_unused_scheduler_artifacts.py
  55. 198 9
      api/app.py
  56. 1 0
      api/routers/__init__.py
  57. 89 0
      api/routers/pipeline.py
  58. 1 0
      api/schemas/__init__.py
  59. 14 0
      api/schemas/pipeline.py
  60. 84 0
      api/services/agent_catalog.py
  61. 2 2
      api/services/category_tree.py
  62. 24 12
      api/services/oss_logs.py
  63. 65 0
      api/services/pipeline.py
  64. 68 0
      api/services/scheduler.py
  65. 193 0
      api/services/video_discovery.py
  66. 38 0
      deploy/README.md
  67. 67 0
      deploy/supervisord.pipeline.conf
  68. 0 36
      jobs/backfill_demand_video_expansion_point_desc.py
  69. 0 35
      jobs/backfill_multi_demand_video_list.py
  70. 0 34
      jobs/backfill_multi_demand_video_points.py
  71. 0 117
      jobs/backfill_multi_demand_video_points_table.py
  72. 0 33
      jobs/backfill_multi_demand_video_titles.py
  73. 0 48
      jobs/expand_demand_from_video_points.py
  74. 0 43
      jobs/grade_demand_pool.py
  75. 0 45
      jobs/retry_failed_grade_plan_items.py
  76. 0 37
      jobs/run_category_tree_rank_scores.py
  77. 0 38
      jobs/run_category_tree_weight.py
  78. 0 35
      jobs/run_popularity_stats.py
  79. 0 14
      jobs/run_scheduler.py
  80. 0 22
      jobs/run_supply_pipeline.py
  81. 0 29
      jobs/sync_demand_belong_pool_rel.py
  82. 0 57
      jobs/sync_multi_demand_videos.py
  83. 5 1
      pyproject.toml
  84. 4 0
      requirements.txt
  85. 0 162
      scripts/backfill_demand_video_expansion_point_desc.py
  86. 8 0
      scripts/container-entrypoint.sh
  87. 7 0
      scripts/docker-deploy.sh
  88. 0 100
      scripts/retry_failed_grade_plan_items.py
  89. 0 85
      scripts/run_grade_plan_groups.py
  90. 33 0
      scripts/verify_video_truncate_runtime.py
  91. 16 0
      sql/video_discovery_add_aigc_plan.sql
  92. 22 0
      sql/video_discovery_add_audit_evidence.sql
  93. 13 0
      sql/video_discovery_add_biz_dt.sql
  94. 10 0
      sql/video_discovery_drop_unused_columns.sql
  95. 120 0
      sql/video_discovery_tables.sql
  96. 9 3
      supply_agent/agent/core.py
  97. 214 152
      supply_agent/agent/loop.py
  98. 4 0
      supply_agent/config.py
  99. 78 2
      supply_agent/llm/client.py
  100. 7 1
      supply_agent/logging/__init__.py

binární
.DS_Store


+ 3 - 0
.dockerignore

@@ -1,5 +1,6 @@
 .git
 .gitignore
+.DS_Store
 .venv
 __pycache__
 *.py[cod]
@@ -12,6 +13,7 @@ __pycache__
 .pytest_cache
 .ruff_cache
 *.log
+*.result.json
 logs/
 tests/
 examples/
@@ -24,3 +26,4 @@ README_myself.md
 agents/README.md
 *.md
 !README.md
+!agents/**/prompt/system_prompt.md

+ 41 - 4
.env.example

@@ -4,11 +4,16 @@ 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
 AGENT_TEMPERATURE=0.7
 
+# find_agent only (read by agents/find_agent/runtime.py)
+# 单个 find_agent 最长运行 10 分钟,超时后标记失败并继续下一条
+FIND_AGENT_TIMEOUT_SECONDS=600
+
 # Skills directory (relative to project root or absolute path)
 SKILLS_DIR=skills
 
@@ -25,7 +30,17 @@ MYSQL_PORT=3306
 MYSQL_USER=root
 MYSQL_PASSWORD=
 MYSQL_DATABASE=supply_agent
-MYSQL_POOL_SIZE=5
+MYSQL_CONNECTION_BUDGET=40
+MYSQL_POOL_SIZE=1
+MYSQL_POOL_SIZE_API=6
+MYSQL_POOL_SIZE_CONTROL=1
+MYSQL_MAX_OVERFLOW=0
+MYSQL_POOL_TIMEOUT_SECONDS=10
+MYSQL_POOL_RECYCLE_SECONDS=1800
+MYSQL_CONNECT_TIMEOUT_SECONDS=10
+MYSQL_READ_TIMEOUT_SECONDS=30
+MYSQL_WRITE_TIMEOUT_SECONDS=30
+MYSQL_OPERATIONAL_RESERVE=4
 MYSQL_ECHO=false
 
 # ODPS (MaxCompute)
@@ -34,10 +49,28 @@ ODPS_ACCESS_KEY=
 ODPS_PROJECT=
 ODPS_ENDPOINT=https://service.cn.maxcompute.aliyun.com/api
 
-# Scheduler
-SCHEDULER_ENABLED=true
+# Scheduler(独立进程;供给流水线每天 15:00 Asia/Shanghai 触发)
+SCHEDULER_ENABLED=false
 SCHEDULER_TIMEZONE=Asia/Shanghai
-# Aliyun OSS (agent 运行日志可视化上传)
+SCHEDULER_CRON_HOUR=15
+SCHEDULER_CRON_MINUTE=0
+
+# Pipeline control plane
+PROCESS_ROLE=api
+DATABASE_AUTO_CREATE=false
+PIPELINE_WORKER_PROCESSES=4
+PIPELINE_MAX_ACTIVE_STEPS=4
+PIPELINE_WORKER_POLL_SECONDS=2
+PIPELINE_LEASE_SECONDS=120
+PIPELINE_HEARTBEAT_SECONDS=30
+PIPELINE_RECONCILE_SECONDS=60
+PIPELINE_MISSED_RUN_GRACE_SECONDS=600
+PIPELINE_SHUTDOWN_GRACE_SECONDS=300
+PIPELINE_WARN_AFTER_HOURS=12
+PIPELINE_CRITICAL_BEFORE_NEXT_MINUTES=120
+PIPELINE_FINAL_WARN_BEFORE_NEXT_MINUTES=30
+PIPELINE_LOG_DIR=logs/pipeline
+# Aliyun OSS (agent 运行日志可视化上传;qwen 视频截断后片段也上传到此 bucket)
 ALIYUN_OSS_ACCESS_KEY_ID=
 ALIYUN_OSS_ACCESS_KEY_SECRET=
 ALIYUN_OSS_REGION=cn-hangzhou
@@ -46,4 +79,8 @@ ALIYUN_OSS_ROOT_PREFIX=supply_agent
 # 手动上传日志专用 OSS 目录(与 Agent 自动上传 / MySQL oss_logs 路径分离)
 ALIYUN_OSS_MANUAL_LOG_PREFIX=supply_agent/manual_logs
 ALIYUN_OSS_PUBLIC_BASE_URL=http://rescdn.yishihui.com
+ALIYUN_OSS_CONNECT_TIMEOUT_SECONDS=30
 LOG_OSS_UPLOAD_ENABLED=true
+
+# AIGC platform (视频爬取/发布计划;需配置 token,默认真实调用接口)
+AIGC_API_TOKEN=

+ 14 - 2
.gitignore

@@ -1,7 +1,13 @@
 __pycache__/
+.DS_Store
 *.py[cod]
 *$py.class
 *.egg-info/
+.coverage
+coverage.xml
+htmlcov/
+test-results/
+*.result.json
 dist/
 build/
 .venv/
@@ -11,8 +17,14 @@ build/
 .ruff_cache/
 *.log
 logs/
-tests/
+tests/*
+!tests/__init__.py
+!tests/supply_infra/
+tests/supply_infra/*
+!tests/supply_infra/__init__.py
+!tests/supply_infra/pipeline/
+!tests/supply_infra/scheduler/
 node_modules/
 web/dist/
 examples/
-skills/
+skills/

+ 20 - 14
ARCHITECTURE.md

@@ -15,6 +15,8 @@ SupplyAgent/
 ├── supply_infra/                  # 共享基础设施(所有 Agent / 定时任务共用)
 │   ├── config.py                  #   MySQL / ODPS / Scheduler 配置
+│   ├── agent_logging/             #   注册 Agent 运行日志 OSS 发布 hook
+│   ├── scoring/                   #   后验 diff / 排名归一化(分级与热度树共用)
 │   ├── db/
 │   │   ├── base.py                #   SQLAlchemy Base + TimestampMixin
 │   │   ├── session.py             #   Engine + get_session()
@@ -26,9 +28,11 @@ SupplyAgent/
 │   ├── odps/
 │   │   └── client.py              #   ODPS 查询封装
 │   ├── scheduler/
-│   │   ├── app.py                 #   APScheduler 调度器
-│   │   └── jobs/                  #   定时任务定义
-│   │       └── sync_odps_to_mysql.py
+│   │   ├── app.py                 #   APScheduler 调度器(随 API 启动)
+│   │   └── jobs/                  #   流水线任务(含 python -m CLI)
+│   │       ├── run_supply_pipeline.py
+│   │       ├── demand_pool/
+│   │       └── ...
 │   └── tools/
 │       └── db_tools.py            #   共享 MySQL 读写工具(供 Agent 调用)
@@ -45,9 +49,6 @@ SupplyAgent/
 │       └── tools/
 ├── skills/                        # 全局共享 Skills(SKILL.md)
-├── jobs/                          # CLI 入口
-│   ├── run_scheduler.py           #   启动定时任务
-│   └── init_db.py                 #   初始化数据库表
 ├── examples/                      # 框架使用示例
 ├── tests/
 ├── logs/                          # 运行日志(自动生成)
@@ -59,8 +60,8 @@ SupplyAgent/
 
 | 层 | 包 | 职责 | 谁用 |
 |----|-----|------|------|
-| 框架层 | `supply_agent` | LLM 调用、工具/技能机制、ReAct 循环 | 所有 Agent |
-| 基础设施层 | `supply_infra` | DB ORM、ODPS、定时任务、共享 DB 工具 | 所有 Agent + 定时任务 |
+| 框架层 | `supply_agent` | LLM 调用、工具/技能机制、ReAct 循环;运行结束后通过 hook 发布产物(不含 OSS/DB) | 所有 Agent |
+| 基础设施层 | `supply_infra` | DB ORM、ODPS、定时任务、共享 DB 工具;`agent_logging` 注册 OSS 日志发布 hook | 所有 Agent + 定时任务 |
 | 业务层 | `agents/*` | 具体 Agent 逻辑、专属工具、工厂函数 | 业务开发 |
 
 ## Data flow
@@ -69,7 +70,7 @@ SupplyAgent/
                     ┌─────────────┐
                     │   ODPS      │
                     └──────┬──────┘
-                           │ 定时任务 (每天 02:00)
+                           │ 定时任务 (每天 15:00)
 ┌──────────┐    ┌─────────────────────┐    ┌──────────┐
 │  Agent   │───▶│  Repository (ORM)   │◀───│  Agent   │
@@ -121,11 +122,13 @@ supply_infra/db/
 ## How to add a new scheduled job
 
 ```bash
-supply_infra/scheduler/jobs/new_job.py   # 1. 定义任务函数
+# 1. 在 supply_infra/scheduler/jobs/ 中定义任务函数与 python -m CLI
+# 2. 若需纳入日批,在 run_supply_pipeline 的 steps 中追加
+# 3. 默认定时仍只注册 run_supply_pipeline;单步可通过 API manual_jobs 触发
 ```
 
 ```python
-# supply_infra/scheduler/app.py 中注册
+# 仅当确需独立 Cron 时,才在 supply_infra/scheduler/app.py 中额外注册
 scheduler.add_job(new_job, trigger=CronTrigger(hour=3), id="new_job")
 ```
 
@@ -133,10 +136,13 @@ scheduler.add_job(new_job, trigger=CronTrigger(hour=3), id="new_job")
 
 ```bash
 # 初始化数据库表
-python jobs/init_db.py
+python -m supply_infra.db
 
-# 启动定时任务
-python jobs/run_scheduler.py
+# 启动 API(同时启动定时任务,需 SCHEDULER_ENABLED=true)
+python -m api
+
+# 手动跑全链路
+python -m supply_infra.scheduler.jobs.run_supply_pipeline
 
 # 运行 find_agent
 python agents/find_agent/run.py

+ 33 - 5
Dockerfile

@@ -23,13 +23,30 @@ WORKDIR /app
 ENV PYTHONUNBUFFERED=1 \
     PYTHONDONTWRITEBYTECODE=1 \
     PIP_NO_CACHE_DIR=1 \
-    PIP_DISABLE_PIP_VERSION_CHECK=1
+    PIP_DISABLE_PIP_VERSION_CHECK=1 \
+    TZ=Asia/Shanghai \
+    SCHEDULER_TIMEZONE=Asia/Shanghai \
+    SCHEDULER_CRON_HOUR=15 \
+    SCHEDULER_CRON_MINUTE=0 \
+    PIPELINE_WORKER_PROCESSES=4 \
+    PIPELINE_MAX_ACTIVE_STEPS=4 \
+    FIND_AGENT_TIMEOUT_SECONDS=600 \
+    MYSQL_CONNECTION_BUDGET=40
 
 RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
     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 supervisor \
+    && 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 ./
@@ -37,12 +54,23 @@ COPY supply_agent/ supply_agent/
 COPY supply_infra/ supply_infra/
 COPY agents/ agents/
 COPY api/ api/
-COPY jobs/ jobs/
+COPY alembic.ini ./
+COPY alembic/ alembic/
+COPY deploy/ deploy/
+COPY scripts/verify_video_truncate_runtime.py scripts/
+COPY scripts/container-entrypoint.sh scripts/
 
-RUN pip install -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com ".[odps]"
+RUN test -s agents/demand_belong_category_agent/prompt/system_prompt.md \
+    && test -s agents/demand_grade_agent/prompt/system_prompt.md \
+    && test -s agents/demand_video_expand_agent/prompt/system_prompt.md \
+    && pip install -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com ".[odps]" \
+    && python scripts/verify_video_truncate_runtime.py \
+    && chmod +x scripts/container-entrypoint.sh
 
 COPY --from=web-builder /app/web/dist ./web/dist
 
 EXPOSE 8080
 
-CMD ["python", "-m", "api"]
+STOPSIGNAL SIGTERM
+
+ENTRYPOINT ["/app/scripts/container-entrypoint.sh"]

+ 841 - 0
PRD.md

@@ -0,0 +1,841 @@
+# SupplyAgent 端到端产品需求文档
+
+> 文档性质:现状还原 + 目标产品要求 + 风险整改清单
+> 版本:v1.0
+> 更新日期:2026-07-24
+> 代码基线:`dev-find` / `5ea5e65`
+> 核心入口:`supply_infra/scheduler/jobs/run_supply_pipeline.py`
+
+---
+
+## 1. 文档目的
+
+本文用于统一说明 SupplyAgent 当前项目的完整业务流程,重点回答:
+
+1. 每日定时任务如何贯穿 ODPS、MySQL、业务 Agent、找片和 AIGC;
+2. 每一阶段的输入、处理规则、输出、状态与失败方式;
+3. API、前端和人工补偿入口如何消费流水线结果;
+4. 当前实现中已经发现的产品问题、数据风险、工程风险和安全风险;
+5. 下一阶段应达到的产品要求、优先级和验收标准。
+
+本文以当前代码实际行为为准。仓库中的 `README.md`、`ARCHITECTURE.md`、`prd/` 和
+`zhangbo.md` 部分内容描述的是旧流程或目标蓝图,不能代替本文对现状的说明。
+
+本次分析未触发 ODPS、外部搜索、AIGC 发布等线上副作用;运行态数据量、接口成功率和真实耗时仍需结合生产监控补充。
+
+---
+
+## 2. 产品概述
+
+### 2.1 产品定位
+
+SupplyAgent 是一套面向内容供给的每日需求处理系统。它将多来源需求信号组织到全局分类树中,
+结合先验热度和真实效果完成需求分级,再从已有视频点位中扩展搜索意图,自动寻找适合目标受众的
+短视频,并把保留候选分发到 AIGC 生产计划。
+
+当前产品形态由五部分组成:
+
+- 通用 Agent 框架:LLM、Tool Calling、Skills、运行日志;
+- 数据基础设施:ODPS、MySQL、OSS;
+- 每日供给流水线:同步、归类、聚合、分级、拓展、找片、分发;
+- FastAPI 查询服务;
+- Vue 需求地图和需求/视频证据页面。
+
+### 2.2 核心业务目标
+
+- 每天形成一份可追溯的需求数据快照;
+- 将需求词稳定挂靠到全局分类树;
+- 基于来源热度和真实效果形成 S/A/B/C/D 需求优先级;
+- 为高优需求补充可执行的搜索点位;
+- 自动发现与需求相关、偏老年受众且具有分享价值的视频;
+- 将合格视频安全、准确、幂等地交给正确的 AIGC 生产链路;
+- 通过 API、前端、日志和执行记录解释每个结果是如何产生的。
+
+### 2.3 当前非目标
+
+当前代码尚未完整实现以下目标:
+
+- 需求的多分类挂靠和正式语义关系图;
+- 统一的“平台需求”版本化产品对象;
+- 人工反馈和线上效果自动回流到次日决策;
+- 真正调用发布计划完成内容发布;
+- 在页面中展示 `find_agent` 最终候选及完整审计过程;
+- 跨实例的任务编排、分布式锁和可靠消息机制。
+
+---
+
+## 3. 用户与外部系统
+
+| 角色/系统 | 主要诉求 | 当前交互 |
+|---|---|---|
+| 内容策略/运营 | 看懂需求热度、等级、原因和内容证据 | Vue 需求地图、需求列表、视频点位 |
+| 数据/算法人员 | 核对数据口径、分类、热度和后验 | MySQL、ODPS、Agent 日志 |
+| 运维/开发人员 | 观察任务是否运行、定位失败、手动补偿 | Scheduler 状态接口、日志、`python -m supply_infra.scheduler.jobs.*` |
+| ODPS | 提供分类树、需求池、视频解析、ROV/VOV | 定时查询 |
+| MySQL | 保存业务快照、关系、执行状态和候选 | 全流程状态底座 |
+| OpenRouter/模型服务 | 执行归类、分级、拓展和找片判断 | Agent Tool Calling |
+| 抖音搜索/画像/TikHub/千问 | 提供找片、详情、画像和内容解析证据 | `find_agent` 外部工具 |
+| OSS | 保存 Agent `.log`、`.jsonl` 和 HTML 可视化 | 每次 Agent 运行后上传 |
+| AIGC 平台 | 创建视频爬取计划并绑定生产计划 | 流水线末端调用 |
+
+---
+
+## 4. 关键业务对象
+
+| 对象 | 说明 | 核心数据表 |
+|---|---|---|
+| 全局分类树 | 稳定的内容分类骨架 | `global_tree_category` |
+| 树下元素 | ODPS 提取的实质元素及挂靠 | `global_tree_element` |
+| 策略需求池 | 每日多策略需求原始行 | `multi_demand_pool_di` |
+| 需求归类词 | 由需求名称拆出的词及单一挂靠节点 | `demand_belong_category` |
+| 需求池匹配边 | 归类词与需求池行的子串关系 | `demand_belong_pool_rel` |
+| 词级热度 | 四项先验和两项后验的 avg/count | `demand_popularity_stats` |
+| 树节点热度 | 六维向祖先汇总后的节点指标 | `category_tree_weight` |
+| 分级计划 | 分类节点分组及任务明细 | `demand_grade_plan*` |
+| 需求等级 | 每日 S/A/B/C/D 结果 | `demand_grade`、`demand_grade_category_rel` |
+| 源视频及点位 | 需求池视频的标题、选题、三类点位 | `multi_demand_video_detail`、`multi_demand_video_point` |
+| 需求拓展 | S/A 需求从视频点位选出的拓展意图 | `demand_video_expansion*` |
+| 找片运行 | 搜索树、候选证据、评分和分池 | `video_discovery_run/search/candidate` |
+| AIGC 分发状态 | 候选被分配到的爬取/生产/发布计划标识 | `video_discovery_candidate` |
+| 任务执行记录 | 总流水线 started/finished/failed/skipped | `scheduler_job_execution` |
+| Agent 审计日志 | 模型输入、输出、工具调用及 HTML | 本地 `logs/`、OSS、`oss_logs` |
+
+---
+
+## 5. 每日总流程
+
+```mermaid
+flowchart LR
+    START["API/CLI 启动 Scheduler"] --> CRON["每日 15:00<br/>Asia/Shanghai"]
+    CRON --> T1["① 同步 T-1 全局分类树"]
+    T1 --> POOL["② 同步当日策略需求池"]
+    POOL --> CLASSIFY["归类新词并建立匹配边"]
+    CLASSIFY --> METRIC["回填 ROV/VOV<br/>计算词级和树级热度"]
+    METRIC --> SOURCE_VIDEO["同步源视频标题与三类点位"]
+    SOURCE_VIDEO --> GRADE["③ 需求分级<br/>S/A/B/C/D"]
+    GRADE --> EXPAND["④ S/A 视频点位拓展"]
+    EXPAND --> FIND["⑤ Top 200 需求找片"]
+    FIND --> AIGC["⑥ 创建 AIGC 爬取计划<br/>绑定生产计划"]
+    AIGC --> API["FastAPI 查询"]
+    API --> WEB["Vue 需求地图/需求/视频证据"]
+```
+
+### 5.1 业务日期
+
+- 未显式传入 `biz_dt` 时,按 `SCHEDULER_TIMEZONE` 的当天生成 `YYYYMMDD`;
+- 全局分类树使用 `biz_dt - 1 天` 的 ODPS 分区;
+- 策略需求池、热度、分级、拓展、找片使用 `biz_dt`;
+- 视频解析同步没有使用传入的 `biz_dt`,而是固定读取任务实际执行日的昨天。
+
+---
+
+## 6. 调度与运行控制
+
+### 6.1 启动方式
+
+1. FastAPI 启动时执行 `init_db()`;
+2. 若 `SCHEDULER_ENABLED=true`,在 API lifespan 中启动后台 Scheduler(唯一启动入口);
+3. 可通过 `python -m supply_infra.scheduler.jobs.run_supply_pipeline [YYYYMMDD]` 手动执行全链路。
+
+### 6.2 调度参数
+
+| 参数 | 当前值 |
+|---|---|
+| 触发时间 | 每日 15:00 |
+| 时区 | 默认 `Asia/Shanghai` |
+| Job ID | `run_supply_pipeline` |
+| 同一 Scheduler 最大实例 | 1 |
+| 合并错过的执行 | `coalesce=True` |
+| 允许延迟 | 3600 秒 |
+| 找片需求上限 | 200 |
+| 找片并发 | 2 |
+| 分级并发 | 5 |
+| 点位拓展并发 | 5 |
+
+### 6.3 当前异常策略
+
+- 总流水线用进程内 `threading.Lock` 防止同进程重入;
+- 每个主步骤捕获异常后记录失败,但继续执行后续步骤;
+- 所有步骤完成后,只有全部步骤成功才把总运行标记为成功;
+- 总运行的开始和结束分别写入 `scheduler_job_execution`;
+- 子步骤没有独立、统一的持久化执行记录;
+- Agent 运行另有本地日志、JSONL、HTML 和 OSS 记录。
+
+---
+
+## 7. 阶段一:同步全局分类树
+
+### 7.1 输入
+
+- ODPS `pattern_mining_element` 的 T-1 分区;
+- ODPS `public_pattern_mining_category` 的 T-1 分区;
+- 固定过滤 `execution_id=401`、`source_type/element_type=实质` 等条件。
+
+### 7.2 处理
+
+1. 拉取有效元素及其 `category_id`;
+2. 拉取全量分类;
+3. 从元素命中的分类向上补齐全部祖先;
+4. 按 ODPS `source_id` 去重;
+5. 对 MySQL 不存在的分类分配本地 ID;
+6. 转换父节点 ID 后 `INSERT IGNORE`;
+7. 重载 ID 映射并写入元素。
+
+### 7.3 输出
+
+- `global_tree_category`;
+- `global_tree_element`;
+- 本阶段的拉取、插入、跳过和失败数量。
+
+### 7.4 当前行为边界
+
+- 已存在分类不会更新名称、描述、层级和父节点;
+- ODPS 已删除或迁移的节点不会在 MySQL 自动软删除;
+- 已有元素也只追加,不校正旧挂靠;
+- 缺失父节点的分类会被跳过,但不会阻断后续流水线。
+
+---
+
+## 8. 阶段二:同步需求池并生成热度底座
+
+本阶段实际上是一个包含七个子阶段的内部流水线。任一子阶段失败时,其余子阶段仍会继续。
+
+### 8.1 子阶段 A:同步当日需求池
+
+输入为 ODPS `dwd_multi_demand_pool_di` 的 `biz_dt` 分区。
+
+处理规则:
+
+1. 先比较 ODPS 与 MySQL 当日 `(strategy, demand_id)` 去重行数;
+2. 行数相同时,完全跳过明细拉取;
+3. 行数不同时,拉取全量明细并按 `(strategy, demand_id)` 做插入、删除和更新;
+4. `video_list` 最多保留 10 个视频;
+5. `weight=0` 转为 `NULL`;
+6. 对已有行只更新 `video_list`、`video_count` 和 `weight`。
+
+输出为 `multi_demand_pool_di` 当日数据。
+
+### 8.2 子阶段 B:归类新需求词
+
+1. 查询当日全部 `demand_name`;
+2. 使用空格拆词,汇总为去重词集合;
+3. 过滤 `demand_belong_category` 已存在的名称;
+4. 每 100 个词调用一次 `demand_belong_category_agent`;
+5. Agent 查询真实分类树,选择分类节点并写入名称、节点和 reason。
+
+当前表以 `name` 唯一,所以一个词实际上只能保存一个分类节点。
+
+### 8.3 子阶段 C:建立归类词与需求池关系
+
+1. 读取全部有效归类词;
+2. 读取需求池全表,而非仅当日分区;
+3. 若 `归类词.name in 需求池.demand_name`,则建立关系边;
+4. 已有边跳过,只插入缺失边;
+5. 合并匹配行的视频,去重后最多保留 10 个,回填归类词。
+
+输出:
+
+- `demand_belong_pool_rel`;
+- `demand_belong_category.video_list`。
+
+### 8.4 子阶段 D:回填真实 ROV/VOV
+
+1. 查询 `biz_dt-7天` 至 `biz_dt` 的生产效果数据;
+2. 仅保留人工 AGC 和自动 AGC;
+3. 计算各特征相对全局基线的 `rov_diff`、`vov_diff`;
+4. SQL 最多返回 1000 行;
+5. 同一特征值有多行时选择 `rov_diff` 优先、再比较 `vov_diff` 的较高记录;
+6. 使用 `特征值 == demand_name` 精确匹配回填当日需求池。
+
+输出:
+
+- `multi_demand_pool_di.real_rov_7d`;
+- `multi_demand_pool_di.real_vov_7d`。
+
+### 8.5 子阶段 E:计算词级热度
+
+对 `demand_belong_category` 的全部有效词逐一处理:
+
+1. 在当日需求池用 `demand_name LIKE %词%` 查找匹配行;
+2. 将来源策略映射为四项先验;
+3. 非零权重计算 avg/count;
+4. ROV/VOV 按匹配需求名称聚合;
+5. 按 `(demand_category_id, biz_dt)` upsert。
+
+策略映射:
+
+| 来源策略 | 指标 |
+|---|---|
+| 新热事件 | `ext_pop` |
+| 逐月 | `plat_sust_pop` |
+| 去年同期阳历、去年同期阴历 | `plat_ly_pop` |
+| 当下供需gap | `recent_pop` |
+
+输出为 `demand_popularity_stats`。
+
+### 8.6 子阶段 F:计算树节点热度和排名
+
+1. 将同一挂载节点下的词级指标按样本数加权;
+2. 每个分类节点汇总其整个子树内所有挂载点;
+3. 六维分别保存 avg/count;
+4. 四项先验分别做全树排名归一化;
+5. 将有效的四维排名分直接相加为 `total_score`;
+6. ROV/VOV 保存但不进入 `total_score`。
+
+输出为 `category_tree_weight`。
+
+### 8.7 子阶段 G:同步需求池源视频
+
+1. 从需求池全表所有 `video_list` 收集视频 ID;
+2. 已存在于详情表的 ID 直接跳过;
+3. 对待处理 ID 分批查询 ODPS 视频解析结果;
+4. ODPS 分区固定使用任务执行日的昨天;
+5. 提取标题、最终选题、灵感点、目的点和关键点;
+6. 写入详情表,并替换本批视频的点位行。
+
+输出:
+
+- `multi_demand_video_detail`;
+- `multi_demand_video_point`。
+
+---
+
+## 9. 阶段三:需求分级
+
+### 9.1 目标
+
+将当日需求分为 S/A/B/C/D,供后续点位拓展、找片和资源分配使用。
+
+### 9.2 自动计划
+
+1. 从 `category_tree_weight.hung_word_count > 0` 的节点中找待分配节点;
+2. 同分类、同父分类优先组合;
+3. 每组目标约 30 条需求;
+4. 每日最多 200 个组;
+5. 计划及组写入 `demand_grade_plan`、`demand_grade_plan_group`;
+6. 通过匹配关系解析当日需求池行,物化为组明细。
+
+当前每日定时任务默认使用代码自动分组,不使用 `demand_grade_orchestrator_agent`。
+
+### 9.3 Agent 分级
+
+1. 5 个 worker 并发领取 pending 组;
+2. 每组明细再按最多 30 条拆分;
+3. `demand_grade_agent` 查询:
+   - 需求来源内排名分;
+   - 分类树 `total_score`;
+   - 父节点及全部兄弟节点;
+   - 词级和节点级 ROV/VOV;
+   - 同名或包含关系的需求池行;
+4. Agent 判定 S/A/B/C/D 并写 reason;
+5. 保存工具确定性重算 `score`:
+   - 每个 strategy 内按非零 weight 排名归一化;
+   - 同一需求已有来源等权平均;
+   - 结果映射为 0~100;
+6. 保存分类关系、关联需求池行、来源、视频列表和先后验快照。
+
+### 9.4 输出
+
+- `demand_grade`;
+- `demand_grade_category_rel`;
+- 计划组和组明细状态。
+
+---
+
+## 10. 阶段四:S/A 需求视频点位拓展
+
+### 10.1 入选条件
+
+一条需求必须同时满足:
+
+- 当日等级为 S 或 A;
+- `demand_grade.video_list` 非空;
+- 对应视频已同步出三类点位;
+- 当日尚未有 finished 拓展记录。
+
+### 10.2 处理
+
+1. 汇总需求关联视频的全部灵感点、目的点和关键点;
+2. 5 个 worker 分别为单条需求调用 `demand_video_expand_agent`;
+3. Agent 判断点位是否与原需求存在包含、细分或同意图关系;
+4. 过滤与原需求完全相同、过宽、无关或非需求表达的点;
+5. 保存拓展文本、点位类型、视频、描述和 reason;
+6. 无候选时允许保存 0 条,并将运行记录标记为 finished。
+
+### 10.3 输出
+
+- `demand_video_expansion`;
+- `demand_video_expansion_run`。
+
+---
+
+## 11. 阶段五:find_agent 视频发现
+
+### 11.1 入选与排序
+
+1. 只读取 S/A 需求;
+2. 只保留已经产生 `demand_video_expansion` 的需求;
+3. 使用拓展记录按视频组装参考标题和点位;
+4. 当前实现先按 `score` 降序,再用 S/A 作为次级排序;
+5. 每天最多取前 200 条;
+6. 2 个 worker 并发执行;
+7. 当日存在 `running` 或 `finished` 找片记录时默认跳过。
+
+### 11.2 单需求找片流程
+
+1. 系统预创建 `video_discovery_run` 并生成 `run_id`;
+2. Agent 根据需求、参考视频和点位形成 2~3 个搜索假设;
+3. 调用内部抖音搜索、TikHub 或作者作品搜索;
+4. 每个搜索页写入 `video_discovery_search`;
+5. 候选视频写入 `video_discovery_candidate`,初始为 `pending_evaluation`;
+6. 对高潜候选补充详情、视频点赞用户画像和作者粉丝画像;明确不使用视频理解;
+7. Agent 独立判断:
+   - R:需求相关性;
+   - E:老年受众倾向;
+   - S:分享价值;
+   - V:联合价值;
+8. 候选进入:
+   - `primary`:主推荐;
+   - `rejected`:淘汰;
+   - `pending_evaluation`:尚未完成的过程状态,不是最终等级;
+9. Agent 保存最终候选评估并将运行置为 `finished`;
+10. 数据库审计通过后重新查询最终状态,再输出主推荐、淘汰原因、搜索树和缺失证据;
+11. completion guard 校验顺序和报告分池,报告之后不再修改数据库。
+
+### 11.3 输出
+
+- `video_discovery_run`;
+- `video_discovery_search`;
+- `video_discovery_candidate`;
+- Agent 审计日志和 OSS HTML。
+
+---
+
+## 12. 阶段六:AIGC 分发
+
+### 12.1 当前入选规则
+
+- 选择当日 `decision_bucket=primary` 的候选;
+- `aweme_id` 必须非空;
+- 默认跳过已经写入 `aigc_crawler_plan_id` 的候选;
+- 当前查询不要求所属 `video_discovery_run.status=finished`。
+
+### 12.2 当前处理
+
+1. 对 AIGC 计划映射按 `(生成ID, 发布ID)` 去重;
+2. 将所有候选轮询均匀分配到所有计划对,不按需求或分类路由;
+3. 每 10 个视频创建一个爬取计划;
+4. 获取生产计划详情;
+5. 将爬取计划追加为生产计划的输入源;
+6. 绑定成功后,在候选表保存:
+   - 爬取计划 ID;
+   - 生成计划 ID;
+   - 发布计划 ID;
+   - 分配标签。
+
+### 12.3 重要语义说明
+
+当前代码只“创建爬取计划并绑定生成计划”,没有使用 `publish_plan_id` 调用发布接口,
+也没有验证内容生产或发布完成。因此现阶段准确名称应为“AIGC 生产计划分发”,不能视为真正发布完成。
+
+---
+
+## 13. API 与前端消费流程
+
+### 13.1 API
+
+| 接口 | 当前用途 |
+|---|---|
+| `GET /health` | 进程健康 |
+| `GET /api/scheduler/status` | 当前进程 Scheduler 状态和下次运行时间 |
+| `GET /api/category-tree` | 分类树、六维指标、`total_score` |
+| `GET /api/demand-belong-category` | 归类词及单一挂靠 |
+| `GET /api/demand-belong-category/{id}/videos` | 归类词的源视频和点位 |
+| `GET /api/demand-grade` | 当日分级需求 |
+| `GET /api/demand-grade/{id}/videos` | 分级需求的拓展视频和点位 |
+| `GET /api/video-discovery/demands` | 以需求为中心的分级与拓展摘要 |
+| `GET /api/video-discovery/demands/{id}` | 需求的拓展视频证据 |
+| `GET /api/demand-belong-oss-logs` | 归类 Agent 日志 |
+
+### 13.2 前端
+
+| 页面 | 当前展示 |
+|---|---|
+| 平台全局需求地图 | 分类树、热度、分级需求及下钻 |
+| 全局分类树 | 可展开分类树和多维热度 |
+| 需求归类过程 | 归类 Agent OSS 日志 |
+| 需求汇总/视频发现 | 分级需求、源视频、拓展点位 |
+
+当前前端“视频发现”页面没有读取 `video_discovery_candidate`,因此展示的是找片输入证据,
+不是 `find_agent` 最终找到的主推荐和淘汰候选。
+
+---
+
+## 14. 旁路与手动流程
+
+以下能力存在于项目中,但不属于每日总流水线:
+
+- `generate_demand_agent`:按单一热度维度写入 `generated_demand`;
+- `demand_grade_orchestrator_agent`:模型统筹分组,当前定时路径已改为代码自动分组;
+- `python -m supply_infra.scheduler.jobs.grade_demand_pool --retry-failed`:手动重试失败分级明细;
+- 通过 API 或 `python -m supply_infra.scheduler.jobs.*` 单独跑各流水线步骤;
+- 日志可视化和手动 OSS 上传。
+
+`generated_demand` 当前没有接入总流水线、API 或前端,属于孤立产物。
+
+---
+
+## 15. 功能需求
+
+### FR-01 每日批次
+
+- 系统必须为每个 `biz_dt` 建立唯一每日批次;
+- 必须记录使用的分区、代码版本、配置版本、模型、Prompt 版本和每步状态;
+- 同一业务日重跑必须生成明确的重跑版本或复用幂等键,不得静默覆盖。
+
+### FR-02 依赖门禁
+
+- 下游步骤只能消费通过完整性校验的上游产物;
+- 分类树或需求池失败时,不得执行依赖其结果的分级;
+- 分级覆盖不完整时,不得将该日结果作为可发布批次;
+- 找片未完成审计时,不得进入 AIGC 分发。
+
+### FR-03 数据同步
+
+- 同步判断必须基于主键集合和内容校验值,不得只比较行数;
+- 分类、元素、需求池和关系都必须明确支持新增、更新、删除/失效;
+- 所有时间窗口和分区必须由 `biz_dt` 派生,支持历史重跑;
+- 缺失、0、NULL 和迟到数据必须有不同语义。
+
+### FR-04 需求模型
+
+- 原始需求行、标准需求词和平台需求必须分层;
+- 一个需求允许挂靠多个分类节点;
+- 每条挂靠和关系必须保存来源、reason、置信度和版本;
+- 字符串包含只能作为候选匹配,不得直接成为权威语义关系。
+
+### FR-05 分级
+
+- 每条当日有效需求必须得到“成功、失败、跳过及原因”之一;
+- 计划覆盖率和结果覆盖率必须分别达到 100% 才能标记完成;
+- S/A/B/C/D 和数值 score 的口径必须版本化;
+- Agent 返回成功后必须查询数据库验证全部输入项均已落库;
+- 失败项自动重试达到上限后进入补偿队列和报警。
+
+### FR-06 点位拓展
+
+- 每条 S/A 需求必须有明确结果:有拓展、无拓展、无源视频、无点位或执行失败;
+- 0 条拓展必须是经工具确认的有效业务结果,不能由“未调用保存工具”推断;
+- 没有拓展点的 S/A 需求仍应允许使用原需求进入找片。
+
+### FR-07 视频发现
+
+- 入选顺序必须符合确认后的业务策略,S/A 优先级与 score 排序不得冲突;
+- 每个运行必须有租约和超时,崩溃遗留的 `running` 可自动恢复;
+- 完成前必须校验搜索页、候选评估、证据、审计和最终状态;
+- 强制重跑必须新建 attempt 或清理旧子记录,不能混用两次搜索状态;
+- 相同视频跨需求发现时必须全局去重或形成“一视频多需求”关系。
+
+### FR-08 AIGC 分发与发布
+
+- 只有 finished 且审计通过的找片运行可以分发;
+- 仅 `primary` 允许自动分发;
+- 候选必须按需求分类或明确的路由规则进入正确计划;
+- 创建、绑定、生产、发布每个外部动作必须有幂等键和独立状态;
+- 必须区分“已分发、已生产、已发布、发布失败”;
+- 只有真实发布接口成功并经查询确认后,才能标记“已发布”。
+
+### FR-09 查询与运营
+
+- 前端必须展示每日批次及各阶段状态;
+- 必须展示 `find_agent` 的主推荐、淘汰候选和证据;
+- 支持按业务日、需求、分类、运行状态和发布状态检索;
+- 支持安全的失败项重试,并保留操作审计。
+
+---
+
+## 16. 非功能需求
+
+### 16.1 可靠性
+
+- 调度采用数据库锁、Redis 锁或独立 Scheduler Leader,保证跨进程/副本单实例执行;
+- 数据写入和外部调用采用幂等设计;
+- 每个步骤支持断点续跑;
+- 任一步失败不得造成错误数据进入不可逆的下游系统。
+
+### 16.2 可观测性
+
+- 每步记录开始、结束、输入量、输出量、失败量、耗时和重试次数;
+- 提供批次级和需求级状态查询;
+- P0/P1 失败应主动报警;
+- 日志不得包含 API Token、密码、Cookie 或完整敏感请求体。
+
+### 16.3 性能
+
+- 避免对全部归类词和需求池做 Python 双重循环;
+- 高频查询建立明确唯一键和索引;
+- API 大结果支持分页、缓存或按子树读取;
+- LLM 调用并发必须受全局限流、超时和成本预算控制。
+
+### 16.4 安全
+
+- 生产 API 必须有认证、授权和访问审计;
+- 外部 Token 只能通过密钥管理系统注入;
+- OSS 日志访问应按敏感级别控制;
+- 所有日志和错误信息必须脱敏。
+
+---
+
+## 17. 已发现问题与风险
+
+### 17.1 P0:上线前必须处理
+
+| ID | 问题 | 影响 | 代码现状/证据 | 产品要求 |
+|---|---|---|---|---|
+| P0-01 | 上游失败后仍继续下游 | 可能用旧树、空需求池或不完整分级继续找片和分发 | 总流水线逐步捕获异常并无条件继续 | 建立依赖 DAG 和硬门禁 |
+| P0-02 | 分级存在失败组时仍可能判定完成 | 部分需求未分级,但总步骤返回成功 | `get_execution_snapshot` 的 `execution_complete` 只统计 pending/running,不统计 failed | failed 必须阻止完成 |
+| P0-03 | 无计划或无分级也可能成功 | 当日需求完全未处理仍进入拓展和找片 | 自动计划异常被吞掉;空计划快照可被视为 complete | 校验计划覆盖率和分级覆盖率 |
+| P0-04 | Agent 返回即把分级明细标记 finished | 模型未保存、少保存或保存工具报错时产生假完成 | worker 不核对 `demand_grade` 实际落库覆盖 | 每批结束后按输入逐条验库 |
+| P0-05 | 点位拓展把“未保存”误判为“零结果” | S/A 需求被永久标记完成并从找片链路消失 | 从工具文本解析不到数量时默认为 0,仍写 finished | 必须验证保存工具调用和 run 状态 |
+| P0-06 | 找片确定性完成控制(已修复) | 防止在搜索、证据、评估或审计未完成时提前结束 | 已注册数据库审计并启用 completion guard,强制审计后查询最终状态再报告 | 保持顺序与负向回归测试 |
+| P0-07 | AIGC 分发不校验找片运行状态 | running/failed 运行中的候选也可能被外发 | publish 查询只筛候选 bucket,不筛 run status/audit | 仅 finished+审计通过可分发 |
+| P0-08 | AIGC 按所有计划轮询,不按品类路由 | 健康、历史、时政等视频可能进入错误生产计划 | 代码明确“不区分品类”,均匀分发 | 建立可配置且可解释的分类路由 |
+| P0-09 | “发布”没有真正执行发布 | 业务误以为已发布,实际只绑定了生成计划 | `publish_plan_id` 只入库,没有参与外部 API 调用 | 拆分分发/生产/发布状态并实现确认 |
+| P0-10 | 外部副作用缺少端到端幂等 | 绑定失败、进程崩溃或数据库回写失败会重复创建爬取计划 | 只有 DB 回写成功后才算已处理 | 使用业务幂等键、outbox 和状态机 |
+| P0-11 | AIGC 错误日志可能泄露 Token | 日志/OSS 中可能出现生产密钥 | `_post` 错误日志输出包含 `baseInfo.token` 的请求体 | 立即脱敏并轮换可能暴露的密钥 |
+
+### 17.2 P1:高优先级正确性与稳定性问题
+
+| ID | 问题 | 影响 | 建议 |
+|---|---|---|---|
+| P1-01 | 进程锁无法防止多 API worker/多副本重复调度 | 重复写库、重复模型调用、重复 AIGC 操作 | 独立 Scheduler 或分布式锁 |
+| P1-02 | 需求池仅凭行数相同就跳过同步 | 行内容变化但总数不变时保留旧数据 | 比较主键+内容 hash 或直接 upsert diff |
+| P1-03 | 分类树和元素只追加不更新/失效 | 分类名称、层级、父子关系长期漂移 | 引入快照版本和变更同步 |
+| P1-04 | 关系边只增不删且缺少外键 | 需求池删除后留下悬空关系,数据不断膨胀 | 增加 FK/唯一键并按快照清理 |
+| P1-05 | `multi_demand_pool_di` 无数据库唯一约束 | 并发执行时可产生重复业务行 | 增加 `(biz_dt,strategy,demand_id)` 唯一键 |
+| P1-06 | 分类 ID 通过 `max(id)+1` 分配 | 多实例同步时可能主键冲突 | 使用自增 ID,先父后子映射或事务锁 |
+| P1-07 | 一词只能挂一个分类,与 Prompt/业务目标冲突 | 多义词和跨领域需求被错误压缩 | 拆分需求词表和多对多挂靠表 |
+| P1-08 | 子串匹配直接建立权威关系 | 短词误命中、语义污染、错误热度归因 | 候选召回+规则/模型确认+置信度 |
+| P1-09 | 当日 `hung_word_count` 包含所有历史归类词 | 无当日数据的历史节点仍进入分级计划 | 按当日有效关系计算挂载数 |
+| P1-10 | “近 7 日”查询实际覆盖 biz_dt-7 到 biz_dt,共 8 个自然日 | 后验口径偏移 | 修正为 7 个自然日并固化口径测试 |
+| P1-11 | ROV/VOV 只取前 1000 行且倾向保留较高值 | 长尾缺失、后验结果乐观偏差 | 全量/分页拉取;按业务主键聚合 |
+| P1-12 | 视频解析分区使用运行日昨天,不使用 biz_dt | 历史重跑读错分区,迟到数据无法补齐 | 所有分区从 biz_dt 派生 |
+| P1-13 | 已存在视频详情永远跳过 | 标题、点位或解析结果更新无法自动刷新 | 保存源版本并支持变更 upsert |
+| P1-14 | 无拓展候选的 S/A 需求完全不进入找片 | 高优需求因缺少拓展点而丢失 | 原需求本身作为默认搜索根 |
+| P1-15 | 找片排序实现与注释不一致 | A 级高 score 可排在 S 级前 | 产品确认排序并加测试 |
+| P1-16 | `running` 找片记录无租约,可能永久跳过 | 进程硬退出后任务永远不再执行 | 增加 heartbeat、超时和 attempt |
+| P1-17 | 强制重跑复用旧 run_id 和旧子记录 | 两次搜索轨迹、候选和状态互相污染 | 每次重跑新 attempt,显式继承关系 |
+| P1-18 | 同一 aweme_id 可在多个 run 重复分发 | AIGC 重复抓取/生产同一视频 | 建立全局视频资产和发布唯一性 |
+| P1-19 | 最终文字覆盖数据库分池(已修复) | 防止未经审计的文本解析改变候选状态 | 最终报告只读数据库最终状态并由 guard 校验 |
+| P1-20 | 前端“视频发现”未展示真实发现候选 | 运营无法核对主推荐、备选和发布状态 | 新增 candidate/run API 和页面 |
+| P1-21 | Scheduler 默认启用且随 API 启动 | 开发、扩容或临时环境可能误触生产任务 | 生产显式开启,默认关闭 |
+| P1-22 | 延迟超过 1 小时会丢失当日调度 | API 故障恢复后不会自动补跑 | 按 biz_dt 对账并自动补批次 |
+| P1-23 | API 无认证并监听 `0.0.0.0` | 业务数据和 OSS 日志地址可能被未授权访问 | 接入认证、权限和网关 |
+| P1-24 | 自动化测试当前无法完成收集 | 关键回归无法执行 | 修复/删除失效测试并纳入 CI |
+
+### 17.3 P2:产品一致性与可维护性问题
+
+| ID | 问题 | 影响 | 建议 |
+|---|---|---|---|
+| P2-01 | 只记录总流水线 start/end | 无法可靠查询每个子步骤的历史与重试 | 建立批次表和步骤执行表 |
+| P2-02 | `scheduler_job_execution.detail` 可能写入巨大嵌套结果 | TEXT 超限后执行记录静默丢失 | 摘要字段结构化,详细结果独立存储 |
+| P2-03 | Agent Prompt、模型和策略未作为业务版本落库 | 跨日等级不可重现 | 保存模型、Prompt hash、参数和工具版本 |
+| P2-04 | `total_score` 将热度和维度覆盖混在一起 | 缺维节点天然低分,口径难解释 | 分离热度、覆盖率、置信度 |
+| P2-05 | API 全量返回分类树和需求,缺少分页/缓存 | 数据增长后响应慢、前端内存压力大 | 子树 API、分页、ETag/缓存 |
+| P2-06 | `generated_demand` 是孤立产物 | 项目存在两套“需求”概念 | 接入正式生命周期或下线 |
+| P2-07 | 文档、Agent 清单、定时时间和代码不一致 | 运维和研发容易按错误流程操作 | 将本文设为主文档并持续更新 |
+| P2-08 | `create_find_agent` 的 model 参数未实际生效 | 调试和灰度模型切换失效 | 尊重传入 model,并记录版本 |
+| P2-09 | 依赖清单未显式声明 `requests` | 依赖传递变化时 AIGC 客户端可能无法启动 | 在项目依赖中直接声明 |
+
+---
+
+## 18. 已复现的测试问题
+
+执行:
+
+```bash
+.venv/bin/python -m pytest -q
+```
+
+当前在测试收集阶段失败,未进入完整测试执行:
+
+1. `tests/agents/demand_grade_orchestrator_agent/test_orchestrator.py` 引用已不存在的
+   `common.plan_builder`;
+2. `tests/supply_infra/scheduler/test_grade_demand_pool.py` 引用已从当前实现移除的
+   `_execute_plan_tasks_with_retries` 等函数。
+
+这说明当前“增加全流程定时任务测试”的提交与实际代码没有保持同步,不能将现有测试目录视为有效回归保障。
+
+---
+
+## 19. 目标流程
+
+```mermaid
+flowchart TB
+    BATCH["创建每日批次<br/>冻结 biz_dt/代码/策略/模型版本"]
+    LOCK{"取得分布式锁?"}
+    SYNC["同步并校验树、需求池、后验、视频分区"]
+    QUALITY{"数据质量门禁通过?"}
+    GRADE["生成分级计划并执行"]
+    COVER{"计划覆盖率=100%<br/>分级覆盖率=100%?"}
+    EXPAND["S/A 拓展<br/>每条都有明确终态"]
+    DISCOVER["找片 attempt<br/>租约/恢复/完成审计"]
+    AUDIT{"运行 finished<br/>候选审计通过?"}
+    ROUTE["按需求分类路由 AIGC"]
+    OUTBOX["幂等 outbox<br/>创建→绑定→生产→发布"]
+    VERIFY["查询外部状态并确认"]
+    DONE["批次完成并发布日报"]
+    STOP["停止下游<br/>告警+补偿队列"]
+
+    BATCH --> LOCK
+    LOCK -->|否| STOP
+    LOCK -->|是| SYNC
+    SYNC --> QUALITY
+    QUALITY -->|否| STOP
+    QUALITY -->|是| GRADE
+    GRADE --> COVER
+    COVER -->|否| STOP
+    COVER -->|是| EXPAND
+    EXPAND --> DISCOVER
+    DISCOVER --> AUDIT
+    AUDIT -->|否| STOP
+    AUDIT -->|是| ROUTE
+    ROUTE --> OUTBOX
+    OUTBOX --> VERIFY
+    VERIFY -->|失败| STOP
+    VERIFY -->|成功| DONE
+```
+
+---
+
+## 20. 迭代优先级
+
+### 20.1 第一阶段:阻止错误外发
+
+- 为总流水线增加依赖门禁;
+- 修复 failed 分级组被视为完成的问题;
+- 增加计划覆盖率、分级覆盖率和逐项落库校验;
+- 保持找片完成守卫、数据库审计和最终状态顺序的负向回归;
+- AIGC 只读取 finished 且审计通过的运行;
+- 暂停无分类路由的自动分发,先切换为 dry-run 或人工确认;
+- 对 AIGC 请求日志脱敏;
+- 明确“分发、生产、发布”三种状态。
+
+### 20.2 第二阶段:实现可靠重跑
+
+- 引入每日批次、步骤状态、attempt 和分布式锁;
+- 增加业务唯一键和外键;
+- 修复需求池 count-skip、关系清理和树变更同步;
+- 所有分区从 `biz_dt` 派生;
+- 找片运行增加租约、超时和恢复;
+- AIGC 外部调用改为 outbox + 幂等状态机;
+- 全局 aweme 去重。
+
+### 20.3 第三阶段:完善产品闭环
+
+- 重构原始需求、标准词、平台需求和多挂靠关系;
+- 将找片结果和 AIGC 状态接入 API/前端;
+- 接入真实生产/发布结果和 ROV/VOV 回流;
+- 统一或下线 `generated_demand` 旁路;
+- 增加策略中心、版本比较和人工反馈。
+
+---
+
+## 21. 验收标准
+
+### 21.1 每日运行
+
+- 同一环境、同一 `biz_dt` 只产生一个有效主批次;
+- 任何上游失败都不会触发错误下游外发;
+- 每个步骤都有独立状态、耗时、输入量、输出量和错误摘要;
+- 延迟或停机恢复后能自动识别并补跑缺失业务日。
+
+### 21.2 数据正确性
+
+- 需求池源数据主键和内容与 ODPS 对账一致;
+- 分类树新增、更新、迁移、删除都有明确处理;
+- 时间窗口自动化测试证明“7 日”恰好包含 7 个业务日;
+- 当日挂载数只统计当日有效需求;
+- 历史重跑读取对应 `biz_dt` 的全部分区。
+
+### 21.3 分级
+
+- 计划节点覆盖率 100%;
+- 当日有效需求结果覆盖率 100%;
+- failed 组数大于 0 时总步骤必为失败;
+- 每个 Agent 批次输入都能在数据库逐条找到结果或明确失败原因;
+- 相同数据、模型和策略版本重跑结果可解释、可比较。
+
+### 21.4 找片
+
+- 每条入选 S/A 需求都有“已完成、无候选、无数据、失败”之一;
+- 无拓展点的 S/A 需求仍能以原需求搜索;
+- finished 运行不存在 `pending_evaluation` 候选;
+- 搜索页、证据、候选分池、审计和最终报告一致;
+- 崩溃遗留 running 能在超时后自动恢复。
+
+### 21.5 AIGC
+
+- 100% 候选来自 finished 且审计通过的运行;
+- 每个候选进入与需求分类匹配的计划;
+- 同一 aweme 在同一发布策略下最多外发一次;
+- 创建、绑定、生产、发布状态可分别查询;
+- 外部接口和数据库任一侧重试不会产生重复计划;
+- 日志中不存在明文 Token。
+
+### 21.6 测试和发布
+
+- `pytest` 可完整收集并通过;
+- P0 路径具备单元测试和集成测试;
+- CI 覆盖同步差异、失败门禁、断点重跑、并发锁、找片审计和 AIGC 幂等;
+- AIGC 默认 dry-run,通过灰度和人工确认后才开启真实外发。
+
+---
+
+## 22. 建议运营指标
+
+| 指标 | 建议目标 |
+|---|---|
+| 每日批次成功率 | ≥ 99% |
+| 数据同步对账差异 | 0 |
+| 分级计划覆盖率 | 100% |
+| 分级结果覆盖率 | 100% |
+| S/A 明确终态覆盖率 | 100% |
+| 找片审计通过率 | 可按失败原因分层,不允许绕过 |
+| 重复 AIGC 外发率 | 0 |
+| 错误品类路由率 | 0 |
+| 密钥明文日志事件 | 0 |
+| P0 失败发现时延 | ≤ 5 分钟 |
+
+---
+
+## 23. 待业务确认
+
+1. 每日 `biz_dt` 应使用当天还是 T-1,15:00 时上游当天分区是否已稳定;
+2. S 与 A 的资源排序是否必须严格 S 优先;
+3. 每天 Top 200 是固定预算,还是应按分类、等级、探索比例动态分配;
+4. 没有源视频或点位的 S/A 需求应如何搜索;
+5. AIGC 计划应按哪个分类层级路由,一条需求多挂靠时如何选主路由;
+6. “发布”最终是完成生产计划绑定,还是必须真正上线到目标账号;
+7. ROV/VOV 的有效样本门槛、归因方式和跨日衰减规则;
+8. `generated_demand` 是否要成为正式平台需求,还是退出主产品;
+9. API 和 OSS 日志的访问权限、数据保留周期及脱敏要求。
+
+---
+
+## 24. 代码导航
+
+| 流程 | 关键代码 |
+|---|---|
+| Scheduler 注册 | `supply_infra/scheduler/app.py` |
+| 总流水线 | `supply_infra/scheduler/jobs/run_supply_pipeline.py` |
+| 全局树同步 | `supply_infra/scheduler/jobs/sync_global_tree_odps_to_mysql.py` |
+| 需求池同步 | `supply_infra/scheduler/jobs/demand_pool/` |
+| 树热度 | `supply_infra/scheduler/jobs/demand_pool/tree_weight.py` |
+| 分级 | `supply_infra/scheduler/jobs/grade_demand_pool.py`、`agents/demand_grade_agent/` |
+| 点位拓展 | `supply_infra/scheduler/jobs/expand_demand_from_video_points.py`、`agents/demand_video_expand_agent/` |
+| 找片 | `supply_infra/scheduler/jobs/discover_videos_from_demands.py`、`agents/find_agent/` |
+| AIGC 分发 | `supply_infra/scheduler/jobs/publish_videos_from_discovery.py`、`supply_infra/aigc/` |
+| 数据模型 | `supply_infra/db/models/` |
+| API | `api/app.py`、`api/services/` |
+| 前端 | `web/src/views/`、`web/src/components/` |

+ 3 - 0
README.md

@@ -150,12 +150,15 @@ async for event in agent.astream("Your question"):
 |------|------|--------|
 | `OPENROUTER_API_KEY` | OpenRouter API 密钥 | (必填) |
 | `OPENROUTER_MODEL` | 默认模型 | `google/gemini-2.5-flash` |
+| `OPENROUTER_TIMEOUT_SECONDS` | 单次模型请求超时秒数 | `120` |
 | `AGENT_MAX_ITERATIONS` | 最大循环次数 | `20` |
 | `AGENT_TEMPERATURE` | 生成温度 | `0.7` |
 | `SKILLS_DIR` | Skills 目录 | `skills` |
 | `LOGS_DIR` | Agent 运行日志目录 | `logs` |
 | `LOG_ENABLED` | 是否写入运行日志 | `true` |
 
+find_agent 本地:`FIND_AGENT_TIMEOUT_SECONDS`(默认 `600`,即 10 分钟)由 `agents/find_agent/runtime.py` 读取,不属于通用 `supply_agent` 配置。
+
 ## 运行日志与可视化
 
 每次 `agent.run()` 会在 `logs/` 下写出:

+ 1 - 1
agents/README.md

@@ -18,7 +18,7 @@ agents/<agent_name>/
 
 | Agent | 目录 | 说明 |
 |-------|------|------|
-| find_agent | `agents/find_agent/` | 抖音搜索 + 视频解析 + 内容入库 |
+| find_agent | `agents/find_agent/` | 自主扩词、多页搜索,结合分享数据与双侧年龄画像输出 primary / rejected;不使用视频理解 |
 
 ## 新增 Agent 模板
 

+ 6 - 2
agents/demand_belong_category_agent/agent.py

@@ -11,6 +11,7 @@ from pathlib import Path
 from supply_agent import Agent
 from supply_agent.config import Settings
 from agents.demand_belong_category_agent.tools import register_all_tools
+from supply_infra.agent_prompt_injection import compose_agent_system_prompt
 
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
@@ -26,8 +27,11 @@ def create_demand_belong_category_agent(
         settings=settings,
         name="demand_belong_category_agent",
         model=model,
-        system_prompt=DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT,
-        max_iterations=30,
+        system_prompt=compose_agent_system_prompt(
+            DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT,
+            "demand_belong_category_agent",
+        ),
+        max_iterations=50,
     )
 
     # 本 Agent 专属工具

+ 2 - 8
agents/demand_belong_category_agent/prompt/system_prompt.md

@@ -13,14 +13,8 @@
 - `batch_insert_demand_belong_category(items)`: 批量插入节点到目标表
 
 ## 工作流程
-1. 调用 `query_global_tree_category` 获取顶级类目,结合词语语义判断最相关的 1~2 个分支。
-2. 下钻进入相关性最高的节点,再次调用 `query_global_tree_category` 查看子类目,继续判断。
-3. 重复上述过程,直到到达叶子节点,或当前层级所有子节点相关度都达不到"有把握"的程度——此时停在上一层已确认的节点,不要为了到达叶子而勉强选择。
-4. 若某个分支下钻后发现子节点都不合适,回退到上一层,重新评估其他兄弟节点,而不是硬选一个。
-5. 若顶级类目层面就找不到相关分支,直接判定为无法归类,不要勉强挂载。
-6. 若词语同时与两个及以上互斥分支都高度相关(一词多义、跨领域词等),记录全部高置信候选,标记为存在歧义。
-7. 每次下钻前用一两句话写明判断依据,再决定是否调用工具;推理要言之有据,避免空泛。
-8. 单个词语的下钻步数建议不超过 8 次工具调用;仍无法收敛时,停在当前最有把握的节点并说明原因,不要无限下钻。
+1. 调用 `query_global_tree_category` 获取全部类目,结合词语语义判断最相关的 1~2 个分支。
+2. 直接判断应该属于哪个分类,然后把词语归属到该分类下,使用`batch_insert_demand_belong_category(items)`工具插入到目标表。
 
 ## 分类判断原则
 - **确定性优先于深度**:能到叶子节点最好,但深入一层后判断不明确时,宁可停在上一层泛化节点。

+ 1 - 1
agents/demand_grade_agent/__init__.py

@@ -2,7 +2,7 @@
 demand_grade_agent — 需求分级评估 Agent
 
 职责:对 multi_demand_pool_di 中的现有需求,结合全局树先验热度
-(category_tree_weight)与后验真实效果(real_rov_7d),以及需求词粒度效果
+(category_tree_weight)与后验 rov_diff/vov_diff(real_rov_7d/real_vov_7d 字段),以及需求词粒度效果
 (demand_popularity_stats),划分 S/A/B/C/D 等级并落库到 demand_grade 表。
 """
 from agents.demand_grade_agent.agent import create_demand_grade_agent

+ 5 - 1
agents/demand_grade_agent/agent.py

@@ -8,6 +8,7 @@ from pathlib import Path
 from supply_agent import Agent
 from supply_agent.config import Settings
 from agents.demand_grade_agent.tools import register_all_tools
+from supply_infra.agent_prompt_injection import compose_agent_system_prompt
 
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_GRADE_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
@@ -23,7 +24,10 @@ def create_demand_grade_agent(
         settings=settings,
         name="demand_grade_agent",
         model=model,
-        system_prompt=DEMAND_GRADE_AGENT_SYSTEM_PROMPT,
+        system_prompt=compose_agent_system_prompt(
+            DEMAND_GRADE_AGENT_SYSTEM_PROMPT,
+            "demand_grade_agent",
+        ),
         max_iterations=40,
     )
     register_all_tools(agent.tools)

+ 14 - 7
agents/demand_grade_agent/prompt/system_prompt.md

@@ -10,22 +10,29 @@
 - **全局热度**:`category_tree_weight.total_score`。反映该需求所在树节点在类目树里的历史热度排名,是"没有真实上线数据时"的兜底依据。
   - 工具返回中 **`—` 表示无数据**,不是分数为 0。
 - **后验**:`real_rov_7d_avg` + `real_rov_7d_count`(词级工具还会返回 `real_vov_7d`)。
+  - 数值含义:存的是相对全局基线的 **rov_diff / vov_diff**(非绝对 ROV/VOV)。正值表示显著优于全局,负值表示低于全局。
+  - 效果档位(ROV/VOV 各自独立判断,取更差的一侧作为综合后验信号):
+    - `> 0`:**效果非常好**
+    - `[-0.2, 0)`:**可接受**(略低于全局但仍在容忍范围内)
+    - `< -0.2`:**效果不佳**(明显弱于全局)
   - `real_rov_7d_count > 0`:说明该节点/需求已有真实上线验证数据,**这是高置信信息,判级时应优先参考**,可以据此给出全档位(包括 S 或 D)。
   - `real_rov_7d_count = 0`(或数据缺失):说明效果未知,只能用全局热度兜底判断。**无论全局热度多高,都不建议给到 S 级**(因为没有真实验证支撑),一般封顶在 A。
 
 ## 四类证据必须分开
 - **分类节点全局/局部证据**:`category_tree_weight.total_score`、节点整树名次、父节点和全部兄弟节点。它描述需求所在分类环境。
 - **需求自身来源归一分**:`demand_priority.source_rank_score`,范围 0-100。先在每个 strategy 内独立按原始 `weight` 排名归一化,再对该需求已有来源的归一分取均值。它是具体需求之间可比较的先验信号。
-- **需求词后验**:真实 ROV/VOV 及样本数,优先级高于纯先验。
+- **需求词后验**:rov_diff / vov_diff 及样本数,优先级高于纯先验。
 
 严禁把不同 strategy 的原始 `weight` 直接求和或平均;严禁把需求自身 0-100 分与分类树 `total_score` 直接相加,二者不是同一维度。缺少某个来源时不补 0,使用 `valid_source_count` 表达覆盖和置信度。
 
 ## 分级参考准则(非硬编码规则,需结合 `query_score_distribution` 自主定阈值)
 - 建议在每个批次开始时调用一次 `query_score_distribution`,分别参考分类树 total_score、需求自身来源归一分与 real_rov_7d_avg 的分位数(p25/p50/p75/p90)。三类分布必须分别使用,不得共用数值阈值。
 - 有后验数据的需求:
-  - 后验效果处于同类中高位(如 real_rov_7d_avg ≥ p75)→ 可评 S 或 A
-  - 中等 → B
-  - 明显偏低(如低于 p25)→ C 或 D(即使全局热度很高,也应如实按后验降级,说明"热度高但验证效果不佳")
+  - rov_diff / vov_diff **均为正**(效果非常好)→ 可评 S 或 A
+  - 至少一项为正、另一项在 [-0.2, 0)(可接受)→ A 或 B
+  - 两项均在 [-0.2, 0)(可接受但无突出项)→ B
+  - 任一项 **< -0.2**(效果不佳)→ C 或 D(即使全局热度很高,也应如实按后验降级,说明"热度高但验证效果不佳")
+  - 可同时参考 `query_score_distribution` 的后验分位数做同类校正,但**不得用分位数覆盖上述绝对阈值**
 - 无后验数据的需求:
   - 全局热度 total_score 很高(如 ≥ p75)→ A(不给 S,注明"无验证数据")
   - 中等 → B
@@ -43,7 +50,7 @@
 ## 同义/相似需求合并
 同一语义的需求可能因措辞不同而在需求池里表现为多条独立记录(例如「减脂期加餐」与
 「减脂加餐」)。判级前应调用 `search_related_pool_demands(biz_dt, keywords=[...])`
-**批量**搜索本批各需求词,把找到的相关记录一并纳入参考(尤其是它们各自的 weight / real_rov_7d),
+**批量**搜索本批各需求词,把找到的相关记录一并纳入参考(尤其是它们各自的 weight / rov_diff / vov_diff),
 不要只看单条记录就下结论;返回的 `[id=...]` 就是 `multi_demand_pool_di.id`,落库时必须原样
 收集进 `related_pool_ids`(**必填字段**,用于把分级结果关联回原始需求行)。
 
@@ -56,9 +63,9 @@
 ## 可用工具
 - `query_latest_biz_dt()`:若用户消息未给出明确 biz_dt 时调用,返回需求池/权重表/热度统计表各自最新业务日。
 - `search_related_pool_demands(biz_dt, keywords)`:按同名/包含关系搜索需求池,**可一次传入多个 keyword** 批量查找同语义需求;同时返回每条需求的来源内名次、来源归一分和需求自身全日排名。
-- `query_demand_category_and_weight(demand_names, biz_dt=None)`:核心取数工具,**可一次传入多个 demand_name** 批量查询归属树节点 → 全局热度 total_score,及后验 real_rov_7d/real_vov_7d。
+- `query_demand_category_and_weight(demand_names, biz_dt=None)`:核心取数工具,**可一次传入多个 demand_name** 批量查询归属树节点 → 全局热度 total_score,及后验 rov_diff/vov_diff
 - `query_category_path(category_ids)`:查询类目根到叶路径文本,用于写 reason。
-- `query_category_local_heat(biz_dt, category_ids)`:查询节点自身、父节点、**全部兄弟节点**的全局热度 total_score + 后验 real_rov_7d/real_vov_7d,并给出兄弟内排名;判级时必须参考列出的全部相关节点,不得只看部分节点。
+- `query_category_local_heat(biz_dt, category_ids)`:查询节点自身、父节点、**全部兄弟节点**的全局热度 total_score + 后验 rov_diff/vov_diff,并给出兄弟内排名;判级时必须参考列出的全部相关节点,不得只看部分节点。
 - `query_demand_popularity_by_word(demand_word_names, biz_dt=None)`:按需求词粒度直接查后验热度统计,**可一次传入多个词** 交叉验证树节点级结论。
 - `query_score_distribution(biz_dt=None)`:分别查询分类树全局热度、需求自身来源归一分与后验分布,制定跨批次一致标准。
 - `batch_save_demand_grades(items, biz_dt=None)`:批量落库分级结果,可重复调用按 (biz_dt, demand_name) upsert 覆盖修正。`related_pool_ids` 必填,`video_list`/`strategies` 自动推导。

+ 1 - 1
agents/demand_grade_agent/tools/batch_save_demand_grades.py

@@ -206,7 +206,7 @@ def batch_save_demand_grades(items: list[dict[str, Any]], biz_dt: Optional[str]
               即各 strategy 内独立排名归一化后,对该需求已有来源取均值
             - category_ids (可选): 归属的树节点 id 列表,会写入 demand_grade_category_rel 映射表
             - prior_total_score (可选): 落库时的先验 total_score 快照
-            - posterior_rov_avg / posterior_rov_count (可选): 落库时的后验 real_rov_7d 快照
+            - posterior_rov_avg / posterior_rov_count (可选): 落库时的后验 rov_diff 快照(real_rov_7d_avg)
               count>0 时自动标记为「有后验数据」
             - biz_dt (可选): 覆盖本项使用的业务日,不传则用调用时的 biz_dt 参数
         biz_dt: 本次调用的默认业务日期 YYYYMMDD;items 内每项也可单独指定 biz_dt 覆盖。

+ 15 - 3
agents/demand_grade_agent/tools/demand_priority.py

@@ -4,7 +4,8 @@ from __future__ import annotations
 from collections import defaultdict
 from typing import Any
 
-from supply_agent.ranking import rank_with_scores
+from supply_infra.scoring.posterior import format_posterior_value
+from supply_infra.scoring.ranking import rank_with_scores
 
 
 DEMAND_PRIORITY_SCORE_METHOD = {
@@ -103,11 +104,11 @@ def build_demand_priority_index(pool_rows: list[Any]) -> dict[str, dict[str, Any
             "observed_source_count": len(sources),
             "sources": sources,
             "posterior_from_exact_pool_rows": {
-                "real_rov_7d": {
+                "rov_diff": {
                     "value": max(rov_values) if rov_values else None,
                     "has_data": bool(rov_values),
                 },
-                "real_vov_7d": {
+                "vov_diff": {
                     "value": max(vov_values) if vov_values else None,
                     "has_data": bool(vov_values),
                 },
@@ -157,4 +158,15 @@ def format_demand_priority(item: dict[str, Any] | None) -> list[str]:
             f"来源内名次={source_rank_text} 来源归一分={normalized_text}"
         )
     lines.append("  口径:来源内先排名归一化,再对已有来源取均值;禁止直接相加原始 weight。")
+    posterior = item.get("posterior_from_exact_pool_rows") or {}
+    rov = posterior.get("rov_diff") or {}
+    vov = posterior.get("vov_diff") or {}
+    if rov.get("has_data") or vov.get("has_data"):
+        lines.append(
+            "  词级后验:"
+            f"rov_diff={format_posterior_value(rov.get('value'))} "
+            f"vov_diff={format_posterior_value(vov.get('value'))}"
+        )
+    else:
+        lines.append("  词级后验:无验证数据(效果未知)")
     return lines

+ 1 - 1
agents/demand_grade_agent/tools/query_category_local_heat.py

@@ -34,7 +34,7 @@ def _normalize_ids(category_ids: list[Any]) -> tuple[list[int], str | None]:
 def query_category_local_heat(biz_dt: str, category_ids: list[int]) -> str:
     """查询节点自身、父节点、全部兄弟节点的全局热度与后验数据及兄弟内排名。
 
-    只返回 total_score 与 real_rov_7d/real_vov_7d,不包含四维先验明细。
+    只返回 total_score 与后验 rov_diff/vov_diff(real_rov_7d/real_vov_7d,不包含四维先验明细。
     """
     normalized_dt, err = normalize_biz_dt(biz_dt)
     if err:

+ 2 - 2
agents/demand_grade_agent/tools/query_demand_category_and_weight.py

@@ -1,5 +1,5 @@
 """
-核心取数工具:需求名 → 归属树节点 → 该节点的先验热度与后验真实效果
+核心取数工具:需求名 → 归属树节点 → 该节点的先验热度与后验 rov_diff/vov_diff
 """
 from __future__ import annotations
 
@@ -100,7 +100,7 @@ def query_demand_category_and_weight(
     biz_dt: Optional[str] = None,
 ) -> str:
     """
-    需求名 → 归属树节点 → 全局热度 total_score + 后验真实效果(real_rov_7d)
+    需求名 → 归属树节点 → 全局热度 total_score + 后验 rov_diff/vov_diff(real_rov_7d/real_vov_7d 字段)
 
     支持批量传入多个需求名,一次调用返回各词的归属与权重;每段结果前会标注原始 demand_name。
 

+ 4 - 4
agents/demand_grade_agent/tools/query_demand_popularity_by_word.py

@@ -9,7 +9,7 @@ from typing import Optional
 from sqlalchemy.orm import Session
 
 from agents.demand_grade_agent.tools.shared import (
-    format_dim_with_count,
+    format_posterior_dim_with_count,
     normalize_biz_dt,
     normalize_str_list,
 )
@@ -38,8 +38,8 @@ def _query_one_demand_popularity_by_word(
         posterior_note = "有后验数据" if row.real_rov_7d_count > 0 else "无后验数据(效果未知)"
         lines.append(
             f"[biz_dt={row.biz_dt}] {row.demand_word_name}: "
-            f"后验real_rov_7d={format_dim_with_count(row.real_rov_7d_avg, row.real_rov_7d_count)} "
-            f"后验real_vov_7d={format_dim_with_count(row.real_vov_7d_avg, row.real_vov_7d_count)} "
+            f"后验rov_diff={format_posterior_dim_with_count(row.real_rov_7d_avg, row.real_rov_7d_count)} "
+            f"后验vov_diff={format_posterior_dim_with_count(row.real_vov_7d_avg, row.real_vov_7d_count)} "
             f"({posterior_note})"
         )
     return "\n".join(lines)
@@ -66,7 +66,7 @@ def query_demand_popularity_by_word(
     Returns:
         每个 demand_word_name 一段,段首标注 `--- demand_word_name: xxx ---`,例如:
         --- demand_word_name: 减脂期加餐 ---
-        [biz_dt=20260716] 减脂期加餐: 后验real_rov_7d=0.12(n=5) 后验real_vov_7d=0.08(n=5) (有后验数据)
+        [biz_dt=20260716] 减脂期加餐: 后验rov_diff=0.0500(n=5) [效果非常好] 后验vov_diff=-0.0800(n=5) [可接受] (有后验数据)
     """
     normalized_dt, err = normalize_biz_dt(biz_dt)
     if err:

+ 10 - 3
agents/demand_grade_agent/tools/query_score_distribution.py

@@ -9,6 +9,7 @@ from agents.demand_grade_agent.tools.demand_priority import (
     build_demand_priority_index,
 )
 from agents.demand_grade_agent.tools.shared import distribution_summary, normalize_biz_dt, to_float
+from supply_infra.scoring.posterior import POSTERIOR_EFFECT_ACCEPTABLE_FLOOR
 from supply_agent.tools import tool
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
@@ -39,8 +40,9 @@ def query_score_distribution(biz_dt: Optional[str] = None) -> str:
     建议在批量分级任务开始时调用一次,分别参考各自分位数制定本批次统一的分档阈值
     (例如 total_score 前 10% 视为全局热度很高),避免同一批次内多次判断标准漂移。
     需求自身分按来源内 rank 归一后对已有来源取均值,不直接合并跨来源 raw weight;
-    后验 real_rov_7d_avg 的分布只统计 real_rov_7d_count>0(有真实验证数据)的子集,
-    因为无验证数据的行 avg 无意义。
+    后验 real_rov_7d_avg / real_vov_7d_avg 存的是相对全局的 rov_diff / vov_diff;
+    分布只统计 real_rov_7d_count>0(有真实验证数据)的子集,因为无验证数据的行 avg 无意义。
+    判级绝对阈值:>0 效果非常好,[-0.2,0) 可接受,<-0.2 效果不佳。
 
     Args:
         biz_dt: 业务日期 YYYYMMDD,可选;不传则使用 category_tree_weight 最新业务日。
@@ -103,10 +105,15 @@ def query_score_distribution(biz_dt: Optional[str] = None) -> str:
 
         lines.append(
             _format_dist(
-                f"后验real_rov_7d_avg(仅count>0子集,共{len(posterior_values)}个节点有验证数据)",
+                f"后验rov_diff_avg(仅count>0子集,共{len(posterior_values)}个节点有验证数据)",
                 distribution_summary(posterior_values),
             )
         )
+        lines.append(
+            "后验效果档位:"
+            f">0 效果非常好;[{POSTERIOR_EFFECT_ACCEPTABLE_FLOOR}, 0) 可接受;"
+            f"<{POSTERIOR_EFFECT_ACCEPTABLE_FLOOR} 效果不佳(ROV/VOV 各自独立判断)。"
+        )
         lines.append(
             _format_dist(
                 "需求自身来源归一分(0-100,来源内排名后对已有来源取均值)",

+ 5 - 4
agents/demand_grade_agent/tools/search_related_pool_demands.py

@@ -12,6 +12,7 @@ from agents.demand_grade_agent.tools.demand_priority import (
     format_demand_priority,
 )
 from agents.demand_grade_agent.tools.shared import (
+    format_posterior_value,
     format_score,
     normalize_biz_dt,
     normalize_str_list,
@@ -39,13 +40,13 @@ def _search_one_related_pool_demands(
         lines.append(f"需求自身证据「{demand_name}」:")
         lines.extend(format_demand_priority(priority_index.get(demand_name)))
     for row in rows:
-        rov = format_score(row["real_rov_7d"])
-        vov = format_score(row["real_vov_7d"])
+        rov = format_posterior_value(row["real_rov_7d"])
+        vov = format_posterior_value(row["real_vov_7d"])
         weight = format_score(row["weight"])
         video_count = row["video_count"] if row["video_count"] is not None else "—"
         lines.append(
             f"[id={row['id']}|{row['strategy']}|weight={weight}"
-            f"|视频数={video_count}|真实ROV={rov}|真实VOV={vov}] {row['demand_name']}"
+            f"|视频数={video_count}|rov_diff={rov}|vov_diff={vov}] {row['demand_name']}"
         )
     return "\n".join(lines)
 
@@ -69,7 +70,7 @@ def search_related_pool_demands(biz_dt: str, keywords: list[str]) -> str:
     Returns:
         每个 keyword 一段,段首标注 `--- keyword: xxx ---`,例如:
         --- keyword: 加餐 ---
-        [id=101|strategy_a|weight=3.20|视频数=12|真实ROV=0.0410|真实VOV=0.0021] 减脂期加餐怎么吃
+        [id=101|strategy_a|weight=3.20|视频数=12|rov_diff=0.0500 [效果非常好]|vov_diff=-0.0800 [可接受]] 减脂期加餐怎么吃
     """
     normalized, err = normalize_biz_dt(biz_dt)
     if err:

+ 10 - 4
agents/demand_grade_agent/tools/shared.py

@@ -5,6 +5,12 @@ import json
 from decimal import Decimal
 from typing import Any
 
+from supply_infra.scoring.posterior import (
+    POSTERIOR_EFFECT_ACCEPTABLE_FLOOR,
+    classify_posterior_effect,
+    format_posterior_dim_with_count,
+    format_posterior_value,
+)
 from supply_infra.db.models.global_tree_category import GlobalTreeCategory
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 
@@ -107,10 +113,10 @@ def format_category_weight_lines(
         f"  biz_dt={detail.get('biz_dt') or biz_dt or '—'}  hung_word_count={hung_word_count}",
         f"  全局热度total_score={format_score(global_heat['total_score'])}",
         (
-            "  后验real_rov_7d="
-            f"{format_dim_with_count(posterior['real_rov_7d']['avg'], posterior['real_rov_7d']['count'])} "
-            f"后验real_vov_7d="
-            f"{format_dim_with_count(posterior['real_vov_7d']['avg'], posterior['real_vov_7d']['count'])} "
+            "  后验rov_diff="
+            f"{format_posterior_dim_with_count(posterior['real_rov_7d']['avg'], posterior['real_rov_7d']['count'])} "
+            f"后验vov_diff="
+            f"{format_posterior_dim_with_count(posterior['real_vov_7d']['avg'], posterior['real_vov_7d']['count'])} "
             f"({posterior_note})"
         ),
     ]

+ 3 - 2
agents/demand_grade_agent/tools/tree_local.py

@@ -10,7 +10,7 @@ from agents.demand_grade_agent.tools.shared import (
     build_category_path,
     format_category_weight_lines,
 )
-from supply_agent.ranking import rank_with_scores
+from supply_infra.scoring.ranking import rank_with_scores
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.session import get_session
@@ -242,7 +242,8 @@ def render_local_heat_report(biz_dt: str, category_ids: list[int]) -> str:
     if not snapshots:
         return "category_ids 不能为空"
     lines = [
-        f"biz_dt={biz_dt} | 局部环境包含节点自身、父节点、全部兄弟节点的全局热度 total_score 与后验数据;",
+        f"biz_dt={biz_dt} | 局部环境包含节点自身、父节点、全部兄弟节点的全局热度 total_score 与后验 rov_diff/vov_diff;",
+        "后验为相对全局 diff:>0 效果非常好、[-0.2,0) 可接受、<-0.2 效果不佳;",
         "每个节点同时给出整棵树名次;不得只参考附近节点,也不得只参考部分兄弟节点。",
     ]
     for snapshot in snapshots:

+ 0 - 134
agents/demand_grade_orchestrator_agent/_verify_logic.py

@@ -1,134 +0,0 @@
-"""统筹 Agent 逻辑与工具自检。"""
-from __future__ import annotations
-
-import json
-import sys
-from unittest.mock import patch
-
-from agents.demand_grade_orchestrator_agent.common.assignment import (
-    MAX_DAILY_BATCHES,
-    dedupe_cross_group_category_ids,
-    strip_assigned_category_ids,
-)
-from agents.demand_grade_orchestrator_agent.common.plan_record import prepare_grade_groups
-from agents.demand_grade_orchestrator_agent.run import _summarize_agent_saves
-from supply_agent.types import Message, Role
-
-
-def _ok(name: str) -> None:
-    print(f"  ✓ {name}")
-
-
-def test_prepare_grade_groups_permissive() -> None:
-    fake_by_id = {
-        101: type("C", (), {"id": 101, "name": "A", "parent_id": None, "level": 1})(),
-        102: type("C", (), {"id": 102, "name": "B", "parent_id": None, "level": 1})(),
-    }
-    fake_weights = {
-        101: type("W", (), {"category_id": 101, "total_score": 0.9, "hung_word_count": 5})(),
-        102: type("W", (), {"category_id": 102, "total_score": 0.8, "hung_word_count": 0})(),
-    }
-    groups = [
-        {"category_ids": [101, 102, 999], "batch_heat_level": "X", "planning_reason": "", "shared_traits": ""},
-        {"category_ids": [101], "batch_heat_level": "A", "planning_reason": "dup", "shared_traits": "dup"},
-    ]
-    with patch(
-        "agents.demand_grade_orchestrator_agent.common.plan_record.load_tree_state",
-        return_value=(fake_by_id, {None: [101, 102]}, fake_weights),
-    ), patch(
-        "agents.demand_grade_orchestrator_agent.common.plan_record.global_heat_positions",
-        return_value={101: {"rank": 1, "total": 1, "normalized_score": 0.95}},
-    ), patch(
-        "agents.demand_grade_orchestrator_agent.common.plan_record.has_hung_demand",
-        side_effect=lambda w: w is not None and int(w.hung_word_count or 0) > 0,
-    ), patch(
-        "agents.demand_grade_orchestrator_agent.common.plan_record.path",
-        side_effect=lambda cid, _by: f"path-{cid}",
-    ), patch(
-        "agents.demand_grade_orchestrator_agent.common.plan_record.heat_level",
-        return_value="A",
-    ):
-        prepared = prepare_grade_groups(
-            "20260714",
-            "策略",
-            groups,
-            assigned_category_ids=set(),
-        )
-    assert len(prepared["groups"]) == 1
-    assert prepared["groups"][0]["category_ids"] == [101]
-    assert prepared["groups"][0]["batch_heat_level"] == "A"
-    assert prepared["groups"][0]["planning_reason"]
-    _ok("无需求/非法字段不报错,仅过滤后入库")
-
-
-def test_dedupe_cross_group() -> None:
-    plan = {"groups": [{"category_ids": [1, 2]}, {"category_ids": [2, 3]}]}
-    removed = dedupe_cross_group_category_ids(plan)
-    assert removed == [2] and plan["groups"][1]["category_ids"] == [3]
-    _ok("后批重复节点过滤")
-
-
-def test_summarize_agent_saves() -> None:
-    class Result:
-        messages = [
-            Message(
-                role=Role.TOOL,
-                name="save_grade_plan",
-                content=json.dumps({"ok": True, "persisted": True, "persisted_group_count": 2}),
-            ),
-        ]
-
-    summary = _summarize_agent_saves(Result())
-    assert summary["save_count"] == 1 and summary["persisted_groups"] == 2
-    _ok("统计入库结果")
-
-
-def test_save_grade_plan_db(biz_dt: str) -> None:
-    from agents.demand_grade_orchestrator_agent.tools.save_grade_plan import save_grade_plan
-    from agents.demand_grade_orchestrator_agent.common.assignment import resolve_planning_state
-
-    state = resolve_planning_state(biz_dt)
-    if not state["unassigned_category_ids"] or state["remaining_batch_quota"] <= 0:
-        print("  · save_grade_plan 跳过(无待分配或额度已满)")
-        return
-
-    cid = state["unassigned_category_ids"][0]
-    with patch(
-        "agents.demand_grade_orchestrator_agent.tools.save_grade_plan.persist_groups_one_by_one",
-        return_value={
-            "persisted_group_count": 1,
-            "persisted_groups": [{"category_ids": [cid]}],
-            "skipped_quota": 0,
-            "skipped_empty": 0,
-            "failed_groups": [],
-            "existing_groups": 1,
-            "remaining_batch_quota": MAX_DAILY_BATCHES - 1,
-            "unassigned_category_ids": state["unassigned_category_ids"][1:],
-            "coverage_complete": False,
-            "total_hanging_nodes": state["total_hanging_nodes"],
-        },
-    ):
-        result = json.loads(
-            save_grade_plan(
-                biz_dt,
-                "自检",
-                [{"category_ids": [cid, cid, 999999], "batch_heat_level": "Z"}],
-            )
-        )
-    assert result["ok"] is True and result["persisted"] is True
-    _ok("save_grade_plan 宽松校验 + 逐批入库路径")
-
-
-def main() -> None:
-    biz_dt = sys.argv[1] if len(sys.argv) > 1 else "20260714"
-    print("=== 统筹 Agent 逻辑自检 ===\n[单元测试]")
-    test_prepare_grade_groups_permissive()
-    test_dedupe_cross_group()
-    test_summarize_agent_saves()
-    print("\n[DB 集成检测]")
-    test_save_grade_plan_db(biz_dt)
-    print("\n全部通过。")
-
-
-if __name__ == "__main__":
-    main()

+ 10 - 2
agents/demand_grade_orchestrator_agent/common/tree_state.py

@@ -5,7 +5,7 @@ from collections import defaultdict
 from types import SimpleNamespace
 from typing import Any
 
-from supply_agent.ranking import rank_with_scores
+from supply_infra.scoring.ranking import rank_with_scores
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.session import get_session
@@ -18,7 +18,15 @@ def load_tree_state(biz_dt: str) -> tuple[dict[int, Any], dict[int | None, list[
             for row in GlobalTreeCategoryRepository(session).list_active_categories()
         ]
         weights = [
-            SimpleNamespace(category_id=int(row.category_id), total_score=row.total_score, hung_word_count=row.hung_word_count)
+            SimpleNamespace(
+                category_id=int(row.category_id),
+                total_score=row.total_score,
+                hung_word_count=row.hung_word_count,
+                real_rov_7d_avg=row.real_rov_7d_avg,
+                real_rov_7d_count=row.real_rov_7d_count,
+                real_vov_7d_avg=row.real_vov_7d_avg,
+                real_vov_7d_count=row.real_vov_7d_count,
+            )
             for row in CategoryTreeWeightRepository(session).list_by_biz_dt(biz_dt)
         ]
     by_id = {row.id: row for row in categories}

+ 3 - 1
agents/demand_grade_orchestrator_agent/prompt/system_prompt.md

@@ -19,7 +19,9 @@
 - 相邻/相似节点优先在同一热度等级时合批,避免一个极热节点把冷节点所在批次整体抬高。
 - 下游会逐组从数据库读取节点下的待分级需求;不要反过来根据需求名称拼凑批次。
 - 热度为空或样本数为 0 是数据不足,不是低热;在 `planning_reason` 中明确说明。
-- 不自行评级。只依据工具真实返回的路径、节点和分数。
+- 下游分级 Agent 会结合节点/词级的 **rov_diff / vov_diff**(相对全局基线,非绝对 ROV/VOV)判级:
+  - `> 0` 效果非常好;`[-0.2, 0)` 可接受;`< -0.2` 效果不佳。
+- 你规划批次时仍以先验 `total_score` 为主;`query_heat_node_group` 在节点有后验样本时会附带 rov_diff/vov_diff 供参考。
 
 ## groups 提交格式
 

+ 10 - 3
agents/demand_grade_orchestrator_agent/tools/query_heat_node_group.py

@@ -11,6 +11,7 @@ from agents.demand_grade_orchestrator_agent.common import (
     load_tree_state,
     path,
 )
+from supply_infra.scoring.posterior import format_posterior_pair
 from supply_agent.tools import tool
 
 
@@ -21,8 +22,9 @@ def query_heat_node_group(biz_dt: str, category_ids: list[int]) -> str:
     positions = global_heat_positions(weights)
     unassigned_nodes = get_unassigned_hanging_category_ids(biz_dt)
     lines = [
-        f"biz_dt={biz_dt} | 格式:[分类ID]分类名称[total_score|整树名次|热度等级] + | "
-        "名次在整棵有分节点中计算;无数据为 null/U;+ 仅表示未分批且有挂载需求"
+        f"biz_dt={biz_dt} | 格式:[分类ID]分类名称[total_score|整树名次|热度等级] + [| rov_diff/vov_diff] | "
+        "名次在整棵有分节点中计算;无数据为 null/U;+ 仅表示未分批且有挂载需求;"
+        "后验为相对全局 diff:>0 优、[-0.2,0) 可接受、<-0.2 不佳"
     ]
 
     def describe(category_id: int) -> str:
@@ -31,9 +33,14 @@ def query_heat_node_group(biz_dt: str, category_ids: list[int]) -> str:
         position = positions.get(category_id)
         is_unassigned = category_id in unassigned_nodes
         suffix = " +" if is_unassigned and has_hung_demand(weight) else ""
+        posterior = ""
+        if weight is not None and int(getattr(weight, "real_rov_7d_count", 0) or 0) > 0:
+            posterior = (
+                f" | {format_posterior_pair(weight.real_rov_7d_avg, weight.real_rov_7d_count, weight.real_vov_7d_avg, weight.real_vov_7d_count)}"
+            )
         return (
             f"[{category_id}]{category.name or ''}"
-            f"[{format_heat_score(weight)}|{format_rank(position)}|{heat_level(position)}]{suffix}"
+            f"[{format_heat_score(weight)}|{format_rank(position)}|{heat_level(position)}]{suffix}{posterior}"
         )
 
     for category_id in dict.fromkeys(int(value) for value in category_ids):

+ 5 - 1
agents/demand_video_expand_agent/agent.py

@@ -8,6 +8,7 @@ from pathlib import Path
 from supply_agent import Agent
 from supply_agent.config import Settings
 from agents.demand_video_expand_agent.tools import register_all_tools
+from supply_infra.agent_prompt_injection import compose_agent_system_prompt
 
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_VIDEO_EXPAND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
@@ -23,7 +24,10 @@ def create_demand_video_expand_agent(
         settings=settings,
         name="demand_video_expand_agent",
         model=model,
-        system_prompt=DEMAND_VIDEO_EXPAND_AGENT_SYSTEM_PROMPT,
+        system_prompt=compose_agent_system_prompt(
+            DEMAND_VIDEO_EXPAND_AGENT_SYSTEM_PROMPT,
+            "demand_video_expand_agent",
+        ),
         max_iterations=10,
     )
     register_all_tools(agent.tools)

+ 331 - 0
agents/find_agent/PRD.md

@@ -0,0 +1,331 @@
+# find_agent 产品需求文档(PRD)
+
+> 文档版本:v1.2
+>
+> 基线日期:2026-07-28
+>
+> Agent 定位:老年受众高潜抖音视频发现 Agent
+
+## 1. 文档目的与边界
+
+本文只描述 `find_agent` 本身:
+
+- 当前职责、输入、输出和能力;
+- 当前使用的工具、判断规则和运行约束;
+- 当前存在的问题及需要优化的点。
+
+本文不描述 SupplyAgent 的整体业务流程,不涉及需求分级、日批调度、跨 Agent 协作、
+下游生产发布、业务里程碑或平台级建设。
+
+当前实现依据:
+
+- Agent 组装:[agent.py](agent.py)
+- 核心提示词:[prompt/system_prompt.md](prompt/system_prompt.md)
+- 工具注册:[tools/__init__.py](tools/__init__.py)
+- 对外调用:[__init__.py](__init__.py)
+
+## 2. Agent 定位
+
+`find_agent` 根据一条明确的内容需求,从抖音候选中寻找同时满足以下条件的视频:
+
+1. 与需求真实意图相关;
+2. 有证据支持其受众偏向较高年龄段;
+3. 具备可解释的分享价值。
+
+Agent 对搜索词生成、候选补证、评分解释和最终分池负责。它不生产或改写视频,也不负责
+需求优先级、任务调度、内容发布及其他 Agent 的行为。
+
+当前版本明确不使用视频画面、语音、字幕或多模态理解。相关性与分享动机仅依据标题、
+描述、话题、详情文本、互动数据和受众画像判断。
+
+## 3. 输入与输出
+
+### 3.1 输入
+
+| 字段 | 必需性 | 含义 |
+|---|---|---|
+| `demand_word` | 必需 | 本次找片的需求词和意图边界 |
+| `seed_video_title` | 可选 | 已知相关视频标题,用于消除需求歧义 |
+| `relevant_points` | 可选 | 参考视频中与需求相关的灵感、目的或关键点 |
+| `reference_videos` | 可选 | 多个参考视频及各自相关点 |
+| `run_id` | 可选 | 已创建的发现运行标识;存在时必须复用 |
+
+输入信息不足时,Agent 可以继续搜索,但必须降低意图判断的置信度,不能用模型常识补全
+未提供的业务要求。
+
+### 3.2 输出
+
+Agent 最终输出以下内容:
+
+1. 一句话需求意图理解;
+2. `primary` 主推荐;
+3. `rejected` 淘汰候选及淘汰原因;
+4. Agent 实际执行的搜索记录;
+5. 缺失数据、画像冲突、未继续搜索项和接口错误。
+
+每条主推荐至少包含:
+
+- 标题、作者、抖音页面链接和 `aweme_id`;
+- 命中的需求点及相关性证据;
+- 原始分享数及可计算的分享效率;
+- 视频点赞用户年龄画像;
+- 作者粉丝年龄画像;
+- 分享动机;
+- `R / E / S / V` 整数分、置信度和主要限制。
+
+最终分池只允许 `primary / rejected`。`pending_evaluation` 仅是处理中的临时状态,不得
+出现在最终结果中。
+
+## 4. 当前能力
+
+### 4.1 需求理解与搜索规划
+
+- 综合需求词、参考标题和相关点解释真实意图;
+- 默认生成 2~3 个语义不同的根搜索词;
+- 搜索词不要求逐字复用 `demand_word`;
+- 可根据高潜候选的话题、标题实体、作者和分页状态继续扩展;
+- 以新增有效候选和潜在信息价值决定是否继续搜索。
+
+### 4.2 候选召回
+
+- 支持内部抖音关键词搜索;
+- 支持 TikHub 独立搜索和分页;
+- 支持按作者扩展最热或最新作品;
+- 不同搜索词、页面和来源的候选按 `aweme_id` 去重;
+- TikHub 不可用时可退回内部搜索,并保留错误原因;
+- 每次搜索结果均可保存查询词、形成原因、分页状态和父搜索信息。
+
+### 4.3 候选证据补全
+
+- 批量获取视频详情,核验标题、作者、话题、链接和互动数据;
+- 获取视频点赞用户画像;
+- 获取作者粉丝年龄画像;
+- 标准化不同年龄桶表达;
+- 记录画像缺失、接口失败及视频画像与作者画像冲突;
+- 先使用搜索结果进行低成本预筛,再为高潜候选补充详情和画像。
+
+当前没有以下证据:
+
+- 视频真实转发用户年龄画像;
+- 分年龄曝光、播放、完播和观看时长;
+- 视频画面、语音或字幕理解结果;
+- 同题材、相近发布时间下的标准化传播基线。
+
+### 4.4 评分与分池
+
+Agent 对每个候选独立判断:
+
+- `R`:需求相关性;
+- `E`:老年受众倾向;
+- `S`:分享价值。
+
+综合价值为:
+
+`V = 100 × R^0.40 × E^0.35 × S^0.25`
+
+`V` 用于保持排序一致,不替代证据判断。只有 `R / E / S` 三项均成立的候选才能进入
+`primary`;任一项不成立时进入 `rejected`。
+
+当前分池由模型根据提示词和证据作出,保存工具只保存结果,不重新计算分数或改变分池。
+
+### 4.5 状态保存与完成控制
+
+- 创建或复用 `run_id`;
+- 保存每个搜索页和去重后的候选;
+- 批量保存候选证据、评分、理由和分池;
+- 查询已保存的搜索与候选状态;
+- 通过数据库审计检查搜索、证据、评估和最终状态;
+- completion guard 强制最后阶段满足:
+
+`搜索页已保存 → 证据已获取 → 评估已保存 → 审计通过 → 查询最终状态 → 输出报告`
+
+最终报告只读取数据库最终状态,不通过文字反向修改候选分池。报告中的主推荐和淘汰
+候选必须与数据库状态一致。
+
+当 Agent 结合任务上下文、替代方案和工具反馈判断任务已经无法继续时,应停止无效重试,
+输出以 `任务未完成(工具故障)` 开头的失败摘要。完成守卫只识别该声明,不解析工具
+返回结构或错误文案,也不替 Agent 判断错误是否可恢复。
+
+## 5. 当前判断规则
+
+### 5.1 需求相关性是准入条件
+
+- 搜索词命中不等于内容相关;
+- 高分享或受众偏老不能弥补低相关;
+- 参考标题和相关点用于理解意图,不要求候选逐字匹配。
+
+### 5.2 年龄证据按强度使用
+
+证据优先级为:
+
+1. 视频点赞用户年龄画像;
+2. 作者粉丝年龄画像;
+3. 标题、描述、话题和详情文本体现的内容适配特征;
+4. 题材、人物或作者形象带来的直觉。
+
+第 4 类不能单独支持老年倾向。视频画像与作者画像冲突时,以视频画像为主并降低置信度。
+明确覆盖 50 岁及以上的年龄桶才属于直接老年信号;只有 40 岁以上数据时,只能表述为
+成熟人群代理信号。
+
+### 5.3 分享证据与年龄证据不能互相替代
+
+- `share_count` 说明传播规模,不说明分享者年龄;
+- 点赞用户或作者粉丝年龄画像说明受众倾向,不说明已经发生转发;
+- 当前只能推断“老年人可能愿意分享”,不能声称已观察到老年分享者;
+- 分享价值同时参考分享规模、分享效率和内容动机。
+
+### 5.4 缺失不是负证据
+
+- 数据缺失或接口失败应标记为未知;
+- 未知会降低置信度,但不自动计为零分;
+- 强反证优先于多个弱正向线索;
+- 不得为了达到推荐数量而降低准入标准。
+
+### 5.5 推荐集合需要新增价值
+
+- 高度重复的视频只保留证据更强的一条;
+- 价值接近时优先覆盖不同需求点和分享动机;
+- 合理搜索后允许少于 5 条或返回空结果。
+
+## 6. 当前工具
+
+| 工具 | 当前用途 | 关键限制 |
+|---|---|---|
+| `douyin_search` | 内部关键词召回 | 搜索结果不是最终事实 |
+| `douyin_search_tikhub` | TikHub 搜索与分页 | 翻页必须复用供应方分页参数 |
+| `douyin_user_videos` | 扩展作者作品 | 作品仍需逐条补证和判断 |
+| `douyin_detail` | 批量核验视频详情 | 单次最多 8 条 |
+| `get_content_fans_portrait` | 获取单条视频点赞用户画像 | 不是分享用户画像 |
+| `get_account_fans_portrait` | 获取作者粉丝画像 | 只代表账号受众先验 |
+| `batch_fetch_portraits` | 批量获取视频和作者画像 | 单次最多 8 条 |
+| `normalize_age_portraits` | 标准化年龄桶 | 不负责业务评分 |
+| `create_video_discovery_run` | 创建或复用运行状态 | 传入 `run_id` 时必须复用 |
+| `record_video_search_page` | 保存搜索页并合并候选 | 每次搜索后均需调用 |
+| `batch_save_video_candidate_evaluations` | 保存证据、评分和分池 | 只接受 `primary / rejected` |
+| `audit_video_discovery_run` | 审计 Agent 最终状态 | `can_finish=true` 才能完成 |
+| `query_video_discovery_state` | 恢复或读取最终状态 | 最终查询必须晚于成功审计 |
+
+`qwen_video_analyze` 未注册,不属于当前 Agent 能力。
+
+## 7. 当前运行约束
+
+| 项目 | 当前设置 |
+|---|---|
+| 模型 | 固定为 `google/gemini-3-flash-preview` |
+| 最大迭代轮次 | 60 |
+| 温度 | 0.2 |
+| 单批详情/画像候选数 | 最多 8 条 |
+
+`create_find_agent(..., model=...)` 和 `run_find_agent(..., model=...)` 虽然暴露了模型参数,
+当前工厂仍固定使用上述模型,传入参数不会生效。
+
+## 8. 当前实现评价
+
+| 能力 | 状态 | 当前判断 |
+|---|---|---|
+| 需求理解 | 可用 | 能结合需求词、参考标题和相关点形成搜索假设 |
+| 多源搜索 | 可用 | 内部搜索、TikHub 和作者作品均已注册 |
+| 候选去重 | 可用 | 支持跨词、跨页和跨来源按 `aweme_id` 合并 |
+| 详情与画像 | 可用但有缺口 | 支持详情、双侧画像和年龄标准化,画像可能缺失 |
+| 内容理解 | 受限 | 仅使用文本、互动和画像,不使用视频理解 |
+| 评分与分池 | 部分可用 | 规则完整,但主要依赖模型遵守长提示词 |
+| 状态保存 | 可用 | 可保存运行、搜索页和候选状态 |
+| 完成审计 | 业务侧 | 由工具 `audit_video_discovery_run` + `run_outcome` 判定;框架无完成守卫 |
+| 故障终止 | 已接入 | Agent 明确声明工具故障时允许失败结束 |
+| 最终一致性 | 部分可用 | 报告只读并接受校验,尚无单一事务性最终化入口 |
+| 运行恢复 | 部分可用 | 能查询旧状态,但缺少执行代次和自动恢复机制 |
+| 可观测性 | 不完整 | 有日志和基础计数,缺少 Agent 级质量与成本指标 |
+
+## 9. 需要优化的点
+
+### 9.1 P0:提高 Agent 的确定性与一致性
+
+1. **增加事务性最终化**
+   - 新增单一 `finalize_video_discovery_run` 能力;
+   - 仅允许最终化当前运行已召回且已评估的候选;
+   - 在一个事务内校验并写入候选分池、计数、完成状态和最终摘要;
+   - 最终化失败必须显式返回错误并支持安全重试。
+
+2. **将关键约束从长提示词下沉到程序**
+   - 用结构化节点控制搜索、补证、评估、审计和最终输出;
+   - 对必需证据、合法状态和工具调用顺序做程序校验;
+   - 保留 LLM 对意图、搜索词、语义相关性和解释的判断空间。
+
+3. **结构化模型输出**
+   - 为搜索计划、候选评估和最终结果定义 JSON Schema;
+   - 校验 `R / E / S` 取值、必填证据、置信度和分池;
+   - 由程序计算 `V`,避免模型计算漂移;
+   - 禁止模型输出未召回的候选 ID。
+
+4. **修复模型配置**
+   - 让显式传入的 `model` 参数真正生效;
+   - 保存实际使用的模型、Prompt 和评分策略版本;
+   - 避免固定依赖预览模型。
+
+5. **补齐 Agent 契约测试**
+   - 覆盖未保存搜索页、证据晚于评估、审计失败、旧状态查询和报告分池不一致;
+   - 覆盖幻觉候选 ID、非法分池、缺失证据;
+   - 用固定样例验证相关性闸门、年龄证据层级和未知数据处理。
+
+### 9.2 P1:提高搜索与判断质量
+
+1. **优化搜索前沿选择**
+   - 结构化记录每个搜索假设的预期价值、实际新增候选和耗时;
+   - 根据边际新增率决定翻页、标签扩展和作者扩展;
+   - 减少同义词重复搜索及低价值工具调用。
+
+2. **优化候选预筛与补证**
+   - 将低成本预筛规则结构化;
+   - 优先为可能改变分池的候选补充详情和画像;
+   - 对重复详情和画像增加短期缓存;
+   - 记录每项缺失证据对置信度的具体影响。
+
+3. **建立评分评测集**
+   - 建立覆盖不同题材的人工标注样例;
+   - 分别评估 `R / E / S`,避免只看最终分池;
+   - 统计 `primary / rejected` 混淆情况;
+   - 按模型、Prompt 和策略版本回放对比。
+
+4. **增强 Agent 可观测性**
+   - 记录搜索新增率、候选漏斗、证据缺失率、工具失败率和单次调用成本;
+   - 区分模型判断失败、外部接口失败、持久化失败和完成校验失败;
+   - 输出本次使用的模型、Prompt、策略和数据源版本。
+
+5. **改进运行恢复**
+   - 为同一 `run_id` 增加执行代次;
+   - 恢复时明确区分已完成步骤、可复用证据和需要重试的失败项;
+   - 避免新一轮执行混用已过期的搜索或候选判断。
+
+### 9.3 P2:升级 Agent 可用证据
+
+1. 接入真实转发用户年龄分布和样本量;
+2. 接入分年龄曝光、有效播放、完播率和观看时长;
+3. 建立同题材、同发布时间窗口的传播基线;
+4. 增加评论中的提醒、家庭沟通、收藏和求链接等结构化意图信号;
+5. 在获得明确授权和完整验证前,继续保持“不使用视频理解”的能力边界;
+6. 将新证据的计算规则、置信度上限和冲突处理策略版本化。
+
+## 10. Agent 验收标准
+
+一次 `find_agent` 运行满足以下条件,才视为 Agent 自身完成:
+
+1. 已创建或复用唯一 `run_id`;
+2. 实际调用的搜索页均已保存;
+3. 候选已按 `aweme_id` 去重;
+4. 主推荐已尝试获取详情和双侧年龄画像;
+5. 年龄画像已标准化,缺失和冲突已披露;
+6. 每个已评估候选具有合法分池及对应理由;
+7. 最终不存在 `pending_evaluation` 或其他非法分池;
+8. 数据库审计返回 `can_finish=true`;
+9. 审计后已重新查询最终状态;
+10. 最终报告与数据库 `primary / rejected` 完全一致;
+11. 不使用未注册的视频理解能力或编造缺失证据;
+12. 推荐不足 5 条时不降低质量标准。
+
+若工具故障导致任务无法继续,则不适用上述成功条件,但必须满足:
+
+1. Agent 已根据上下文判断继续调用无法产生有效进展;
+2. Agent 已停止重复调用失败工具;
+3. 最终摘要明确标记 `任务未完成(工具故障)`;
+4. 摘要包含失败工具、原始错误、已完成内容和未完成内容;
+5. 不输出看似有效的推荐结果。

+ 135 - 0
agents/find_agent/README.md

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

+ 119 - 0
agents/find_agent/VALIDATION.md

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

+ 49 - 3
agents/find_agent/__init__.py

@@ -1,8 +1,54 @@
 """
-find_agent — 内容发现 Agent
+find_agent — 老年受众高潜视频发现 Agent
 
-职责:抖音搜索、视频解析、内容入库
+职责:按需求搜索抖音视频,结合内容、分享行为和双侧年龄画像进行筛选
 """
+from __future__ import annotations
+
+import logging
+from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
+
 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.runtime import find_agent_timeout_seconds
+from supply_agent.config import Settings
+from supply_agent.types import AgentResult
+
+__all__ = ["create_find_agent", "run_find_agent"]
+
+logger = logging.getLogger(__name__)
+
+_PUBLISH_TIMEOUT_SECONDS = 120.0
+
+
+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,
+            timeout_seconds=find_agent_timeout_seconds(),
+        )
+    )
+    try:
+        with ThreadPoolExecutor(max_workers=1) as publish_executor:
+            publish_future = publish_executor.submit(agent._finish_run, result)
+            publish_future.result(timeout=_PUBLISH_TIMEOUT_SECONDS)
+    except FuturesTimeoutError:
+        logger.error(
+            "find_agent publish timed out after %.0fs; continuing without blocking worker",
+            _PUBLISH_TIMEOUT_SECONDS,
+        )
+    except Exception:
+        logger.exception("find_agent publish failed")
+    return result

+ 7 - 13
agents/find_agent/agent.py

@@ -6,22 +6,14 @@ find_agent 工厂 — 组装 Agent 实例。
 """
 from __future__ import annotations
 
+from pathlib import Path
+
 from supply_agent import Agent
 from supply_agent.config import Settings
 from agents.find_agent.tools import register_all_tools
 
-FIND_AGENT_SYSTEM_PROMPT = """\
-你是内容发现助手(find_agent),帮助用户搜索和分析短视频内容。
-
-## 工作流程
-1. 使用 douyin_search 搜索抖音视频
-2. 使用 douyin_detail 根据 aweme_id 批量获取详情与真实播放链接 video_url
-3. 使用 qwen_video_analyze 解析视频内容
-4. 使用 save_video_content 将结果存入数据库
-5. 使用 query_video_content 查询历史数据
-
-请按步骤执行,给出清晰的分析报告。
-"""
+_PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
+FIND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 
 
 def create_find_agent(
@@ -33,8 +25,10 @@ def create_find_agent(
     agent = Agent(
         settings=settings,
         name="find_agent",
-        model=model,
+        model=model or "google/gemini-3-flash-preview",
         system_prompt=FIND_AGENT_SYSTEM_PROMPT,
+        max_iterations=60,
+        temperature=0.2,
     )
 
     # 本 Agent 专属工具

+ 131 - 0
agents/find_agent/async_runner.py

@@ -0,0 +1,131 @@
+"""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__)
+
+_ASYNC_CLEANUP_TIMEOUT_SECONDS = 5.0
+
+
+async def _drain_event_loop() -> None:
+    """Bound cleanup of httpx/OpenAI background tasks."""
+    loop = asyncio.get_running_loop()
+    pending = {
+        task
+        for task in asyncio.all_tasks(loop)
+        if task is not asyncio.current_task() and not task.done()
+    }
+    if not pending:
+        return
+
+    done, still_pending = await asyncio.wait(
+        pending,
+        timeout=_ASYNC_CLEANUP_TIMEOUT_SECONDS,
+    )
+    for task in done:
+        if task.cancelled():
+            continue
+        error = task.exception()
+        if error is not None:
+            logger.debug("pending async task error during drain: %s", error)
+    if still_pending:
+        logger.warning(
+            "Cancelling %d async cleanup task(s) after %.0fs",
+            len(still_pending),
+            _ASYNC_CLEANUP_TIMEOUT_SECONDS,
+        )
+        for task in still_pending:
+            task.cancel()
+        await asyncio.wait(still_pending, timeout=1.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 asyncio.wait_for(
+                async_client.close(),
+                timeout=_ASYNC_CLEANUP_TIMEOUT_SECONDS,
+            )
+        except TimeoutError:
+            logger.warning(
+                "close async llm client timed out after %.0fs",
+                _ASYNC_CLEANUP_TIMEOUT_SECONDS,
+            )
+        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,
+    *,
+    timeout_seconds: float,
+) -> AgentResult:
+    """在单个事件循环内运行 find_agent 核心循环并确保资源释放(不含 OSS 发布)。"""
+    try:
+        return await asyncio.wait_for(
+            agent.arun_core(user_input),
+            timeout=timeout_seconds,
+        )
+    except TimeoutError:
+        logger.error("find_agent timed out after %.0fs", timeout_seconds)
+        raise TimeoutError(
+            f"find_agent timed out after {timeout_seconds:.0f}s"
+        ) from None
+    finally:
+        await _close_agent_async_resources(agent)
+
+
+def _shutdown_worker_loop(loop: asyncio.AbstractEventLoop) -> None:
+    """Release async generators/executor before closing a worker-thread loop."""
+    shutdown_timeout = 3.0
+    try:
+        loop.run_until_complete(
+            asyncio.wait_for(loop.shutdown_asyncgens(), timeout=shutdown_timeout)
+        )
+    except Exception:
+        logger.debug("shutdown_asyncgens failed", exc_info=True)
+    shutdown_executor = getattr(loop, "shutdown_default_executor", None)
+    if shutdown_executor is not None:
+        try:
+            loop.run_until_complete(
+                asyncio.wait_for(
+                    shutdown_executor(),
+                    timeout=shutdown_timeout,
+                )
+            )
+        except Exception:
+            logger.debug("shutdown_default_executor failed", exc_info=True)
+
+
+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 = asyncio.new_event_loop()
+    asyncio.set_event_loop(loop)
+    try:
+        return loop.run_until_complete(coro)
+    finally:
+        try:
+            _shutdown_worker_loop(loop)
+        finally:
+            loop.close()
+            asyncio.set_event_loop(None)

+ 423 - 0
agents/find_agent/demand_run.py

@@ -0,0 +1,423 @@
+"""从 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.session import get_session
+from supply_infra.services.video_discovery_service import get_video_discovery_service
+
+logger = logging.getLogger(__name__)
+
+_POINT_TYPES = {"inspiration", "purpose", "key"}
+@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
+    succeeded: bool = False
+    failure_reason: str | 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 _grade_priority_key(row: Any) -> tuple[float, int, str, int]:
+    """S 优先于 A;同等级按 score 降序。"""
+    score = float(row.score) if row.score is not None else -1.0
+    grade_rank = 0 if str(row.grade) == "S" else 1
+    return (-score, grade_rank, str(row.demand_name), int(row.id))
+
+
+def load_find_demand_contexts(
+    session,
+    biz_dt: str,
+    *,
+    grades: Iterable[str] = ("S", "A"),
+    top_limit: int | None = None,
+) -> list[FindDemandContext]:
+    """加载指定业务日全部 S/A 需求(可选 top_limit 截断),每个需求词组装为一条完整上下文。"""
+    grades_rows = DemandGradeRepository(session).list_by_biz_dt_and_grades(biz_dt, grades)
+    if not grades_rows:
+        return []
+
+    grades_rows = sorted(grades_rows, key=_grade_priority_key)
+    if top_limit is not None and top_limit > 0:
+        grades_rows = grades_rows[: int(top_limit)]
+
+    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"),
+    top_limit: int | None = None,
+) -> tuple[str, list[FindDemandContext]]:
+    """返回解析后的业务日与待执行上下文(默认当日全部 S/A,可选 top_limit)。"""
+    resolved_biz_dt = _resolve_biz_dt(biz_dt)
+    with get_session() as session:
+        contexts = load_find_demand_contexts(
+            session,
+            resolved_biz_dt,
+            grades=grades,
+            top_limit=top_limit,
+        )
+    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
+    values = {
+        "run_id": uuid.uuid4().hex,
+        "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,
+    }
+    run_id, skip_reason = get_video_discovery_service().prepare_scheduled_run(
+        biz_dt=ctx.biz_dt,
+        demand_grade_id=ctx.demand_grade_id,
+        values=values,
+        force=force,
+    )
+    if skip_reason is None and run_id:
+        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, skip_reason
+
+
+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
+
+    skip_ids = get_video_discovery_service().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:
+        from agents.find_agent.run_outcome import evaluate_find_agent_run
+
+        agent_result = run_find_agent(user_input)
+        outcome = evaluate_find_agent_run(run_id, agent_result)
+        if not outcome.succeeded:
+            stop_reason = outcome.failure_reason or "incomplete"
+            content_preview = (agent_result.content or "").strip()[:500]
+            if content_preview:
+                stop_reason = f"{stop_reason}: {content_preview}"
+            get_video_discovery_service().mark_run_failed(
+                run_id,
+                stop_reason=stop_reason,
+            )
+        return FindDemandExecutionResult(
+            run_id=run_id,
+            agent_result=agent_result,
+            succeeded=outcome.succeeded,
+            failure_reason=outcome.failure_reason,
+        )
+    except Exception as exc:
+        get_video_discovery_service().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],
+    }

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

@@ -0,0 +1,270 @@
+# 角色与唯一目标
+
+你是短视频供给发现 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)`
+
+最终分池只允许:
+
+- `primary`:`R / E / S` 三个命题共同成立;
+- `rejected`:未满足 `primary` 的任一候选。
+
+`pending_evaluation` 只是搜索召回后的过程状态,不是最终等级。不存在 `backup`、
+补充推荐或人工备选等级。
+
+# 决策公理与定理
+
+## 1. 需求闸门公理
+
+相关性是**主推荐池**的准入条件,不是加分项。一个高分享、老年粉丝很多但没有
+回答本次需求的视频不能保留,最终必须进入 `rejected`。
+
+`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` 均使用 `0~1` 小数,原样写入,不做百分制换算**。
+
+`R/E/S/V` 是帮助你保持判断一致的参考量,**不是程序校验线**。候选最终进入
+`primary / rejected` 完全由你根据全部证据判断。`batch_save_video_candidate_evaluations`
+会 **原样保存** 你给出的 `0~1` 分数,不会重算、换算或改写 `decision_bucket`。
+`audit_video_discovery_run` **不校验分数**,只审计证据完备性、分池合法性和搜索覆盖。
+
+优先目标是保留至少 5 条质量可靠的 `primary`,可以超过 5 条。5 条是搜索和筛选的
+优先目标,不是硬性准入线:
+在合理搜索、翻页和扩展后确实没有更多好视频时,允许少于 5 条,禁止为凑数保留明显
+低质、低相关或缺乏基本证据的候选。
+
+低相关候选即使原始分享规模、分享效率或老年倾向很强,也不能进入 `primary`。
+
+## 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` 时已经自动执行同一标准化,
+  不要重复调用。
+- `create_video_discovery_run`:在开始探索时保存输入并取得 `run_id`。若用户消息已给出
+  预创建 `run_id`,必须原样传入该 `run_id` 复用已有记录,禁止自行生成新的 `run_id`。
+- `record_video_search_page`:每次 `douyin_search` 后保存实际搜索词、形成原因、标签或
+  翻页来源、作者来源、供应方分页状态和本页结果;TikHub 搜索及作者作品页也必须保存,
+  任何搜索页都不能只存在于上下文中。新召回候选初始状态是
+  `pending_evaluation`,表示等待 Agent 补证和评分,不能直接输出。
+- `batch_save_video_candidate_evaluations`:原样保存你给出的详情、证据、**0~1 的 R/E/S/V 评分**
+  和 `decision_bucket`。工具只接受 `primary / rejected`,不会重算分数或替你改池。
+  每个候选至少传入 `aweme_id` 和你决定的 `decision_bucket`;其他证据、理由和分数
+  尽量完整传入。
+- `audit_video_discovery_run`:候选评估完成后按 `run_id` 从数据库读取完整搜索和候选
+  状态,执行确定性完成审计(证据、分池、搜索覆盖;**不校验 R/E/S/V 分数**)。只有返回
+  `can_finish=true` 才能进入最终状态查询。
+- `query_video_discovery_state`:恢复长搜索的已探索关键词、翻页状态、主推荐和淘汰
+  候选,也用于查看已经保存的模型决定。结束前的最后一次查询必须发生在审计通过后,
+  最终报告只能依据这次查询结果生成。
+
+优先让廉价证据淘汰没有主推荐价值的候选。详情与双画像用于仍可能进入主推荐的候选。
+所有工具失败都保留原始错误语义,不得编造缺失字段。
+
+若 `create_video_discovery_run` 明确返回数据库表未初始化或数据库不可用,只尝试一次:
+保留原始错误且不得反复调用或假装完成。数据库不可用时不能输出已完成报告,应立即按
+下方“工具故障终止规则”结束任务。
+
+# 工具故障终止规则
+
+由你结合任务上下文、已尝试的替代方案和工具反馈判断任务是否已经无法继续。程序不解析
+工具错误字段,也不根据错误文案替你判断错误是否可恢复。参数可以修正或仍有替代工具时
+应继续;继续调用已经确认无效的工具不会产生新信息时,应停止重试。
+
+确认无法继续后:
+
+1. 立即停止调用失败工具,不再为了满足正常成功守卫重复调用;
+2. 直接输出失败摘要,第一行必须是 `任务未完成(工具故障)`;
+3. 说明失败工具、原始错误、已完成内容和未完成内容;
+4. 明确写出“未产出有效推荐”,不得输出看似正常的主推荐或淘汰候选报告。
+
+该出口表示任务失败结束,不是成功完成;调度侧会按运行结果判定为失败。
+
+# 成本与迭代预算
+
+- 根搜索默认形成2~3个语义不同的词;每个生产性首页最多继续1页,除非第二页仍显著
+  提升主推荐质量;
+- 搜索结果先按需求相关性、分享规模/效率和主推荐潜力做廉价预筛,默认最多选择8条进入
+  `douyin_detail` 和双画像阶段;
+- 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
+  不得依赖或声称使用了视频画面、语音、字幕解析;
+- 执行新搜索、翻页、详情或画像后,应重新保存受影响候选;
+- 不得用“接下来我会继续”作为最终回答。最终报告必须与库中 `primary / rejected`
+  一致,并由 `audit_video_discovery_run` 与结束后的状态查询支撑。
+- 你已按“工具故障终止规则”判断无法继续时,不再尝试正常完成条件,直接输出失败摘要。
+- 数据库保留数量达到 5 条后,若没有明显更高价值的搜索前沿,优先结束任务。
+- 保留数量不足 5 条时,优先继续有效的搜索、翻页或扩标签;合理前沿已经耗尽,或剩余
+  候选明显不值得保留时,可以少于 5 条结束。
+
+# 完成条件
+
+一次任务优先在数据库中正确保留至少 5 条符合 `primary` 规则的视频,
+可以保留更多。若经过合理搜索仍没有 5 条合格视频,则保留全部真正合格的候选后结束,
+不能降低基本质量要求硬凑数量;确实没有合格候选时可以返回空结果。
+
+硬性完成条件:
+
+- 已创建发现运行且每个搜索页都已持久化;
+- 推荐按联合价值排序,优先保证 5 条,可以超过 5 条;确实没有足够好视频时允许更少;
+- 已把候选证据、最终分池和运行完成状态持久化;
+- `audit_video_discovery_run` 返回 `can_finish=true`;
+- 审计通过后重新调用 `query_video_discovery_state`,最终报告与该状态中的
+  `primary / rejected` 完全一致。
+
+结束前必须按以下顺序完成最后一段流程:
+
+`最后搜索并保存搜索页 → 获取并整理证据 → 保存候选评估 → 数据库审计 → 最终状态查询 → 报告`
+
+如果审计后又发生搜索、证据获取或候选评估,原审计立即失效,必须从受影响阶段继续,
+重新审计并重新查询最终状态。最终状态查询必须晚于最后一次成功审计,报告之后不得再
+反向修改候选分池。
+
+以下探索项在不足 5 条时应优先执行;但它们只作为 warning,不把 5 条变成硬门槛:
+
+- 独立根搜索词少于 2 个;
+- 生产性首页尚未翻页;
+- 值得扩展的标签尚未建立搜索分支。
+
+# 最终输出契约
+
+先用一句话复述你对需求意图的理解,然后输出“主推荐”和“淘汰候选”。主推荐每条必须包含:
+
+- 排名、标题、作者、抖音页面链接、`aweme_id`;
+- 命中的需求点及相关性证据;
+- 原始 `share_count`,以及可计算时的分享率替代指标;
+- 视频点赞年龄画像证据;
+- 作者粉丝年龄画像证据;
+- 老年人可能愿意分享的内容动机;
+- `R / E / S / V` 的 `0~1` 分值、置信度(高/中/低);
+- 一句包含正证与主要限制的推荐理由。
+
+输出顺序:
+
+1. **主推荐**:列出你最终决定推荐的视频;
+2. **淘汰候选**:列出已评估候选及淘汰理由;低相关但高分享或偏老年也必须在此列,
+   不得另设中间等级;
+3. 搜索树:实际搜索词、形成来源、已翻页数、标签扩展关系和新增候选数;
+4. 缺失数据、画像冲突、未继续的搜索前沿及接口失败。
+
+决定均由 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")
 
 

+ 30 - 0
agents/find_agent/run_outcome.py

@@ -0,0 +1,30 @@
+"""判定 find_agent 调度执行是否真正成功完成。"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from supply_agent.types import AgentResult
+from supply_infra.services.video_discovery_service import get_video_discovery_service
+
+
+@dataclass(frozen=True)
+class FindAgentRunOutcome:
+    succeeded: bool
+    failure_reason: str | None = None
+
+
+def evaluate_find_agent_run(
+    run_id: str,
+    agent_result: AgentResult | None = None,
+) -> FindAgentRunOutcome:
+    """有候选写入即成功:``video_discovery_candidate`` 按 run_id 存在记录。
+
+    ``agent_result`` 保留给调用方兼容,不再参与成败判定。
+    """
+    del agent_result  # 成败只看库表,不看模型最终文案
+    if get_video_discovery_service().has_candidates(run_id):
+        return FindAgentRunOutcome(succeeded=True)
+    return FindAgentRunOutcome(
+        succeeded=False,
+        failure_reason="no_candidates",
+    )

+ 15 - 0
agents/find_agent/runtime.py

@@ -0,0 +1,15 @@
+"""find_agent 本地运行参数(不属于通用 supply_agent 配置)。"""
+
+from __future__ import annotations
+
+import os
+
+
+def find_agent_timeout_seconds() -> float:
+    """单次 find_agent 运行总超时(秒),可读环境变量 FIND_AGENT_TIMEOUT_SECONDS。"""
+    raw = os.getenv("FIND_AGENT_TIMEOUT_SECONDS", "600")
+    try:
+        value = float(raw)
+    except ValueError:
+        value = 600.0
+    return max(60.0, min(7200.0, value))

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

@@ -10,20 +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.qwen_video_analyze import qwen_video_analyze
+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.video_discovery_store import (
+    audit_video_discovery_run,
+    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,
-    qwen_video_analyze,
+    get_content_fans_portrait,
+    get_account_fans_portrait,
+    batch_fetch_portraits,
+    normalize_age_portraits,
+    create_video_discovery_run,
+    record_video_search_page,
+    batch_save_video_candidate_evaluations,
+    audit_video_discovery_run,
+    query_video_discovery_state,
 ]
 
 __all__ = [
     "ALL_TOOLS",
     "douyin_search",
+    "douyin_search_tikhub",
+    "douyin_user_videos",
     "douyin_detail",
-    "qwen_video_analyze",
+    "get_content_fans_portrait",
+    "get_account_fans_portrait",
+    "batch_fetch_portraits",
+    "normalize_age_portraits",
+    "create_video_discovery_run",
+    "record_video_search_page",
+    "batch_save_video_candidate_evaluations",
+    "audit_video_discovery_run",
+    "query_video_discovery_state",
     "register_all_tools",
 ]
 

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

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

+ 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}")

+ 663 - 0
agents/find_agent/tools/hotspot_profile.py

@@ -0,0 +1,663 @@
+"""
+热点宝画像数据工具
+
+调用内部爬虫服务获取账号/内容的粉丝画像。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import time
+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 = 8
+
+ACCOUNT_FANS_PORTRAIT_API = (
+    "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait"
+)
+CONTENT_FANS_PORTRAIT_API = (
+    "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait"
+)
+DEFAULT_TIMEOUT = 60.0
+
+
+def _top_k(items: dict[str, Any], k: int) -> list[tuple[str, Any]]:
+    def percent_value(entry: tuple[str, Any]) -> float:
+        metrics = entry[1] if isinstance(entry[1], dict) else {}
+        return metrics.get("percentage") or 0.0
+
+    return sorted(items.items(), key=percent_value, reverse=True)[:k]
+
+
+def _format_portrait_summary(
+    header_line: str,
+    link_line: str,
+    portrait: dict[str, Any],
+) -> str:
+    summary_lines = [header_line, link_line, ""]
+    for key, value in portrait.items():
+        if not isinstance(value, dict):
+            continue
+        if key in ("省份", "城市"):
+            summary_lines.append(f"【{key} TOP5】分布")
+            items = _top_k(value, 5)
+        else:
+            summary_lines.append(f"【{key}】分布")
+            items = value.items()
+
+        for name, metrics in items:
+            ratio = metrics.get("percentage")
+            tgi = metrics.get("preference")
+            summary_lines.append(f"  {name}: {ratio} (偏好度: {tgi})")
+        summary_lines.append("")
+    return "\n".join(summary_lines)
+
+
+def _validate_account_id(account_id: str) -> Optional[str]:
+    if not account_id or not isinstance(account_id, str):
+        return "account_id 参数无效:必须是非空字符串"
+    if not account_id.startswith("MS4wLjABAAAA"):
+        return (
+            f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,"
+            f"当前值: {account_id[:min(20, len(account_id))]}..."
+        )
+    return None
+
+
+def _validate_content_id(content_id: str) -> Optional[str]:
+    if not content_id or not isinstance(content_id, str):
+        return "content_id 参数无效:必须是非空字符串"
+    if not content_id.isdigit():
+        return f"content_id 格式错误:aweme_id 应该是纯数字,当前值: {content_id[:20]}..."
+    if len(content_id) < 15 or len(content_id) > 25:
+        return f"content_id 长度异常:期望 15-25 位数字,实际 {len(content_id)} 位"
+    return None
+
+
+def _dimension_flags(
+    need_province: bool,
+    need_city: bool,
+    need_city_level: bool,
+    need_gender: bool,
+    need_age: bool,
+    need_phone_brand: bool,
+    need_phone_price: bool,
+) -> dict[str, bool]:
+    return {
+        "need_province": need_province,
+        "need_city": need_city,
+        "need_city_level": need_city_level,
+        "need_gender": need_gender,
+        "need_age": need_age,
+        "need_phone_brand": need_phone_brand,
+        "need_phone_price": need_phone_price,
+    }
+
+
+def _parse_portrait_response(
+    data: dict[str, Any],
+    *,
+    header: str,
+    link: str,
+) -> dict[str, Any]:
+    data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
+    portrait = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
+    output = _format_portrait_summary(header, link, portrait)
+    has_portrait = bool(portrait and any(isinstance(v, dict) and v for v in portrait.values()))
+    return {
+        "output": output,
+        "has_portrait": has_portrait,
+        "portrait_data": portrait,
+        "raw_data": data,
+    }
+
+
+async def _fetch_account_portrait(
+    client: httpx.AsyncClient,
+    account_id: str,
+    flags: dict[str, bool],
+) -> tuple[Optional[str], Optional[dict[str, Any]]]:
+    err = _validate_account_id(account_id)
+    if err:
+        return err, None
+
+    response = await client.post(
+        ACCOUNT_FANS_PORTRAIT_API,
+        json={"account_id": account_id, **flags},
+        headers={"Content-Type": "application/json"},
+    )
+    response.raise_for_status()
+    data = response.json()
+
+    header = f"账号 {account_id} 的粉丝画像"
+    link = (
+        f"画像链接:https://douhot.douyin.com/creator/detail?"
+        f"active_tab=creator_fans_portrait&creator_id={account_id}"
+    )
+    return None, _parse_portrait_response(data, header=header, link=link)
+
+
+async def _fetch_content_portrait(
+    client: httpx.AsyncClient,
+    content_id: str,
+    flags: dict[str, bool],
+) -> tuple[Optional[str], Optional[dict[str, Any]]]:
+    err = _validate_content_id(content_id)
+    if err:
+        return err, None
+
+    response = await client.post(
+        CONTENT_FANS_PORTRAIT_API,
+        json={"content_id": content_id, **flags},
+        headers={"Content-Type": "application/json"},
+    )
+    response.raise_for_status()
+    data = response.json()
+
+    header = f"内容 {content_id} 的点赞用户画像"
+    link = (
+        f"画像链接:https://douhot.douyin.com/video/detail?"
+        f"active_tab=video_fans&video_id={content_id}"
+    )
+    return None, _parse_portrait_response(data, header=header, link=link)
+
+
+def _success_result(payload: dict[str, Any]) -> str:
+    return json.dumps(payload, 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
+async def get_account_fans_portrait(
+    account_id: str,
+    need_province: bool = False,
+    need_city: bool = False,
+    need_city_level: bool = False,
+    need_gender: bool = False,
+    need_age: bool = True,
+    need_phone_brand: bool = False,
+    need_phone_price: bool = False,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    获取抖音账号粉丝画像(热点宝数据)
+
+    获取指定账号的粉丝画像数据,包括年龄、性别、地域等多个维度。
+
+    Args:
+        account_id: 抖音账号ID(使用 author.sec_uid)
+        need_province: 是否获取省份分布,默认 False
+        need_city: 是否获取城市分布,默认 False
+        need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
+        need_gender: 是否获取性别分布,默认 False
+        need_age: 是否获取年龄分布,默认 True
+        need_phone_brand: 是否获取手机品牌分布,默认 False
+        need_phone_price: 是否获取手机价格分布,默认 False
+        timeout: 超时时间(秒),默认 60
+
+    Returns:
+        JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
+        account_id 使用 author.sec_uid;省份数据只显示 TOP5。
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    flags = _dimension_flags(
+        need_province,
+        need_city,
+        need_city_level,
+        need_gender,
+        need_age,
+        need_phone_brand,
+        need_phone_price,
+    )
+
+    try:
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            err, ok = await _fetch_account_portrait(client, account_id, flags)
+
+        duration_ms = int((time.time() - start_time) * 1000)
+
+        if err:
+            logger.error("get_account_fans_portrait failed: account_id=%s error=%s", account_id, err)
+            return _error_result(err, title="账号粉丝画像获取失败")
+
+        assert ok is not None
+        logger.info(
+            "get_account_fans_portrait completed: account_id=%s has_portrait=%s duration_ms=%d",
+            account_id,
+            ok["has_portrait"],
+            duration_ms,
+        )
+        return _success_result(
+            {
+                "title": f"账号粉丝画像: {account_id}",
+                "output": ok["output"],
+                "has_portrait": ok["has_portrait"],
+                "portrait_data": ok["portrait_data"],
+                "raw_data": ok["raw_data"],
+                "duration_ms": duration_ms,
+            }
+        )
+
+    except httpx.HTTPStatusError as e:
+        logger.error(
+            "get_account_fans_portrait HTTP error: account_id=%s status=%d",
+            account_id,
+            e.response.status_code,
+        )
+        return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="账号粉丝画像获取失败")
+    except httpx.TimeoutException:
+        logger.error("get_account_fans_portrait timeout: account_id=%s timeout=%s", account_id, request_timeout)
+        return _error_result(f"请求超时({request_timeout}秒)", title="账号粉丝画像获取失败")
+    except httpx.RequestError as e:
+        logger.error("get_account_fans_portrait network error: account_id=%s error=%s", account_id, e)
+        return _error_result(f"网络错误: {e}", title="账号粉丝画像获取失败")
+    except Exception as e:
+        logger.error(
+            "get_account_fans_portrait unexpected error: account_id=%s error=%s",
+            account_id,
+            e,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {e}", title="账号粉丝画像获取失败")
+
+
+@tool
+async def get_content_fans_portrait(
+    content_id: str,
+    need_province: bool = False,
+    need_city: bool = False,
+    need_city_level: bool = False,
+    need_gender: bool = False,
+    need_age: bool = True,
+    need_phone_brand: bool = False,
+    need_phone_price: bool = False,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    获取抖音内容点赞用户画像(热点宝数据)
+
+    获取指定视频内容的点赞用户画像数据,包括年龄、性别、地域等多个维度。
+
+    Args:
+        content_id: 抖音内容ID(使用 aweme_id)
+        need_province: 是否获取省份分布,默认 False
+        need_city: 是否获取城市分布,默认 False
+        need_city_level: 是否获取城市等级分布(一线/新一线/二线等),默认 False
+        need_gender: 是否获取性别分布,默认 False
+        need_age: 是否获取年龄分布,默认 True
+        need_phone_brand: 是否获取手机品牌分布,默认 False
+        need_phone_price: 是否获取手机价格分布,默认 False
+        timeout: 超时时间(秒),默认 60
+
+    Returns:
+        JSON 字符串,包含 output(文本摘要)、has_portrait、portrait_data、raw_data。
+        若 has_portrait 为 False,可用 get_account_fans_portrait 作为兜底。
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    flags = _dimension_flags(
+        need_province,
+        need_city,
+        need_city_level,
+        need_gender,
+        need_age,
+        need_phone_brand,
+        need_phone_price,
+    )
+
+    try:
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            err, ok = await _fetch_content_portrait(client, content_id, flags)
+
+        duration_ms = int((time.time() - start_time) * 1000)
+
+        if err:
+            logger.error("get_content_fans_portrait failed: content_id=%s error=%s", content_id, err)
+            return _error_result(err, title="内容点赞用户画像获取失败")
+
+        assert ok is not None
+        logger.info(
+            "get_content_fans_portrait completed: content_id=%s has_portrait=%s duration_ms=%d",
+            content_id,
+            ok["has_portrait"],
+            duration_ms,
+        )
+        return _success_result(
+            {
+                "title": f"内容点赞用户画像: {content_id}",
+                "output": ok["output"],
+                "has_portrait": ok["has_portrait"],
+                "portrait_data": ok["portrait_data"],
+                "raw_data": ok["raw_data"],
+                "duration_ms": duration_ms,
+            }
+        )
+
+    except httpx.HTTPStatusError as e:
+        logger.error(
+            "get_content_fans_portrait HTTP error: content_id=%s status=%d",
+            content_id,
+            e.response.status_code,
+        )
+        return _error_result(f"HTTP {e.response.status_code}: {e.response.text}", title="内容点赞用户画像获取失败")
+    except httpx.TimeoutException:
+        logger.error("get_content_fans_portrait timeout: content_id=%s timeout=%s", content_id, request_timeout)
+        return _error_result(f"请求超时({request_timeout}秒)", title="内容点赞用户画像获取失败")
+    except httpx.RequestError as e:
+        logger.error("get_content_fans_portrait network error: content_id=%s error=%s", content_id, e)
+        return _error_result(f"网络错误: {e}", title="内容点赞用户画像获取失败")
+    except Exception as e:
+        logger.error(
+            "get_content_fans_portrait unexpected error: content_id=%s error=%s",
+            content_id,
+            e,
+            exc_info=True,
+        )
+        return _error_result(f"未知错误: {e}", title="内容点赞用户画像获取失败")
+
+
+@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,
+    need_gender: bool = False,
+    need_age: bool = True,
+    need_phone_brand: bool = False,
+    need_phone_price: bool = False,
+    timeout: Optional[float] = None,
+) -> str:
+    """
+    批量获取多条候选视频的画像
+
+    依次请求内容点赞画像。fetch_account_portrait=true 时同时请求作者粉丝画像;
+    否则仅在内容画像缺失且允许兜底时请求作者画像。
+    一次调用返回所有条目,便于比较同一候选的两侧年龄证据。
+
+    Args:
+        candidates_json: JSON 数组字符串。每项为对象,字段:
+            - aweme_id (必填): 视频 id
+            - author_sec_uid (可选): 作者 sec_uid,作者画像或兜底时需要
+            - try_account_fallback (可选,默认 true): 为 false 时不请求账号画像
+        fetch_account_portrait: 是否为每个候选同时获取作者粉丝画像,默认 False。
+            老年受众判断建议设为 True;缺少 author_sec_uid 的条目会跳过作者画像。
+        need_* / timeout: 与各单条画像工具一致
+
+    Returns:
+        JSON 字符串,包含 output(人类可读摘要)和 results(结构化列表)。
+        results 与 candidates 顺序一致,每项含 content / account 子对象。
+    """
+    start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    raw = (candidates_json or "").strip()
+
+    if not raw:
+        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="批量画像失败",
+            input_error=True,
+        )
+
+    if not isinstance(parsed, list):
+        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(
+        need_province,
+        need_city,
+        need_city_level,
+        need_gender,
+        need_age,
+        need_phone_brand,
+        need_phone_price,
+    )
+
+    results: list[dict[str, Any]] = []
+    output_chunks: list[str] = []
+
+    try:
+        async with httpx.AsyncClient(timeout=request_timeout) as client:
+            for idx, entry in enumerate(parsed):
+                if not isinstance(entry, dict):
+                    results.append(
+                        {
+                            "aweme_id": None,
+                            "error": "条目不是对象",
+                            "content": None,
+                            "account": None,
+                        }
+                    )
+                    output_chunks.append(f"[{idx}] 跳过:条目不是 JSON 对象")
+                    continue
+
+                aweme_id = entry.get("aweme_id") or entry.get("content_id")
+                author_sec = entry.get("author_sec_uid") or entry.get("account_id")
+                try_fallback = entry.get("try_account_fallback", True)
+                if isinstance(try_fallback, str):
+                    try_fallback = try_fallback.strip().lower() in ("1", "true", "yes")
+
+                if not aweme_id or not isinstance(aweme_id, str):
+                    results.append(
+                        {
+                            "aweme_id": aweme_id,
+                            "error": "缺少 aweme_id",
+                            "content": None,
+                            "account": None,
+                        }
+                    )
+                    output_chunks.append(f"[{idx}] 跳过:缺少 aweme_id")
+                    continue
+
+                item_result: dict[str, Any] = {
+                    "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,
+                }
+
+                try:
+                    cerr, cok = await _fetch_content_portrait(client, aweme_id, flags)
+                except httpx.HTTPError as e:
+                    cerr, cok = str(e), None
+
+                if cerr:
+                    item_result["content"] = {
+                        "ok": False,
+                        "error": cerr,
+                        "has_portrait": False,
+                        "portrait_data": {},
+                    }
+                else:
+                    assert cok is not None
+                    item_result["content"] = {
+                        "ok": True,
+                        "error": None,
+                        "has_portrait": cok["has_portrait"],
+                        "portrait_data": cok["portrait_data"],
+                        "output": cok["output"],
+                    }
+
+                c_block = item_result["content"]
+                content_has = bool(c_block and c_block.get("has_portrait"))
+                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,无法获取作者画像",
+                            "has_portrait": False,
+                            "portrait_data": {},
+                        }
+                    else:
+                        try:
+                            aerr, aok = await _fetch_account_portrait(client, author_sec, flags)
+                        except httpx.HTTPError as e:
+                            aerr, aok = str(e), None
+
+                        if aerr:
+                            item_result["account"] = {
+                                "attempted": True,
+                                "error": aerr,
+                                "has_portrait": False,
+                                "portrait_data": {},
+                            }
+                        else:
+                            assert aok is not None
+                            item_result["account"] = {
+                                "attempted": True,
+                                "error": None,
+                                "has_portrait": aok["has_portrait"],
+                                "portrait_data": aok["portrait_data"],
+                                "output": aok["output"],
+                            }
+                else:
+                    skip_reason = (
+                        "try_account_fallback 为 false"
+                        if not try_fallback
+                        else "内容侧已有有效画像,且未要求同时获取作者画像"
+                    )
+                    item_result["account"] = {
+                        "attempted": False,
+                        "skipped_reason": skip_reason,
+                        "has_portrait": False,
+                        "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 {}
+                output_chunks.append(
+                    f"[{idx}] aweme_id={aweme_id} "
+                    f"content_has_portrait={c_part.get('has_portrait')} "
+                    f"account_attempted={a_part.get('attempted')} "
+                    f"account_has_portrait={a_part.get('has_portrait')}"
+                )
+
+        duration_ms = int((time.time() - start_time) * 1000)
+        logger.info(
+            "batch_fetch_portraits completed: count=%d candidates=%d duration_ms=%d",
+            len(results),
+            len(parsed),
+            duration_ms,
+        )
+        return _success_result(
+            {
+                "title": f"批量画像完成 ({len(results)} 条)",
+                "output": "\n".join(output_chunks),
+                "results": results,
+                "count": len(results),
+                "duration_ms": duration_ms,
+            }
+        )
+
+    except Exception as e:
+        logger.error("batch_fetch_portraits unexpected error: error=%s", e, exc_info=True)
+        return _error_result(f"未知错误: {e}", title="批量画像失败")
+
+
+async def main() -> None:
+    content_id = os.getenv("TEST_CONTENT_ID", "7641118685977614586")
+    account_id = os.getenv("TEST_ACCOUNT_SEC_UID", "MS4wLjABAAAAcA9a--HmibvcoJ_0YCQYZ1qqbn2uCj5e4CVdc0c6y6s")
+
+    print("=== 测试 get_content_fans_portrait ===")
+    content_result = json.loads(await get_content_fans_portrait(content_id=content_id))
+    if "error" in content_result:
+        print(f"获取失败: {content_result['error']}")
+    else:
+        print(content_result["output"])
+        print(
+            f"\nhas_portrait={content_result.get('has_portrait')} "
+            f"duration_ms={content_result.get('duration_ms')}"
+        )
+
+    if account_id:
+        print("\n=== 测试 get_account_fans_portrait ===")
+        account_result = json.loads(
+            await get_account_fans_portrait(account_id=account_id)
+        )
+        if "error" in account_result:
+            print(f"获取失败: {account_result['error']}")
+        else:
+            print(account_result["output"])
+            print(
+                f"\nhas_portrait={account_result.get('has_portrait')} "
+                f"duration_ms={account_result.get('duration_ms')}"
+            )
+    else:
+        print("\n跳过账号画像测试(设置环境变量 TEST_ACCOUNT_SEC_UID 可启用)")
+
+    print("\n=== 测试 batch_fetch_portraits ===")
+    candidates = [
+        {
+            "aweme_id": content_id,
+            "author_sec_uid": account_id or None,
+            "try_account_fallback": bool(account_id),
+        }
+    ]
+    batch_result = json.loads(
+        await batch_fetch_portraits(candidates_json=json.dumps(candidates, ensure_ascii=False))
+    )
+    if "error" in batch_result:
+        print(f"批量获取失败: {batch_result['error']}")
+    else:
+        print(batch_result["output"])
+        print(f"\ncount={batch_result.get('count')} duration_ms={batch_result.get('duration_ms')}")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 479 - 16
agents/find_agent/tools/qwen_video_analyze.py

@@ -1,22 +1,32 @@
 """
-千问视频解析工具
+历史千问视频解析实现。
 
-通过阿里云百炼平台(DashScope 兼容模式)调用 qwen3.7-plus 模型,解析视频内容。
+当前 find_agent 明确不使用视频理解,本模块未注册到 Agent,也不属于在线能力。
+保留文件仅用于历史兼容,不得因文件或数据库字段存在而推断能力已启用。
 """
 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, APITimeoutError, OpenAI
 
 from supply_agent.paths import find_project_root
-from supply_agent.tools import tool
+from supply_infra.config import get_infra_settings
+from supply_infra.oss.client import OssClient
 
 logger = logging.getLogger(__name__)
 
@@ -25,11 +35,24 @@ 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
+FFPROBE_TIMEOUT_SECONDS = 30.0
+FFMPEG_TIMEOUT_SECONDS = 300.0
+
+_DURATION_RE = re.compile(
+    r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)",
+    re.IGNORECASE,
+)
 
 _env_loaded = False
 
 
+class ToolTimeoutError(RuntimeError):
+    """A bounded local tool operation exceeded its deadline."""
+
+
 def _ensure_env_loaded() -> None:
     """从项目根目录加载 .env,使 os.getenv 能读到其中的变量。"""
     global _env_loaded
@@ -47,6 +70,288 @@ 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:
+        try:
+            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,
+                timeout=FFPROBE_TIMEOUT_SECONDS,
+            )
+        except subprocess.TimeoutExpired:
+            logger.warning(
+                "ffprobe timed out after %.0fs: %s",
+                FFPROBE_TIMEOUT_SECONDS,
+                target,
+            )
+        else:
+            if result.returncode == 0:
+                raw = result.stdout.strip()
+                if raw:
+                    try:
+                        return float(raw)
+                    except ValueError:
+                        pass
+
+    try:
+        result = subprocess.run(
+            [ffmpeg, "-i", target],
+            capture_output=True,
+            text=True,
+            check=False,
+            timeout=FFPROBE_TIMEOUT_SECONDS,
+        )
+    except subprocess.TimeoutExpired:
+        logger.warning(
+            "ffmpeg duration probe timed out after %.0fs: %s",
+            FFPROBE_TIMEOUT_SECONDS,
+            target,
+        )
+        return None
+    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),
+    ]
+    try:
+        result = subprocess.run(
+            copy_cmd,
+            capture_output=True,
+            text=True,
+            check=False,
+            timeout=FFMPEG_TIMEOUT_SECONDS,
+        )
+    except subprocess.TimeoutExpired:
+        logger.warning(
+            "ffmpeg stream copy timed out after %.0fs",
+            FFMPEG_TIMEOUT_SECONDS,
+        )
+        result = None
+    if (
+        result is not None
+        and 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:] if result is not None else "timeout"),
+    )
+    encode_cmd = [
+        ffmpeg,
+        "-y",
+        "-i",
+        str(input_path),
+        "-t",
+        duration_text,
+        "-c:v",
+        "libx264",
+        "-preset",
+        "fast",
+        "-c:a",
+        "aac",
+        "-movflags",
+        "+faststart",
+        str(output_path),
+    ]
+    try:
+        encode_result = subprocess.run(
+            encode_cmd,
+            capture_output=True,
+            text=True,
+            check=False,
+            timeout=FFMPEG_TIMEOUT_SECONDS,
+        )
+    except subprocess.TimeoutExpired:
+        raise ToolTimeoutError(
+            f"ffmpeg 截断视频超时({FFMPEG_TIMEOUT_SECONDS:.0f}秒)"
+        ) from None
+    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 _safe_unlink(path: Path) -> None:
+    """删除临时视频文件,失败时仅记录日志。"""
+    try:
+        if path.is_file():
+            path.unlink()
+    except OSError as exc:
+        logger.warning("failed to delete temp video file %s: %s", path, exc)
+
+
+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,
+                )
+                _safe_unlink(source_path)
+                return video_url, meta
+
+        _ensure_oss_configured()
+        await asyncio.to_thread(
+            _truncate_video,
+            ffmpeg,
+            source_path,
+            clipped_path,
+            max_duration_seconds,
+        )
+        _safe_unlink(source_path)
+
+        analysis_url = await asyncio.to_thread(
+            _upload_clip,
+            clipped_path,
+            video_url,
+            max_duration_seconds,
+        )
+        _safe_unlink(clipped_path)
+
+    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 +386,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,44 +398,136 @@ 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,
+            timeout=FFMPEG_TIMEOUT_SECONDS,
+        )
+        _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
 async def qwen_video_analyze(
     video_url: str,
     prompt: str = DEFAULT_PROMPT,
     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:
     """
-    千问视频内容解析
+    历史视频解析函数,不注册为 find_agent 工具。
 
     通过阿里云百炼平台调用 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 +536,80 @@ 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 ToolTimeoutError as e:
+        logger.warning(
+            "qwen_video_analyze local operation timed out: video_url=%s error=%s",
+            video_url,
+            e,
+        )
+        return _error_result(
+            str(e),
+            error_code="tool_timeout",
+            retryable=True,
+        )
     except ValueError as e:
         logger.error("qwen_video_analyze config error: %s", e)
         return _error_result(str(e))
+    except httpx.TimeoutException as e:
+        logger.warning(
+            "qwen_video_analyze download timed out: video_url=%s error=%s",
+            video_url,
+            e,
+        )
+        return _error_result(
+            f"下载视频超时: {e}",
+            error_code="tool_timeout",
+            retryable=True,
+        )
+    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 APITimeoutError as e:
+        logger.warning(
+            "qwen_video_analyze model request timed out: video_url=%s error=%s",
+            video_url,
+            e,
+        )
+        return _error_result(
+            f"视频模型请求超时: {e}",
+            error_code="tool_timeout",
+            retryable=True,
+        )
+    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",

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

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

+ 39 - 0
alembic.ini

@@ -0,0 +1,39 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+path_separator = os
+sqlalchemy.url = driver://unused
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 58 - 0
alembic/env.py

@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from supply_infra.config import get_infra_settings
+from supply_infra.db.base import Base
+import supply_infra.db.models  # noqa: F401
+
+config = context.config
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+# Alembic uses ConfigParser interpolation; escaped credentials may contain "%".
+config.set_main_option(
+    "sqlalchemy.url",
+    get_infra_settings().mysql_url.replace("%", "%%"),
+)
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    context.configure(
+        url=config.get_main_option("sqlalchemy.url"),
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+        compare_type=True,
+    )
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+    with connectable.connect() as connection:
+        if connection.dialect.name == "mysql":
+            connection.exec_driver_sql("SET time_zone = '+08:00'")
+            connection.commit()
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            compare_type=True,
+        )
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 24 - 0
alembic/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+    ${downgrades if downgrades else "pass"}

+ 219 - 0
alembic/versions/20260727_01_pipeline_control_plane.py

@@ -0,0 +1,219 @@
+"""create durable pipeline control plane
+
+Revision ID: 20260727_01
+Revises:
+Create Date: 2026-07-27
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+from datetime import datetime
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260727_01"
+down_revision: str | None = None
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "pipeline_run",
+        sa.Column("run_id", sa.String(36), primary_key=True),
+        sa.Column("dedupe_key", sa.String(191), nullable=False),
+        sa.Column("pipeline_key", sa.String(64), nullable=False),
+        sa.Column("biz_dt", sa.String(8), nullable=False),
+        sa.Column("trigger_type", sa.String(24), nullable=False),
+        sa.Column("trigger_source", sa.String(64), nullable=True),
+        sa.Column("triggered_by", sa.String(128), nullable=True),
+        sa.Column("trigger_reason", sa.Text(), nullable=True),
+        sa.Column("parent_run_id", sa.String(36), nullable=True),
+        sa.Column("run_mode", sa.String(24), nullable=False),
+        sa.Column("dry_run", sa.Boolean(), nullable=False),
+        sa.Column("status", sa.String(32), nullable=False),
+        sa.Column("current_step", sa.String(64), nullable=True),
+        sa.Column("scheduled_for", sa.DateTime(), nullable=True),
+        sa.Column("deadline_at", sa.DateTime(), nullable=False),
+        sa.Column("started_at", sa.DateTime(), nullable=True),
+        sa.Column("finished_at", sa.DateTime(), nullable=True),
+        sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
+        sa.Column("lease_owner", sa.String(191), nullable=True),
+        sa.Column("lease_until", sa.DateTime(), nullable=True),
+        sa.Column("code_version", sa.String(128), nullable=True),
+        sa.Column("config_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("date_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("summary_json", sa.JSON(), nullable=True),
+        sa.Column("error_code", sa.String(64), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint("dedupe_key", name="uk_pipeline_run_dedupe_key"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_pipeline_run_biz",
+        "pipeline_run",
+        ["pipeline_key", "biz_dt", "created_at"],
+    )
+    op.create_index(
+        "idx_pipeline_run_status",
+        "pipeline_run",
+        ["status", "lease_until"],
+    )
+    op.create_index(
+        "idx_pipeline_run_deadline",
+        "pipeline_run",
+        ["status", "deadline_at"],
+    )
+
+    op.create_table(
+        "pipeline_step_run",
+        sa.Column("step_run_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="CASCADE"),
+            nullable=False,
+        ),
+        sa.Column("step_key", sa.String(64), nullable=False),
+        sa.Column("step_order", sa.Integer(), nullable=False),
+        sa.Column("attempt", sa.Integer(), nullable=False),
+        sa.Column("status", sa.String(32), nullable=False),
+        sa.Column("critical", sa.Boolean(), nullable=False),
+        sa.Column("dependency_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("input_snapshot_json", sa.JSON(), nullable=False),
+        sa.Column("timeout_seconds", sa.Integer(), nullable=False),
+        sa.Column("max_attempts", sa.Integer(), nullable=False),
+        sa.Column("retryable", sa.Boolean(), nullable=False),
+        sa.Column("next_retry_at", sa.DateTime(), nullable=True),
+        sa.Column("started_at", sa.DateTime(), nullable=True),
+        sa.Column("finished_at", sa.DateTime(), nullable=True),
+        sa.Column("heartbeat_at", sa.DateTime(), nullable=True),
+        sa.Column("lease_owner", sa.String(191), nullable=True),
+        sa.Column("lease_until", sa.DateTime(), nullable=True),
+        sa.Column("exit_code", sa.Integer(), nullable=True),
+        sa.Column("result_summary_json", sa.JSON(), nullable=True),
+        sa.Column("metrics_json", sa.JSON(), nullable=True),
+        sa.Column("error_code", sa.String(64), nullable=True),
+        sa.Column("error_message", sa.Text(), nullable=True),
+        sa.Column("log_uri", sa.String(1024), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "run_id",
+            "step_key",
+            "attempt",
+            name="uk_pipeline_step_run_attempt",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_pipeline_step_claim",
+        "pipeline_step_run",
+        ["status", "next_retry_at", "step_order"],
+    )
+    op.create_index(
+        "idx_pipeline_step_run",
+        "pipeline_step_run",
+        ["run_id", "step_order", "attempt"],
+    )
+    op.create_index(
+        "idx_pipeline_step_lease",
+        "pipeline_step_run",
+        ["status", "lease_until"],
+    )
+
+    op.create_table(
+        "pipeline_lock",
+        sa.Column("lock_key", sa.String(191), primary_key=True),
+        sa.Column("owner_run_id", sa.String(36), nullable=False),
+        sa.Column("owner_instance", sa.String(191), nullable=False),
+        sa.Column("lease_until", sa.DateTime(), nullable=False),
+        sa.Column("heartbeat_at", sa.DateTime(), nullable=False),
+        sa.Column("version", sa.BigInteger(), nullable=False),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        mysql_charset="utf8mb4",
+    )
+    pipeline_lock = sa.table(
+        "pipeline_lock",
+        sa.column("lock_key", sa.String()),
+        sa.column("owner_run_id", sa.String()),
+        sa.column("owner_instance", sa.String()),
+        sa.column("lease_until", sa.DateTime()),
+        sa.column("heartbeat_at", sa.DateTime()),
+        sa.column("version", sa.BigInteger()),
+    )
+    epoch = datetime(1970, 1, 1)
+    op.bulk_insert(
+        pipeline_lock,
+        [
+            {
+                "lock_key": "pipeline:claim_guard",
+                "owner_run_id": "control-plane",
+                "owner_instance": "claim-coordinator",
+                "lease_until": epoch,
+                "heartbeat_at": epoch,
+                "version": 1,
+            }
+        ],
+    )
+
+    op.create_table(
+        "pipeline_outbox",
+        sa.Column("outbox_id", sa.String(36), primary_key=True),
+        sa.Column(
+            "run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_run.run_id", ondelete="CASCADE"),
+            nullable=False,
+        ),
+        sa.Column(
+            "step_run_id",
+            sa.String(36),
+            sa.ForeignKey("pipeline_step_run.step_run_id", ondelete="CASCADE"),
+            nullable=False,
+        ),
+        sa.Column("effect_type", sa.String(64), nullable=False),
+        sa.Column("idempotency_key", sa.String(191), nullable=False),
+        sa.Column("payload_hash", sa.String(64), nullable=False),
+        sa.Column("payload_json", sa.JSON(), nullable=True),
+        sa.Column("payload_uri", sa.String(1024), nullable=True),
+        sa.Column("status", sa.String(32), nullable=False),
+        sa.Column("dry_run", sa.Boolean(), nullable=False),
+        sa.Column("record_error", sa.Text(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.UniqueConstraint(
+            "idempotency_key",
+            name="uk_pipeline_outbox_idempotency",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_pipeline_outbox_run",
+        "pipeline_outbox",
+        ["run_id", "step_run_id"],
+    )
+    op.create_index(
+        "idx_pipeline_outbox_status",
+        "pipeline_outbox",
+        ["status", "created_at"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("idx_pipeline_outbox_status", table_name="pipeline_outbox")
+    op.drop_index("idx_pipeline_outbox_run", table_name="pipeline_outbox")
+    op.drop_table("pipeline_outbox")
+    op.drop_table("pipeline_lock")
+    op.drop_index("idx_pipeline_step_lease", table_name="pipeline_step_run")
+    op.drop_index("idx_pipeline_step_run", table_name="pipeline_step_run")
+    op.drop_index("idx_pipeline_step_claim", table_name="pipeline_step_run")
+    op.drop_table("pipeline_step_run")
+    op.drop_index("idx_pipeline_run_deadline", table_name="pipeline_run")
+    op.drop_index("idx_pipeline_run_status", table_name="pipeline_run")
+    op.drop_index("idx_pipeline_run_biz", table_name="pipeline_run")
+    op.drop_table("pipeline_run")

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

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

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

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

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

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

+ 198 - 9
api/app.py

@@ -7,26 +7,64 @@ from pathlib import Path
 from fastapi import FastAPI, HTTPException, Query
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel, Field
+from sqlalchemy import text
+from starlette.exceptions import HTTPException as StarletteHTTPException
 
+from api.routers.pipeline import router as pipeline_router
+from api.services.agent_catalog import (
+    get_agent_detail,
+    update_agent_document_injection,
+)
 from api.services.category_tree import build_category_tree
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
 from api.services.demand_grade_videos import list_videos_for_demand_grade
 from api.services.demand_videos import list_videos_for_demand_belong
-from api.services.oss_logs import list_demand_belong_oss_logs
-from supply_infra.db import init_db
-from supply_infra.scheduler.app import get_scheduler_status, start_scheduler, stop_scheduler
+from api.services.oss_logs import list_agent_oss_logs, list_demand_belong_oss_logs
+from api.services.scheduler import (
+    get_scheduler_job_run,
+    list_triggerable_jobs,
+    run_scheduler_job,
+    run_supply_pipeline,
+    scheduler_status as get_persistent_scheduler_status,
+)
+from api.services.video_discovery import (
+    get_video_discovery_demand,
+    list_video_discovery_demands,
+)
+from supply_infra.config import get_infra_settings
+from supply_infra.db import dispose_engine, get_session, init_db
+
+
+class SPAStaticFiles(StaticFiles):
+    """Serve index.html for browser routes handled by Vue Router."""
+
+    async def get_response(self, path: str, scope):
+        try:
+            return await super().get_response(path, scope)
+        except StarletteHTTPException as exc:
+            is_backend_path = (
+                path == "api"
+                or path.startswith("api/")
+                or path == "health"
+                or path.startswith("health/")
+            )
+            if exc.status_code != 404 or is_backend_path or Path(path).suffix:
+                raise
+            return await super().get_response("index.html", scope)
 
 
 @asynccontextmanager
 async def lifespan(_app: FastAPI):
-    init_db()
-    start_scheduler()
+    if get_infra_settings().database_auto_create:
+        init_db()
     yield
-    stop_scheduler()
+    dispose_engine()
 
 
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
+app.include_router(pipeline_router)
 
 app.add_middleware(
     CORSMiddleware,
@@ -47,10 +85,105 @@ def health() -> dict[str, str]:
     return {"status": "ok"}
 
 
+@app.get("/health/live")
+def health_live() -> dict[str, str]:
+    return {"status": "ok"}
+
+
+@app.get("/health/ready")
+def health_ready() -> dict[str, str]:
+    try:
+        with get_session() as session:
+            session.execute(text("SELECT 1"))
+    except Exception as exc:
+        raise HTTPException(status_code=503, detail="database unavailable") from exc
+    return {"status": "ready"}
+
+
 @app.get("/api/scheduler/status")
 def scheduler_status() -> dict:
-    """Return scheduler enabled/running state and next run times."""
-    return get_scheduler_status()
+    """Deprecated compatibility view backed by MySQL pipeline state."""
+    return get_persistent_scheduler_status()
+
+
+class RunPipelineBody(BaseModel):
+    biz_dt: str | None = Field(
+        default=None,
+        pattern=r"^\d{8}$",
+        description="业务日 YYYYMMDD;省略则取当天",
+    )
+
+
+class TriggerSchedulerJobBody(BaseModel):
+    biz_dt: str | None = Field(
+        default=None,
+        pattern=r"^\d{8}$",
+        description="业务日 YYYYMMDD;省略则取当天",
+    )
+
+
+class AgentDocumentInjectionBody(BaseModel):
+    content: str = Field(
+        default="",
+        max_length=100_000,
+        description="追加到 Agent System Prompt 后的业务文档",
+    )
+    enabled: bool = Field(
+        default=True,
+        description="是否在新建 Agent 时启用该文档注入",
+    )
+
+
+@app.post("/api/pipeline/run", status_code=202)
+def run_pipeline(
+    body: RunPipelineBody | None = None,
+    biz_dt: str | None = Query(
+        default=None,
+        pattern=r"^\d{8}$",
+        description="业务日 YYYYMMDD(与 body 二选一,body 优先)",
+    ),
+) -> dict:
+    """
+    异步一键执行供给数据全流程,立即返回 run_id,后台串行执行:
+
+    全局树同步 → 需求池同步 → 需求分级 → 视频点位拓展 → find_agent 找片 → AIGC 发布。
+    """
+    resolved_biz_dt = body.biz_dt if body and body.biz_dt is not None else biz_dt
+    return run_supply_pipeline(biz_dt=resolved_biz_dt)
+
+
+@app.get("/api/scheduler/jobs")
+def scheduler_jobs() -> dict:
+    """Return jobs that can be triggered manually."""
+    return {"items": list_triggerable_jobs()}
+
+
+@app.post("/api/scheduler/jobs/{job_id}/run")
+def trigger_scheduler_job(
+    job_id: str,
+    body: TriggerSchedulerJobBody | None = None,
+    biz_dt: str | None = Query(
+        default=None,
+        pattern=r"^\d{8}$",
+        description="业务日 YYYYMMDD(与 body 二选一,body 优先)",
+    ),
+) -> dict:
+    """Manually trigger a scheduler job."""
+    resolved_biz_dt = body.biz_dt if body and body.biz_dt is not None else biz_dt
+
+    try:
+        return run_scheduler_job(job_id, biz_dt=resolved_biz_dt)
+    except KeyError:
+        raise HTTPException(status_code=404, detail=f"scheduler job not found: {job_id}") from None
+
+
+@app.get("/api/scheduler/runs/{run_id}")
+def scheduler_job_run(run_id: str) -> dict:
+    """Return durable pipeline run status."""
+    result = get_scheduler_job_run(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="scheduler run not found")
+    return result
 
 
 @app.get("/api/category-tree")
@@ -101,6 +234,26 @@ def demand_grade_videos(demand_grade_id: int) -> dict:
     return result
 
 
+@app.get("/api/video-discovery/demands")
+def video_discovery_demands(
+    biz_dt: str | None = Query(
+        default=None,
+        description="业务日 YYYYMMDD;省略则取 demand_grade 最新一日",
+    ),
+) -> dict:
+    """Return demand-first video discovery cards for the selected/latest day."""
+    return list_video_discovery_demands(biz_dt=biz_dt)
+
+
+@app.get("/api/video-discovery/demands/{demand_grade_id}")
+def video_discovery_demand(demand_grade_id: int) -> dict:
+    """Return one demand and its videos/points resolved from the source tables."""
+    result = get_video_discovery_demand(demand_grade_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="demand_grade not found")
+    return result
+
+
 @app.get("/api/demand-belong-oss-logs")
 def demand_belong_oss_logs() -> dict:
     """Return demand_belong_category_agent oss_logs ordered by create_time desc."""
@@ -108,6 +261,42 @@ def demand_belong_oss_logs() -> dict:
     return {"items": items}
 
 
+@app.get("/api/agent-oss-logs")
+def agent_oss_logs(
+    agent_name: str | None = Query(
+        default=None,
+        description="Agent 名称;省略则返回全部 Agent 日志",
+    ),
+) -> dict:
+    """Return Agent oss_logs and the available Agent names."""
+    return list_agent_oss_logs(agent_name=agent_name)
+
+
+@app.get("/api/agents/{agent_name}")
+def agent_detail(agent_name: str) -> dict:
+    """Return one business Agent's current system prompt and tools."""
+    result = get_agent_detail(agent_name)
+    if result is None:
+        raise HTTPException(status_code=404, detail=f"agent not found: {agent_name}")
+    return result
+
+
+@app.put("/api/agents/{agent_name}/document-injection")
+def agent_document_injection(
+    agent_name: str,
+    body: AgentDocumentInjectionBody,
+) -> dict:
+    """Create or update the web-managed document injected into an Agent prompt."""
+    result = update_agent_document_injection(
+        agent_name,
+        content=body.content,
+        enabled=body.enabled,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail=f"agent not found: {agent_name}")
+    return result
+
+
 _web_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
 if _web_dist.is_dir():
-    app.mount("/", StaticFiles(directory=_web_dist, html=True), name="web")
+    app.mount("/", SPAStaticFiles(directory=_web_dist, html=True), name="web")

+ 1 - 0
api/routers/__init__.py

@@ -0,0 +1 @@
+"""FastAPI routers."""

+ 89 - 0
api/routers/pipeline.py

@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Query
+
+from api.schemas.pipeline import CreatePipelineRunBody
+from api.services.pipeline import (
+    cancel_run,
+    create_run,
+    get_run,
+    list_runs,
+    pipeline_health,
+    resume_run,
+    retry_step,
+)
+
+router = APIRouter(prefix="/api/pipeline", tags=["pipeline"])
+
+
+@router.get("/runs")
+def pipeline_runs(
+    limit: int = Query(default=50, ge=1, le=200),
+    status: str | None = None,
+    biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+) -> dict:
+    return {"items": list_runs(limit=limit, status=status, biz_dt=biz_dt)}
+
+
+@router.post("/runs", status_code=202)
+def create_pipeline_run(body: CreatePipelineRunBody | None = None) -> dict:
+    payload = body or CreatePipelineRunBody()
+    return create_run(
+        biz_dt=payload.biz_dt,
+        source="api",
+        reason=payload.reason,
+    )
+
+
+@router.get("/runs/{run_id}")
+def pipeline_run_detail(run_id: str) -> dict:
+    result = get_run(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="pipeline run not found")
+    return result
+
+
+@router.get("/runs/{run_id}/steps")
+def pipeline_run_steps(run_id: str) -> dict:
+    result = get_run(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="pipeline run not found")
+    return {"items": result["steps"]}
+
+
+@router.post("/runs/{run_id}/resume", status_code=202)
+def resume_pipeline_run(run_id: str) -> dict:
+    if not resume_run(run_id):
+        raise HTTPException(status_code=404, detail="pipeline run not found or not resumable")
+    result = get_run(run_id)
+    assert result is not None
+    return {"accepted": True, "run_id": run_id, "status": result["status"]}
+
+
+@router.post("/runs/{run_id}/cancel", status_code=202)
+def cancel_pipeline_run(run_id: str) -> dict:
+    if not cancel_run(run_id):
+        raise HTTPException(status_code=404, detail="pipeline run not found")
+    result = get_run(run_id)
+    assert result is not None
+    return {"accepted": True, "run_id": run_id, "status": result["status"]}
+
+
+@router.post("/runs/{run_id}/steps/{step_key}/retry", status_code=202)
+def retry_pipeline_run_step(run_id: str, step_key: str) -> dict:
+    if not retry_step(run_id, step_key):
+        raise HTTPException(
+            status_code=409,
+            detail="step is not the first failed step or run is not resumable",
+        )
+    return {
+        "accepted": True,
+        "run_id": run_id,
+        "step_key": step_key,
+        "status": "queued",
+    }
+
+
+@router.get("/health")
+def health() -> dict:
+    return pipeline_health()

+ 1 - 0
api/schemas/__init__.py

@@ -0,0 +1 @@
+"""API request/response schemas."""

+ 14 - 0
api/schemas/pipeline.py

@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class CreatePipelineRunBody(BaseModel):
+    biz_dt: str | None = Field(default=None, pattern=r"^\d{8}$")
+    reason: str | None = Field(default=None, max_length=2000)
+
+
+class PipelineRunActionResponse(BaseModel):
+    accepted: bool
+    run_id: str
+    status: str

+ 84 - 0
api/services/agent_catalog.py

@@ -0,0 +1,84 @@
+"""Read-only catalog for the business Agents shown in the web console."""
+from __future__ import annotations
+
+from importlib import import_module
+from pathlib import Path
+from typing import Any
+
+from supply_infra.agent_prompt_injection import (
+    get_agent_document_injection,
+    save_agent_document_injection,
+)
+
+_PROJECT_ROOT = Path(__file__).resolve().parents[2]
+
+_AGENTS: dict[str, dict[str, str]] = {
+    "demand_belong_category_agent": {
+        "display_name": "分配 Agent",
+        "subtitle": "语义归属与分类挂载",
+        "prompt_path": "agents/demand_belong_category_agent/prompt/system_prompt.md",
+        "tools_module": "agents.demand_belong_category_agent.tools",
+    },
+    "demand_grade_agent": {
+        "display_name": "分等级 Agent",
+        "subtitle": "证据融合与优先级判断",
+        "prompt_path": "agents/demand_grade_agent/prompt/system_prompt.md",
+        "tools_module": "agents.demand_grade_agent.tools",
+    },
+    "demand_video_expand_agent": {
+        "display_name": "拓展 Agent",
+        "subtitle": "视频点位与需求拓展",
+        "prompt_path": "agents/demand_video_expand_agent/prompt/system_prompt.md",
+        "tools_module": "agents.demand_video_expand_agent.tools",
+    },
+}
+
+
+def get_agent_detail(agent_name: str) -> dict[str, Any] | None:
+    """Return the current system prompt and registered tool definitions."""
+    spec = _AGENTS.get(agent_name)
+    if spec is None:
+        return None
+
+    prompt_path = _PROJECT_ROOT / spec["prompt_path"]
+    prompt = prompt_path.read_text(encoding="utf-8")
+
+    tools_module = import_module(spec["tools_module"])
+    tools = []
+    for tool_fn in tools_module.ALL_TOOLS:
+        tools.append(
+            {
+                "name": getattr(tool_fn, "_tool_name", tool_fn.__name__),
+                "description": getattr(
+                    tool_fn,
+                    "_tool_description",
+                    (tool_fn.__doc__ or "").strip(),
+                ),
+                "parameters": getattr(tool_fn, "_tool_parameters", {}),
+            }
+        )
+
+    return {
+        "name": agent_name,
+        "display_name": spec["display_name"],
+        "subtitle": spec["subtitle"],
+        "system_prompt": prompt,
+        "document_injection": get_agent_document_injection(agent_name),
+        "tools": tools,
+    }
+
+
+def update_agent_document_injection(
+    agent_name: str,
+    *,
+    content: str,
+    enabled: bool,
+) -> dict[str, Any] | None:
+    """Persist the editable document injection for an allow-listed Agent."""
+    if agent_name not in _AGENTS:
+        return None
+    return save_agent_document_injection(
+        agent_name,
+        content=content,
+        enabled=enabled,
+    )

+ 2 - 2
api/services/category_tree.py

@@ -36,8 +36,8 @@ DIM_META: list[dict[str, str]] = [
     {"key": "plat_sust_pop", "label": "平台持续热度"},
     {"key": "plat_ly_pop", "label": "去年同期热度"},
     {"key": "recent_pop", "label": "近期热度"},
-    {"key": "real_rov_7d", "label": "真实ROV(7日)"},
-    {"key": "real_vov_7d", "label": "真实VOV(7日)"},
+    {"key": "real_rov_7d", "label": "ROV相对差(7日)"},
+    {"key": "real_vov_7d", "label": "VOV相对差(7日)"},
 ]
 
 

+ 24 - 12
api/services/oss_logs.py

@@ -9,20 +9,32 @@ from supply_infra.db.session import get_session
 _DEMAND_AGENT = "demand_belong_category_agent"
 
 
+def _serialize_log(row: Any) -> dict[str, Any]:
+    return {
+        "id": row.id,
+        "log_name": row.log_name,
+        "agent_name": row.agent_name,
+        "oss_path": row.oss_path,
+        "create_time": row.create_time.isoformat(sep=" ", timespec="seconds")
+        if row.create_time
+        else None,
+    }
+
+
+def list_agent_oss_logs(agent_name: str | None = None) -> dict[str, Any]:
+    """List all Agent OSS logs, optionally filtered by exact agent name."""
+    with get_session() as session:
+        repo = OssLogRepository(session)
+        rows = repo.list_by_agent_name(agent_name) if agent_name else repo.list_all()
+        return {
+            "items": [_serialize_log(row) for row in rows],
+            "agents": repo.list_agent_names(),
+        }
+
+
 def list_demand_belong_oss_logs() -> list[dict[str, Any]]:
     """List demand_belong_category_agent oss logs, newest first."""
     with get_session() as session:
         repo = OssLogRepository(session)
         rows = repo.list_by_agent_name(_DEMAND_AGENT)
-        return [
-            {
-                "id": row.id,
-                "log_name": row.log_name,
-                "agent_name": row.agent_name,
-                "oss_path": row.oss_path,
-                "create_time": row.create_time.isoformat(sep=" ", timespec="seconds")
-                if row.create_time
-                else None,
-            }
-            for row in rows
-        ]
+        return [_serialize_log(row) for row in rows]

+ 65 - 0
api/services/pipeline.py

@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from typing import Any
+
+from supply_infra.pipeline.dag import PIPELINE_STEPS
+from supply_infra.pipeline.health import pipeline_health_snapshot
+from supply_infra.pipeline.run_service import (
+    cancel_pipeline_run,
+    get_pipeline_run,
+    list_pipeline_runs,
+    resume_pipeline_run,
+    retry_pipeline_step,
+    submit_pipeline_run,
+)
+
+
+def create_run(
+    *,
+    biz_dt: str | None,
+    source: str,
+    reason: str | None = None,
+) -> dict[str, Any]:
+    return submit_pipeline_run(
+        biz_dt=biz_dt,
+        trigger_type="api",
+        trigger_source=source,
+        trigger_reason=reason,
+    ).to_dict()
+
+
+def list_runs(
+    *,
+    limit: int,
+    status: str | None,
+    biz_dt: str | None,
+) -> list[dict[str, Any]]:
+    return list_pipeline_runs(limit=limit, status=status, biz_dt=biz_dt)
+
+
+def get_run(run_id: str) -> dict[str, Any] | None:
+    return get_pipeline_run(run_id)
+
+
+def resume_run(run_id: str) -> bool:
+    return resume_pipeline_run(run_id)
+
+
+def cancel_run(run_id: str) -> bool:
+    return cancel_pipeline_run(run_id)
+
+
+def retry_step(run_id: str, step_key: str) -> bool:
+    return retry_pipeline_step(run_id, step_key)
+
+
+def pipeline_health() -> dict[str, Any]:
+    recent = list_pipeline_runs(limit=1)
+    latest = recent[0] if recent else None
+    return {
+        "scheduler_embedded_in_api": False,
+        "pipeline_key": "supply_pipeline",
+        "steps": len(PIPELINE_STEPS),
+        "latest_run": latest,
+        **pipeline_health_snapshot(),
+    }

+ 68 - 0
api/services/scheduler.py

@@ -0,0 +1,68 @@
+"""Compatibility facade over the durable pipeline control plane."""
+from __future__ import annotations
+
+from typing import Any
+
+from api.services.pipeline import get_run, pipeline_health
+from supply_infra.config import get_infra_settings
+from supply_infra.pipeline.dag import PIPELINE_STEPS
+from supply_infra.pipeline.dates import CHINA_TIMEZONE, next_schedule_china
+from supply_infra.pipeline.run_service import submit_pipeline_run
+from supply_infra.scheduler.constants import SUPPLY_PIPELINE_JOB_ID, SUPPLY_PIPELINE_JOB_NAME
+
+
+def list_triggerable_jobs() -> list[dict[str, Any]]:
+    return [
+        {
+            "id": SUPPLY_PIPELINE_JOB_ID,
+            "name": SUPPLY_PIPELINE_JOB_NAME,
+            "description": "提交严格门禁的 12 步持久化供给流水线",
+            "accepts_biz_dt": True,
+            "deprecated": False,
+            "steps": [step.key for step in PIPELINE_STEPS],
+        }
+    ]
+
+
+def run_scheduler_job(
+    job_id: str,
+    *,
+    biz_dt: str | None = None,
+) -> dict[str, Any]:
+    if job_id != SUPPLY_PIPELINE_JOB_ID:
+        raise KeyError(job_id)
+    return submit_pipeline_run(
+        biz_dt=biz_dt,
+        trigger_type="api",
+        trigger_source="legacy_scheduler_api",
+    ).to_dict()
+
+
+def get_scheduler_job_run(run_id: str) -> dict[str, Any] | None:
+    return get_run(run_id)
+
+
+def run_supply_pipeline(*, biz_dt: str | None = None) -> dict[str, Any]:
+    return run_scheduler_job(SUPPLY_PIPELINE_JOB_ID, biz_dt=biz_dt)
+
+
+def scheduler_status() -> dict[str, Any]:
+    settings = get_infra_settings()
+    health = pipeline_health()
+    return {
+        "enabled": settings.scheduler_enabled,
+        "running": health["scheduler_running"],
+        "embedded": False,
+        "deprecated": True,
+        "timezone": settings.scheduler_timezone,
+        "jobs": [
+            {
+                "id": SUPPLY_PIPELINE_JOB_ID,
+                "name": SUPPLY_PIPELINE_JOB_NAME,
+                "next_run_time": next_schedule_china(settings=settings)
+                .replace(tzinfo=CHINA_TIMEZONE)
+                .isoformat(),
+            }
+        ],
+        **health,
+    }

+ 193 - 0
api/services/video_discovery.py

@@ -0,0 +1,193 @@
+"""Demand-first video discovery data for the web workspace."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+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.global_tree_category_repo import (
+    GlobalTreeCategoryRepository,
+)
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+
+_POINT_TYPES = {"inspiration", "purpose", "key"}
+
+
+def _parse_string_list(raw: Any) -> list[str]:
+    """Parse JSON arrays and legacy comma-separated text into a stable string list."""
+    if raw is None:
+        return []
+
+    values: list[Any]
+    if isinstance(raw, str):
+        text = raw.strip()
+        if not text:
+            return []
+        try:
+            parsed = json.loads(text)
+        except (TypeError, ValueError):
+            parsed = None
+        if isinstance(parsed, list):
+            values = parsed
+        elif parsed is not None:
+            values = [parsed]
+        else:
+            values = [part.strip() for part in text.split(",")]
+    elif isinstance(raw, (list, tuple)):
+        values = list(raw)
+    else:
+        values = [raw]
+
+    result: list[str] = []
+    seen: set[str] = set()
+    for value in values:
+        text = str(value).strip() if value is not None else ""
+        if text and text not in seen:
+            seen.add(text)
+            result.append(text)
+    return result
+
+
+def _parse_int_list(raw: Any) -> list[int]:
+    result: list[int] = []
+    for value in _parse_string_list(raw):
+        try:
+            result.append(int(value))
+        except ValueError:
+            continue
+    return result
+
+
+def _number(value: Any) -> float | None:
+    return float(value) if value is not None else None
+
+
+def _serialize_demand(
+    row: Any,
+    category_names: dict[int, str],
+    video_count: int,
+) -> dict[str, Any]:
+    category_ids = _parse_int_list(row.category_ids)
+    return {
+        "id": int(row.id),
+        "demand_name": row.demand_name,
+        "biz_dt": str(row.biz_dt),
+        "grade": row.grade,
+        "score": _number(row.score),
+        "prior_total_score": _number(row.prior_total_score),
+        "posterior_rov_avg": _number(row.posterior_rov_avg),
+        "posterior_rov_count": int(row.posterior_rov_count or 0),
+        "has_posterior": bool(row.has_posterior),
+        "category_ids": category_ids,
+        "category_names": [
+            category_names[category_id]
+            for category_id in category_ids
+            if category_id in category_names
+        ],
+        "strategies": _parse_string_list(row.strategies),
+        "reason": row.reason,
+        "video_count": video_count,
+    }
+
+
+def _category_name_map(session: Any, rows: list[Any]) -> dict[int, str]:
+    category_ids = sorted(
+        {
+            category_id
+            for row in rows
+            for category_id in _parse_int_list(row.category_ids)
+        }
+    )
+    categories = GlobalTreeCategoryRepository(session).list_active_by_ids(category_ids)
+    return {
+        int(category.id): str(category.name)
+        for category in categories
+        if category.name
+    }
+
+
+def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
+    """Return one demand card per demand_grade row for the selected/latest day."""
+    with get_session() as session:
+        grade_repo = DemandGradeRepository(session)
+        resolved_biz_dt = biz_dt or grade_repo.get_latest_biz_dt()
+        if not resolved_biz_dt:
+            return {"biz_dt": None, "items": []}
+
+        rows = grade_repo.list_by_biz_dt(str(resolved_biz_dt))
+        category_names = _category_name_map(session, rows)
+        video_counts = DemandVideoExpansionRepository(
+            session
+        ).count_distinct_videos_by_demand_grade(str(resolved_biz_dt))
+        return {
+            "biz_dt": str(resolved_biz_dt),
+            "items": [
+                _serialize_demand(
+                    row,
+                    category_names,
+                    video_counts.get(int(row.id), 0),
+                )
+                for row in rows
+            ],
+        }
+
+
+def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
+    """
+    Resolve the judged video list and hit evidence from demand_video_expansion.
+
+    multi_demand_video_detail contributes only the title and no other field.
+    """
+    with get_session() as session:
+        grade = DemandGradeRepository(session).get_by_id(demand_grade_id)
+        if grade is None:
+            return None
+
+        expansions = DemandVideoExpansionRepository(session).list_by_demand_grade(
+            str(grade.biz_dt),
+            demand_grade_id,
+        )
+        category_names = _category_name_map(session, [grade])
+
+        video_ids: list[str] = []
+        points_by_video: dict[str, list[dict[str, Any]]] = {}
+        for row in expansions:
+            video_id = str(row.video_id).strip()
+            if not video_id or row.point_type not in _POINT_TYPES:
+                continue
+            if video_id not in points_by_video:
+                video_ids.append(video_id)
+                points_by_video[video_id] = []
+            points_by_video[video_id].append(
+                {
+                    "point_type": row.point_type,
+                    "expanded_text": row.expanded_text,
+                    "point_desc": row.point_desc,
+                    "reason": row.reason,
+                }
+            )
+
+        details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
+        videos: list[dict[str, Any]] = []
+        for video_id in video_ids:
+            points = points_by_video[video_id]
+            detail = details.get(video_id)
+            videos.append(
+                {
+                    "vid": video_id,
+                    "title": detail.title if detail else None,
+                    "point_count": len(points),
+                    "points": points,
+                }
+            )
+
+        demand = _serialize_demand(grade, category_names, len(videos))
+        demand["point_count"] = sum(video["point_count"] for video in videos)
+        demand["videos"] = videos
+        return demand

+ 38 - 0
deploy/README.md

@@ -0,0 +1,38 @@
+# Pipeline 单 Docker 部署
+
+当前生产保持一个镜像、一个容器。Supervisor 在容器内分别托管 API、独立
+Scheduler、多个 Pipeline Worker 和 Reconciler。
+
+## 启动前
+
+1. MySQL 必须是 8.0.36,并为本应用保留 40 个连接;
+2. 挂载 `/app/logs/pipeline` 到持久化目录;
+3. `SCHEDULER_ENABLED=true` 只影响独立 Scheduler 进程,API 不再内嵌调度器;
+   默认调度时间为 `Asia/Shanghai` 每日 15:00;
+4. 配置 `AIGC_API_TOKEN`,日批最后一步会真实创建/绑定 AIGC 计划;
+5. Docker 停止窗口至少 300 秒。
+6. 容器和 Alembic 连接 MySQL 后会将会话时区固定为 `+08:00`(中国标准时间);定时日批有次日
+   调度 deadline,手工/API/CLI 补数不设置 deadline。
+7. 单个 `find_agent` 默认最多运行 600 秒(10 分钟);运行中的找片批次每 60 秒输出一次
+   进度日志,超时记录会标记失败,批次继续处理下一条需求。
+
+示例:
+
+```bash
+docker run \
+  --stop-timeout 300 \
+  --env-file /path/to/supply-agent.env \
+  -v /host/supply-agent-pipeline-logs:/app/logs/pipeline \
+  -p 8080:8080 \
+  registry.example/supply-agent:tag
+```
+
+标准档使用 40 个连接、4 个 Worker、4 个最大活跃步骤。高并发补跑档使用
+50 个连接、20 个 Worker,但必须将最大活跃步骤限制为 10。
+
+容器启动时默认运行 `alembic upgrade head`。紧急回滚代码时不自动 downgrade
+数据库;四张控制面表保留审计数据。
+
+Reconciler 会把失败、失联、漏跑恢复以及 12 小时/2 小时/30 分钟分级预警以
+`pipeline_alert` 结构化日志写到容器 stdout。第一阶段不上传到现有公共 CDN OSS;
+生产应由 Docker 日志采集器接入现有告警平台,或至少保留容器日志。

+ 67 - 0
deploy/supervisord.pipeline.conf

@@ -0,0 +1,67 @@
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+pidfile=/tmp/supervisord.pid
+
+[program:api]
+command=python -m api
+directory=/app
+environment=PROCESS_ROLE="api"
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:scheduler]
+command=python -m supply_infra.scheduler
+directory=/app
+environment=PROCESS_ROLE="scheduler"
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:pipeline-worker]
+command=python -m supply_infra.pipeline.cli worker
+directory=/app
+environment=PROCESS_ROLE="worker"
+numprocs=%(ENV_PIPELINE_WORKER_PROCESSES)s
+process_name=%(program_name)s-%(process_num)02d
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:pipeline-reconciler]
+command=python -m supply_infra.pipeline.cli reconciler
+directory=/app
+environment=PROCESS_ROLE="reconciler"
+autostart=true
+autorestart=unexpected
+startsecs=3
+stopasgroup=true
+killasgroup=true
+stopwaitsecs=300
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0

+ 0 - 36
jobs/backfill_demand_video_expansion_point_desc.py

@@ -1,36 +0,0 @@
-#!/usr/bin/env python3
-"""手动补全 demand_video_expansion 缺失的 point_desc。
-
-用法:
-    python jobs/backfill_demand_video_expansion_point_desc.py
-    python jobs/backfill_demand_video_expansion_point_desc.py 20260721
-    python jobs/backfill_demand_video_expansion_point_desc.py 20260721 --dry-run
-"""
-from __future__ import annotations
-
-import logging
-import sys
-from pathlib import Path
-
-_ROOT = Path(__file__).resolve().parents[1]
-if str(_ROOT) not in sys.path:
-    sys.path.insert(0, str(_ROOT))
-
-from scripts.backfill_demand_video_expansion_point_desc import backfill_missing_point_descs
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(biz_dt: str | None = None, *, dry_run: bool = False) -> dict:
-    result = backfill_missing_point_descs(biz_dt, dry_run=dry_run)
-    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
-    main(biz_dt_arg, dry_run="--dry-run" in args)

+ 0 - 35
jobs/backfill_multi_demand_video_list.py

@@ -1,35 +0,0 @@
-#!/usr/bin/env python3
-"""回填 multi_demand_pool_di 的 video_list / video_count。
-
-用法:
-    python jobs/backfill_multi_demand_video_list.py 20260714
-    python jobs/backfill_multi_demand_video_list.py          # 默认当天
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-from datetime import datetime
-
-from supply_infra.scheduler.jobs.backfill_multi_demand_pool_video_list import (
-    backfill_video_list,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(biz_dt: str | None = None) -> dict:
-    if biz_dt is None:
-        biz_dt = datetime.now().strftime("%Y%m%d")
-    result = backfill_video_list(biz_dt)
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
-    main(date_arg)

+ 0 - 34
jobs/backfill_multi_demand_video_points.py

@@ -1,34 +0,0 @@
-#!/usr/bin/env python3
-"""回填 multi_demand_video_detail 的灵感点/目的点/关键点
-(decode_result.灵感点 / 目的点 / 关键点,每项保留 点/点描述)。
-
-用法:
-    python jobs/backfill_multi_demand_video_points.py
-    python jobs/backfill_multi_demand_video_points.py 100   # 每批 100
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-
-from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
-    VIDEO_SYNC_BATCH_SIZE,
-    backfill_video_points,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(batch_arg: str | None = None) -> dict:
-    batch_size = int(batch_arg) if batch_arg else VIDEO_SYNC_BATCH_SIZE
-    result = backfill_video_points(batch_size=batch_size)
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    main(sys.argv[1] if len(sys.argv) > 1 else None)

+ 0 - 117
jobs/backfill_multi_demand_video_points_table.py

@@ -1,117 +0,0 @@
-#!/usr/bin/env python3
-"""将 multi_demand_video_detail 三个 JSON 点位列迁移到 multi_demand_video_point 表。
-
-用法:
-    python jobs/backfill_multi_demand_video_points_table.py
-    python jobs/backfill_multi_demand_video_points_table.py 500   # 每批 500 条视频
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-
-from sqlalchemy import select
-
-from supply_infra.db.models.multi_demand_video_detail import MultiDemandVideoDetail
-from supply_infra.db.repositories.multi_demand_video_point_repo import (
-    MultiDemandVideoPointRepository,
-)
-from supply_infra.db.session import get_session
-from supply_infra.video_points import points_from_json_fields
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-logger = logging.getLogger(__name__)
-
-_DEFAULT_BATCH_SIZE = 200
-
-
-def backfill_video_points_table(batch_size: int = _DEFAULT_BATCH_SIZE) -> dict:
-    """从 detail 表 JSON 列回填点位表,跳过已迁移的 video_id。"""
-    chunk = max(1, int(batch_size))
-    total_rows = 0
-    total_points = 0
-    batches = 0
-    offset = 0
-
-    while True:
-        with get_session() as session:
-            stmt = (
-                select(MultiDemandVideoDetail)
-                .where(
-                    MultiDemandVideoDetail.inspiration_points_json.is_not(None)
-                    | MultiDemandVideoDetail.purpose_points_json.is_not(None)
-                    | MultiDemandVideoDetail.key_points_json.is_not(None)
-                )
-                .order_by(MultiDemandVideoDetail.id)
-                .offset(offset)
-                .limit(chunk)
-            )
-            rows = list(session.scalars(stmt).all())
-            if not rows:
-                break
-
-            vids = [str(row.vid) for row in rows if row.vid]
-            existing = MultiDemandVideoPointRepository(session).list_video_ids_with_points(
-                vids
-            )
-
-            points_by_vid: dict[str, list] = {}
-            for row in rows:
-                vid = str(row.vid).strip() if row.vid else ""
-                if not vid or vid in existing:
-                    continue
-                point_rows = points_from_json_fields(
-                    vid,
-                    inspiration_points_json=row.inspiration_points_json,
-                    purpose_points_json=row.purpose_points_json,
-                    key_points_json=row.key_points_json,
-                )
-                if point_rows:
-                    points_by_vid[vid] = point_rows
-
-            inserted = 0
-            if points_by_vid:
-                inserted = MultiDemandVideoPointRepository(session).replace_for_video_ids(
-                    points_by_vid
-                )
-
-        batch_count = len(rows)
-        migrated = len(points_by_vid)
-        total_rows += batch_count
-        total_points += inserted
-        batches += 1
-        offset += batch_count
-        logger.info(
-            "Batch %d: scanned=%d migrated_videos=%d inserted_points=%d offset=%d",
-            batches,
-            batch_count,
-            migrated,
-            inserted,
-            offset,
-        )
-
-        if batch_count < chunk:
-            break
-
-    result = {
-        "batches": batches,
-        "scanned_rows": total_rows,
-        "inserted_points": total_points,
-    }
-    logger.info("Backfill multi_demand_video_point completed: %s", result)
-    return result
-
-
-def main(batch_arg: str | None = None) -> dict:
-    batch_size = int(batch_arg) if batch_arg else _DEFAULT_BATCH_SIZE
-    result = backfill_video_points_table(batch_size=batch_size)
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    main(sys.argv[1] if len(sys.argv) > 1 else None)

+ 0 - 33
jobs/backfill_multi_demand_video_titles.py

@@ -1,33 +0,0 @@
-#!/usr/bin/env python3
-"""回填 multi_demand_video_detail.title(decode_result.target_post.title)。
-
-用法:
-    python jobs/backfill_multi_demand_video_titles.py
-    python jobs/backfill_multi_demand_video_titles.py 100   # 每批 100
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-
-from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
-    VIDEO_SYNC_BATCH_SIZE,
-    backfill_video_titles,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(batch_arg: str | None = None) -> dict:
-    batch_size = int(batch_arg) if batch_arg else VIDEO_SYNC_BATCH_SIZE
-    result = backfill_video_titles(batch_size=batch_size)
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    main(sys.argv[1] if len(sys.argv) > 1 else None)

+ 0 - 48
jobs/expand_demand_from_video_points.py

@@ -1,48 +0,0 @@
-#!/usr/bin/env python3
-"""手动执行 S/A 需求视频点位拓展任务。
-
-用法:
-    python jobs/expand_demand_from_video_points.py                  # 最新/当天 biz_dt
-    python jobs/expand_demand_from_video_points.py 20260721          # 指定业务日
-    python jobs/expand_demand_from_video_points.py 20260721 5        # 指定业务日 + 5 并发
-    python jobs/expand_demand_from_video_points.py 20260721 --force   # 忽略已完成记录重跑
-"""
-from __future__ import annotations
-
-import logging
-import sys
-
-from supply_infra.scheduler.jobs.expand_demand_from_video_points import (
-    expand_demand_from_video_points,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(
-    biz_dt: str | None = None,
-    workers_arg: str | None = None,
-    *,
-    skip_finished: bool = True,
-) -> dict:
-    workers = int(workers_arg) if workers_arg else 5
-    result = expand_demand_from_video_points(
-        biz_dt,
-        skip_finished=skip_finished,
-        workers=workers,
-    )
-    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]
-    skip_finished = "--force" not in args
-    main(biz_dt_arg, workers_arg, skip_finished=skip_finished)

+ 0 - 43
jobs/grade_demand_pool.py

@@ -1,43 +0,0 @@
-#!/usr/bin/env python3
-"""手动执行树热度驱动的需求分级。
-
-统筹规划 Agent 先落库当天全量节点组计划,再由多个 worker 领取任务并调用分级 Agent。
-
-用法:
-    python jobs/grade_demand_pool.py                  # 默认业务日、5 个 worker
-    python jobs/grade_demand_pool.py 20260716         # 指定业务日
-    python jobs/grade_demand_pool.py 20260716 5       # 指定业务日 + 5 个并发 worker
-"""
-from __future__ import annotations
-
-import logging
-import sys
-
-from supply_infra.scheduler.jobs.grade_demand_pool import grade_demand_pool
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(
-    biz_dt: str | None = None,
-    workers_arg: str | None = None,
-) -> dict:
-    workers = int(workers_arg) if workers_arg else 5
-
-    result = grade_demand_pool(
-        biz_dt,
-        workers=workers,
-        with_orchestrate=True,
-    )
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    main(
-        sys.argv[1] if len(sys.argv) > 1 else None,
-        sys.argv[2] if len(sys.argv) > 2 else None,
-    )

+ 0 - 45
jobs/retry_failed_grade_plan_items.py

@@ -1,45 +0,0 @@
-#!/usr/bin/env python3
-"""手动重试 demand_grade_plan_group_item 中失败的分级任务。
-
-用法:
-    python jobs/retry_failed_grade_plan_items.py
-    python jobs/retry_failed_grade_plan_items.py 20260721
-    python jobs/retry_failed_grade_plan_items.py 20260721 5
-    python jobs/retry_failed_grade_plan_items.py 20260721 5 --dry-run
-"""
-from __future__ import annotations
-
-import logging
-import sys
-
-from supply_infra.scheduler.jobs.grade_demand_pool import retry_failed_plan_group_items
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(
-    biz_dt: str | None = None,
-    workers_arg: str | None = None,
-    *,
-    dry_run: bool = False,
-) -> dict:
-    workers = int(workers_arg) if workers_arg else 5
-    result = retry_failed_plan_group_items(
-        biz_dt,
-        workers=workers,
-        dry_run=dry_run,
-    )
-    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]
-    main(biz_dt_arg, workers_arg, dry_run="--dry-run" in args)

+ 0 - 37
jobs/run_category_tree_rank_scores.py

@@ -1,37 +0,0 @@
-#!/usr/bin/env python3
-"""单独执行 category_tree_weight 四维排名归一化打分。
-
-用法:
-    python jobs/run_category_tree_rank_scores.py 20260714
-    python jobs/run_category_tree_rank_scores.py          # 默认当天
-
-前置: 指定 biz_dt 的 category_tree_weight 已全部写入(可先跑 run_category_tree_weight.py)。
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-from datetime import datetime
-
-from supply_infra.scheduler.jobs.update_category_tree_rank_scores import (
-    update_category_tree_rank_scores,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(biz_dt: str | None = None) -> dict:
-    if biz_dt is None:
-        biz_dt = datetime.now().strftime("%Y%m%d")
-    result = update_category_tree_rank_scores(biz_dt)
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
-    main(date_arg)

+ 0 - 38
jobs/run_category_tree_weight.py

@@ -1,38 +0,0 @@
-#!/usr/bin/env python3
-"""单独执行 category_tree_weight 整树节点加权平均计算,并在全部写入后更新四维排名分。
-
-用法:
-    python jobs/run_category_tree_weight.py 20260714
-    python jobs/run_category_tree_weight.py          # 默认当天
-
-前置: 已执行建表 SQL,且当天 demand_popularity_stats 已就绪。
-仅补跑排名分: python jobs/run_category_tree_rank_scores.py [biz_dt]
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-from datetime import datetime
-
-from supply_infra.scheduler.jobs.compute_category_tree_weight import (
-    compute_category_tree_weight,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(biz_dt: str | None = None) -> dict:
-    if biz_dt is None:
-        biz_dt = datetime.now().strftime("%Y%m%d")
-    result = compute_category_tree_weight(biz_dt)
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
-    main(date_arg)

+ 0 - 35
jobs/run_popularity_stats.py

@@ -1,35 +0,0 @@
-#!/usr/bin/env python3
-"""单独执行 demand_popularity_stats 热度统计。
-
-用法:
-    python jobs/run_popularity_stats.py 20260714
-    python jobs/run_popularity_stats.py          # 默认当天
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-from datetime import datetime
-
-from supply_infra.scheduler.jobs.sync_multi_demand_pool_odps_to_mysql import (
-    compute_popularity_stats,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(biz_dt: str | None = None) -> dict:
-    if biz_dt is None:
-        biz_dt = datetime.now().strftime("%Y%m%d")
-    result = compute_popularity_stats(biz_dt)
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    date_arg = sys.argv[1] if len(sys.argv) > 1 else None
-    main(date_arg)

+ 0 - 14
jobs/run_scheduler.py

@@ -1,14 +0,0 @@
-#!/usr/bin/env python3
-"""CLI entry point for the scheduler."""
-
-import logging
-
-from supply_infra.scheduler.app import run_scheduler
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-if __name__ == "__main__":
-    run_scheduler()

+ 0 - 22
jobs/run_supply_pipeline.py

@@ -1,22 +0,0 @@
-#!/usr/bin/env python3
-"""手动执行供给数据流水线(全局树 → 需求池 → 分级 → 视频点位拓展)。"""
-
-import logging
-import sys
-
-from supply_infra.scheduler.jobs.run_supply_pipeline import run_supply_pipeline
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main() -> None:
-    biz_dt = sys.argv[1] if len(sys.argv) > 1 else None
-    result = run_supply_pipeline(biz_dt)
-    print(result)
-
-
-if __name__ == "__main__":
-    main()

+ 0 - 29
jobs/sync_demand_belong_pool_rel.py

@@ -1,29 +0,0 @@
-#!/usr/bin/env python3
-"""手动同步 demand_belong_pool_rel,并回填 demand_belong_category.video_list。
-
-用法:
-    python jobs/sync_demand_belong_pool_rel.py
-"""
-
-from __future__ import annotations
-
-import logging
-
-from supply_infra.scheduler.jobs.sync_demand_belong_pool_rel import (
-    sync_demand_belong_pool_rel,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main() -> dict:
-    result = sync_demand_belong_pool_rel()
-    print(result)
-    return result
-
-
-if __name__ == "__main__":
-    main()

+ 0 - 57
jobs/sync_multi_demand_videos.py

@@ -1,57 +0,0 @@
-#!/usr/bin/env python3
-"""手动增量同步 multi_demand_video_detail(每批查询后立刻写入)。
-
-用法:
-    python jobs/sync_multi_demand_videos.py              # 只跑 1 批 100 个
-    python jobs/sync_multi_demand_videos.py 100           # 同上
-    python jobs/sync_multi_demand_videos.py 100 100       # 从 offset=100 起再跑 1 批
-    python jobs/sync_multi_demand_videos.py all           # 循环每批 100,查完写一批直到结束
-"""
-
-from __future__ import annotations
-
-import logging
-import sys
-
-from supply_infra.scheduler.jobs.sync_multi_demand_videos import (
-    VIDEO_SYNC_BATCH_SIZE,
-    sync_multi_demand_videos,
-)
-
-logging.basicConfig(
-    level=logging.INFO,
-    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
-)
-
-
-def main(limit_arg: str | None = None, offset_arg: str | None = None) -> dict:
-    if limit_arg is None:
-        limit: int | None = VIDEO_SYNC_BATCH_SIZE
-    elif limit_arg.lower() in {"all", "0", "-1"}:
-        limit = None
-    else:
-        limit = int(limit_arg)
-
-    offset = int(offset_arg) if offset_arg is not None else 0
-    result = sync_multi_demand_videos(
-        limit=limit,
-        offset=offset,
-        batch_size=VIDEO_SYNC_BATCH_SIZE,
-    )
-    print(result)
-
-    remaining = int(result.get("remaining") or 0)
-    next_offset = result.get("next_offset")
-    if remaining > 0 and next_offset is not None and limit is not None:
-        print(
-            f"还有 {remaining} 个未处理。下一批:\n"
-            f"  python jobs/sync_multi_demand_videos.py {limit} {next_offset}"
-        )
-    return result
-
-
-if __name__ == "__main__":
-    main(
-        sys.argv[1] if len(sys.argv) > 1 else None,
-        sys.argv[2] if len(sys.argv) > 2 else None,
-    )

+ 5 - 1
pyproject.toml

@@ -16,6 +16,8 @@ dependencies = [
     "rich>=13.0",
     "sqlalchemy>=2.0",
     "pymysql>=1.1",
+    "alembic>=1.13",
+    "requests>=2.32",
     "apscheduler>=3.10",
     "python-dotenv>=1.0",
     "oss2>=2.18.0",
@@ -26,6 +28,7 @@ dependencies = [
 
 [project.optional-dependencies]
 odps = ["pyodps>=0.12"]
+media = ["imageio-ffmpeg>=0.5.1"]
 dev = [
     "pytest>=8.0",
     "pytest-asyncio>=0.24",
@@ -36,9 +39,10 @@ dev = [
 packages = ["supply_agent", "supply_infra", "agents", "api"]
 
 [project.scripts]
-supply-scheduler = "supply_infra.scheduler.app:run_scheduler"
 supply-visualize = "supply_agent.logging.cli:main"
 supply-api = "api.run:main"
+supply-scheduler = "supply_infra.scheduler.__main__:main"
+supply-pipeline = "supply_infra.pipeline.cli:main"
 
 [tool.ruff]
 line-length = 100

+ 4 - 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
@@ -11,6 +13,8 @@ markdown>=3.6
 # Database
 sqlalchemy>=2.0
 pymysql>=1.1
+alembic>=1.13
+requests>=2.32
 
 # Scheduler
 apscheduler>=3.10

+ 0 - 162
scripts/backfill_demand_video_expansion_point_desc.py

@@ -1,162 +0,0 @@
-#!/usr/bin/env python3
-"""补全 demand_video_expansion 表中缺失的 point_desc。
-
-从 multi_demand_video_point 按 (video_id, point_type, expanded_text=point_data) 匹配;
-查不到则保持空值。
-
-用法:
-  .venv/bin/python scripts/backfill_demand_video_expansion_point_desc.py
-  .venv/bin/python scripts/backfill_demand_video_expansion_point_desc.py --biz-dt 20260721
-  .venv/bin/python scripts/backfill_demand_video_expansion_point_desc.py --biz-dt 20260721 --dry-run
-"""
-from __future__ import annotations
-
-import argparse
-import json
-import logging
-import sys
-from pathlib import Path
-from typing import Any
-
-from sqlalchemy import or_, select, update
-
-_ROOT = Path(__file__).resolve().parents[1]
-if str(_ROOT) not in sys.path:
-    sys.path.insert(0, str(_ROOT))
-
-from agents.demand_video_expand_agent.tools.batch_save_demand_expansions import (
-    _fill_missing_point_descs,
-)
-from supply_infra.db.models.demand_video_expansion import DemandVideoExpansion
-from supply_infra.db.session import get_session
-
-logger = logging.getLogger(__name__)
-_BATCH_SIZE = 500
-
-
-def _list_rows_missing_point_desc(biz_dt: str | None) -> list[dict[str, Any]]:
-    stmt = select(DemandVideoExpansion).where(
-        DemandVideoExpansion.is_delete == 0,
-        or_(
-            DemandVideoExpansion.point_desc.is_(None),
-            DemandVideoExpansion.point_desc == "",
-        ),
-    )
-    if biz_dt:
-        stmt = stmt.where(DemandVideoExpansion.biz_dt == biz_dt)
-    stmt = stmt.order_by(DemandVideoExpansion.id)
-
-    with get_session() as session:
-        rows = session.scalars(stmt).all()
-        return [
-            {
-                "id": int(row.id),
-                "biz_dt": str(row.biz_dt),
-                "video_id": str(row.video_id),
-                "point_type": str(row.point_type),
-                "expanded_text": str(row.expanded_text),
-                "point_desc": row.point_desc,
-            }
-            for row in rows
-        ]
-
-
-def backfill_missing_point_descs(
-    biz_dt: str | None = None,
-    *,
-    dry_run: bool = False,
-) -> dict[str, Any]:
-    rows = _list_rows_missing_point_desc(biz_dt)
-    result: dict[str, Any] = {
-        "biz_dt": biz_dt,
-        "dry_run": dry_run,
-        "missing_total": len(rows),
-        "filled": 0,
-        "still_empty": 0,
-        "updated": 0,
-        "samples": [],
-    }
-    if not rows:
-        return result
-
-    with get_session() as session:
-        _fill_missing_point_descs(rows, session)
-
-    to_update: list[dict[str, Any]] = []
-    for row in rows:
-        if row.get("point_desc"):
-            to_update.append(row)
-            result["filled"] += 1
-            if len(result["samples"]) < 10:
-                result["samples"].append(
-                    {
-                        "id": row["id"],
-                        "video_id": row["video_id"],
-                        "point_type": row["point_type"],
-                        "expanded_text": row["expanded_text"],
-                        "point_desc": row["point_desc"][:80]
-                        if len(str(row["point_desc"])) > 80
-                        else row["point_desc"],
-                    }
-                )
-        else:
-            result["still_empty"] += 1
-
-    if dry_run or not to_update:
-        result["updated"] = 0
-        return result
-
-    with get_session() as session:
-        for i in range(0, len(to_update), _BATCH_SIZE):
-            batch = to_update[i : i + _BATCH_SIZE]
-            for row in batch:
-                session.execute(
-                    update(DemandVideoExpansion)
-                    .where(DemandVideoExpansion.id == int(row["id"]))
-                    .values(point_desc=row["point_desc"])
-                )
-            result["updated"] += len(batch)
-
-    return result
-
-
-def main(argv: list[str] | None = None) -> int:
-    parser = argparse.ArgumentParser(
-        description="补全 demand_video_expansion 缺失的 point_desc",
-    )
-    parser.add_argument("--biz-dt", default=None, help="业务日期 YYYYMMDD,默认全表")
-    parser.add_argument("--dry-run", action="store_true", help="仅统计,不写库")
-    parser.add_argument("--json", action="store_true", help="以 JSON 输出结果")
-    args = parser.parse_args(argv)
-
-    logging.basicConfig(
-        level=logging.INFO,
-        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
-    )
-
-    result = backfill_missing_point_descs(args.biz_dt, dry_run=bool(args.dry_run))
-
-    if args.json:
-        print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
-    else:
-        print("\n=== point_desc 补全 ===")
-        print(f"biz_dt={result.get('biz_dt') or '全部'}")
-        print(f"dry_run={result.get('dry_run')}")
-        print(f"缺失记录={result.get('missing_total')}")
-        print(f"可补全={result.get('filled')}")
-        print(f"仍为空={result.get('still_empty')}")
-        print(f"已更新={result.get('updated')}")
-        if result.get("samples"):
-            print("\n示例:")
-            for item in result["samples"]:
-                print(
-                    f"  id={item['id']} video={item['video_id']} "
-                    f"type={item['point_type']} text={item['expanded_text']!r} "
-                    f"desc={item['point_desc']!r}"
-                )
-
-    return 0
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 8 - 0
scripts/container-entrypoint.sh

@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "${RUN_DB_MIGRATIONS:-true}" == "true" ]]; then
+  alembic upgrade head
+fi
+
+exec /usr/bin/supervisord -c /app/deploy/supervisord.pipeline.conf

+ 7 - 0
scripts/docker-deploy.sh

@@ -67,6 +67,13 @@ 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 --entrypoint python "$IMAGE_TAG" scripts/verify_video_truncate_runtime.py
+
+echo "==> Verifying pipeline imports and migration head"
+docker run --rm --entrypoint python "$IMAGE_TAG" -m compileall -q supply_infra api alembic
+docker run --rm --entrypoint alembic "$IMAGE_TAG" heads
+
 if [[ "$PUSH" == "true" ]]; then
   echo "==> Pushing: ${IMAGE_TAG}"
   docker push "$IMAGE_TAG"

+ 0 - 100
scripts/retry_failed_grade_plan_items.py

@@ -1,100 +0,0 @@
-#!/usr/bin/env python3
-"""重试 demand_grade_plan_group_item 中 status=failed 的分级任务。
-
-用法:
-  .venv/bin/python scripts/retry_failed_grade_plan_items.py
-  .venv/bin/python scripts/retry_failed_grade_plan_items.py --biz-dt 20260721
-  .venv/bin/python scripts/retry_failed_grade_plan_items.py --biz-dt 20260721 --workers 5
-  .venv/bin/python scripts/retry_failed_grade_plan_items.py --biz-dt 20260721 --dry-run
-  .venv/bin/python scripts/retry_failed_grade_plan_items.py --group-id 12 --group-id 15
-"""
-from __future__ import annotations
-
-import argparse
-import json
-import logging
-import sys
-from pathlib import Path
-
-_ROOT = Path(__file__).resolve().parents[1]
-if str(_ROOT) not in sys.path:
-    sys.path.insert(0, str(_ROOT))
-
-from supply_infra.scheduler.jobs.grade_demand_pool import retry_failed_plan_group_items
-from supply_infra.scheduler.plan_group_batch import MAX_DEMANDS_PER_BATCH
-
-logger = logging.getLogger(__name__)
-
-
-def main(argv: list[str] | None = None) -> int:
-    parser = argparse.ArgumentParser(
-        description="重试 demand_grade_plan_group_item 中失败的分级任务",
-    )
-    parser.add_argument("--biz-dt", default=None, help="业务日期 YYYYMMDD,默认当天")
-    parser.add_argument("--workers", type=int, default=5, help="并发执行的 plan_group 数")
-    parser.add_argument(
-        "--max-demands-per-batch",
-        type=int,
-        default=MAX_DEMANDS_PER_BATCH,
-        help=f"每个 Agent 子批次最多处理的需求条数,默认 {MAX_DEMANDS_PER_BATCH}",
-    )
-    parser.add_argument(
-        "--group-id",
-        type=int,
-        action="append",
-        dest="group_ids",
-        help="仅重试指定 group_id,可重复传入",
-    )
-    parser.add_argument(
-        "--dry-run",
-        action="store_true",
-        help="仅列出将要重试的 failed 记录,不实际执行",
-    )
-    parser.add_argument("--json", action="store_true", help="以 JSON 打印结果")
-    args = parser.parse_args(argv)
-
-    logging.basicConfig(
-        level=logging.INFO,
-        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
-    )
-
-    result = retry_failed_plan_group_items(
-        args.biz_dt,
-        workers=max(1, int(args.workers)),
-        max_demands_per_batch=max(1, min(int(args.max_demands_per_batch), MAX_DEMANDS_PER_BATCH)),
-        group_ids=args.group_ids,
-        dry_run=bool(args.dry_run),
-    )
-
-    if args.json:
-        print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
-    else:
-        reset = result.get("reset") or {}
-        print("\n=== 失败任务重试 ===")
-        print(f"biz_dt={result.get('biz_dt')}")
-        print(f"dry_run={result.get('dry_run')}")
-        print(f"failed_items={result.get('failed_items', 0)}")
-        print(f"reset_items={reset.get('reset_items', 0)}")
-        print(f"reset_groups={reset.get('reset_groups', 0)}")
-        if reset.get("group_ids"):
-            print(f"group_ids={reset.get('group_ids')}")
-        if not result.get("dry_run"):
-            print(f"graded: {result.get('graded_before')} -> {result.get('graded_after')}")
-            print(f"remaining_failed={result.get('remaining_failed', 0)}")
-            print(f"group_status={result.get('group_status')}")
-            print(f"success={result.get('success')}")
-        elif reset.get("items"):
-            print("\n待重试明细:")
-            for item in reset["items"][:20]:
-                print(
-                    f"  item_id={item['item_id']} group_id={item['group_id']} "
-                    f"demand={item['demand_name']!r}"
-                )
-            if len(reset["items"]) > 20:
-                print(f"  ... 另有 {len(reset['items']) - 20} 条")
-
-    return 0 if result.get("success") else 1
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

+ 0 - 85
scripts/run_grade_plan_groups.py

@@ -1,85 +0,0 @@
-#!/usr/bin/env python3
-"""批量执行 demand_grade_plan_group 分级任务。
-
-与定时任务共用 supply_infra.scheduler.jobs.grade_demand_pool.grade_demand_pool。
-
-Usage:
-  .venv/bin/python scripts/run_grade_plan_groups.py
-  .venv/bin/python scripts/run_grade_plan_groups.py --biz-dt 20260721
-  .venv/bin/python scripts/run_grade_plan_groups.py --biz-dt 20260721 --workers 5
-  .venv/bin/python scripts/run_grade_plan_groups.py --biz-dt 20260721 --with-orchestrate
-"""
-from __future__ import annotations
-
-import argparse
-import json
-import logging
-import sys
-from pathlib import Path
-
-_ROOT = Path(__file__).resolve().parents[1]
-if str(_ROOT) not in sys.path:
-    sys.path.insert(0, str(_ROOT))
-
-from supply_infra.scheduler.plan_group_batch import MAX_DEMANDS_PER_BATCH
-from supply_infra.scheduler.jobs.grade_demand_pool import grade_demand_pool
-
-logger = logging.getLogger(__name__)
-
-
-def main(argv: list[str] | None = None) -> int:
-    parser = argparse.ArgumentParser(description="批量执行 demand_grade_plan_group 分级任务")
-    parser.add_argument("--biz-dt", default="20260721", help="业务日期 YYYYMMDD,默认 20260721")
-    parser.add_argument(
-        "--max-demands-per-batch",
-        type=int,
-        default=MAX_DEMANDS_PER_BATCH,
-        help=f"每个 Agent 子批次最多处理的需求条数,默认 {MAX_DEMANDS_PER_BATCH}",
-    )
-    parser.add_argument("--workers", type=int, default=5, help="并发执行的 plan_group 数")
-    parser.add_argument(
-        "--max-rounds",
-        type=int,
-        default=0,
-        help="最多执行轮数,0 表示直到没有 pending 任务",
-    )
-    parser.add_argument(
-        "--with-orchestrate",
-        action="store_true",
-        help="执行前先跑统筹 Agent 生成/补充计划",
-    )
-    parser.add_argument(
-        "--json",
-        action="store_true",
-        help="最终以 JSON 打印摘要",
-    )
-    args = parser.parse_args(argv)
-
-    logging.basicConfig(
-        level=logging.INFO,
-        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
-    )
-
-    result = grade_demand_pool(
-        str(args.biz_dt).strip(),
-        workers=max(1, int(args.workers)),
-        max_demands_per_batch=max(1, min(int(args.max_demands_per_batch), MAX_DEMANDS_PER_BATCH)),
-        with_orchestrate=bool(args.with_orchestrate),
-        max_rounds=max(0, int(args.max_rounds)),
-    )
-
-    if args.json:
-        print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
-    else:
-        print("\n=== 批量分级完成 ===")
-        print(f"biz_dt={result.get('biz_dt')}")
-        print(f"完成任务组={result.get('groups_run')}")
-        print(f"已分级: {result.get('graded_before')} -> {result.get('graded_after')}")
-        print(f"任务状态: {(result.get('group_status') or result.get('plan_execution', {}).get('final_snapshot', {}).get('group_status'))}")
-        print(f"是否全部完成: {result.get('success')}")
-
-    return 0 if result.get("success") else 1
-
-
-if __name__ == "__main__":
-    raise SystemExit(main())

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

+ 22 - 0
sql/video_discovery_add_audit_evidence.sql

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

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

+ 10 - 0
sql/video_discovery_drop_unused_columns.sql

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

+ 120 - 0
sql/video_discovery_tables.sql

@@ -0,0 +1,120 @@
+-- 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 '主推荐数',
+  `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 '抖音页面链接',
+  `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 '是否已执行年龄画像标准化',
+  `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 / 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='本次运行发现的视频及评估快照';

+ 9 - 3
supply_agent/agent/core.py

@@ -150,15 +150,21 @@ class Agent:
         self._finish_run(result)
         return result
 
-    async def arun(
+    async def arun_core(
         self, user_input: str, *, history: list[Message] | None = None
     ) -> AgentResult:
-        """Run the agent asynchronously."""
+        """Run the agent loop without closing logs or publishing artifacts."""
         self.logger.start_run(user_input, model=self.model, agent_name=self.name)
         messages = list(history or [])
         messages.append(Message(role=Role.USER, content=user_input))
         loop = self._create_loop(messages)
-        result = await loop.arun()
+        return await loop.arun()
+
+    async def arun(
+        self, user_input: str, *, history: list[Message] | None = None
+    ) -> AgentResult:
+        """Run the agent asynchronously."""
+        result = await self.arun_core(user_input, history=history)
         self._finish_run(result)
         return result
 

+ 214 - 152
supply_agent/agent/loop.py

@@ -2,9 +2,9 @@ from __future__ import annotations
 
 import json
 from collections.abc import AsyncIterator, Callable, Iterator
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
 
-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,17 +12,25 @@ from supply_agent.types import (
     AgentResult,
     Message,
     Role,
+    ToolCall,
+    ToolDefinition,
+    ToolResult,
 )
 
 if TYPE_CHECKING:
     from supply_agent.logging.logger import AgentLogger
 
+# Outcome of one assistant turn before tools / completion.
+_TurnKind = Literal["done", "tools"]
+
 
 class AgentLoop:
     """
     ReAct-style agent loop: Reason → Act (tool call) → Observe → Repeat.
 
-    Implements the standard tool-calling pattern used by modern agent frameworks.
+    Sync/async and stream/non-stream entry points share the same turn and
+    tool-handling logic so skill injection and max-iteration behavior stay
+    consistent.
     """
 
     def __init__(
@@ -45,7 +53,8 @@ class AgentLoop:
         self.max_iterations = max_iterations
         self.temperature = temperature
         self.logger = logger
-        self.active_skills = active_skills or []
+        # Use `is not None` so an empty shared list from Agent is kept by identity.
+        self.active_skills = active_skills if active_skills is not None else []
         self.tool_calls_made = 0
 
     def _all_messages(self) -> list[Message]:
@@ -64,50 +73,85 @@ class AgentLoop:
         if self.system_message_builder:
             self.system_message = self.system_message_builder()
 
-    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,
+    def _append_malformed_tool_feedback(self) -> None:
+        self.messages.append(
+            Message(
+                role=Role.USER,
+                content=(
+                    "上一步连续生成了无效工具参数。请继续任务,下一步只调用一个"
+                    "最必要的工具,严格使用其 schema,不得添加未定义字段。"
+                ),
             )
-            self.messages.append(response)
+        )
 
-            if not response.tool_calls:
-                return self._build_result(response.content or "", iterations)
+    def _available_tool_definitions(self) -> list[ToolDefinition] | None:
+        return self.tools.definitions or None
 
-            for tc in response.tool_calls:
-                self.tool_calls_made += 1
-                result = self.tools.execute(tc.id, tc.name, tc.arguments)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
-                if tc.name == "load_skill" and not result.is_error:
-                    self._on_skill_loaded(tc.arguments)
-                    if self.logger:
-                        self.logger.log_skill_loaded(iterations, tc.arguments)
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
-                )
+    def _build_result(self, content: str, iterations: int) -> AgentResult:
+        return AgentResult(
+            content=content,
+            messages=self.messages,
+            iterations=iterations,
+            tool_calls_made=self.tool_calls_made,
+            skills_used=list(self.active_skills),
+        )
+
+    def _record_tool_result(
+        self,
+        tool_call: ToolCall,
+        result: ToolResult,
+        iterations: int,
+    ) -> None:
+        if self.logger:
+            self.logger.log_tool_call(
+                iterations,
+                tool_call.name,
+                tool_call.arguments,
+                result.content,
+                result.is_error,
+                tool_call_id=tool_call.id,
+            )
+        if tool_call.name == "load_skill" and not result.is_error:
+            self._on_skill_loaded(tool_call.arguments)
+            if self.logger:
+                self.logger.log_skill_loaded(iterations, tool_call.arguments)
+        self.messages.append(
+            Message(
+                role=Role.TOOL,
+                content=result.content,
+                tool_call_id=result.tool_call_id,
+                name=result.name,
+            )
+        )
+
+    def _resolve_tool_call_sync(self, tool_call: ToolCall) -> ToolResult:
+        return self.tools.execute(
+            tool_call.id, tool_call.name, tool_call.arguments
+        )
+
+    async def _resolve_tool_call_async(self, tool_call: ToolCall) -> ToolResult:
+        return await self.tools.aexecute(
+            tool_call.id, tool_call.name, tool_call.arguments
+        )
 
+    def _handle_assistant_message(
+        self,
+        response: Message,
+        iterations: int,
+    ) -> tuple[_TurnKind, AgentResult | None]:
+        """Classify an assistant turn: finish, or run tools."""
+        self.messages.append(response)
+        if response.tool_calls:
+            return "tools", None
+        return "done", self._build_result(response.content or "", iterations)
+
+    def _nudge_best_answer_sync(self, iterations: int) -> AgentResult:
         self.messages.append(
             Message(
                 role=Role.USER,
-                content="Maximum iterations reached. Please provide your best answer now.",
+                content=(
+                    "Maximum iterations reached. Please provide your best answer now."
+                ),
             )
         )
         final = self.llm.chat(
@@ -118,50 +162,13 @@ class AgentLoop:
         self.messages.append(final)
         return self._build_result(final.content or "", iterations)
 
-    async def arun(self) -> AgentResult:
-        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,
-            )
-            self.messages.append(response)
-
-            if not response.tool_calls:
-                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)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
-                if tc.name == "load_skill" and not result.is_error:
-                    self._on_skill_loaded(tc.arguments)
-                    if self.logger:
-                        self.logger.log_skill_loaded(iterations, tc.arguments)
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
-                )
-
+    async def _nudge_best_answer_async(self, iterations: int) -> AgentResult:
         self.messages.append(
             Message(
                 role=Role.USER,
-                content="Maximum iterations reached. Please provide your best answer now.",
+                content=(
+                    "Maximum iterations reached. Please provide your best answer now."
+                ),
             )
         )
         final = await self.llm.achat(
@@ -172,6 +179,60 @@ class AgentLoop:
         self.messages.append(final)
         return self._build_result(final.content or "", iterations)
 
+    def run(self) -> AgentResult:
+        iterations = 0
+        while iterations < self.max_iterations:
+            iterations += 1
+            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
+
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
+                return done
+
+            assert response.tool_calls is not None
+            for tc in response.tool_calls:
+                self.tool_calls_made += 1
+                result = self._resolve_tool_call_sync(tc)
+                self._record_tool_result(tc, result, iterations)
+
+        return self._nudge_best_answer_sync(iterations)
+
+    async def arun(self) -> AgentResult:
+        iterations = 0
+        while iterations < self.max_iterations:
+            iterations += 1
+            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
+
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
+                return done
+
+            assert response.tool_calls is not None
+            for tc in response.tool_calls:
+                self.tool_calls_made += 1
+                result = await self._resolve_tool_call_async(tc)
+                self._record_tool_result(tc, result, iterations)
+
+        return await self._nudge_best_answer_async(iterations)
+
     def stream(self) -> Iterator[AgentEvent]:
         iterations = 0
         while iterations < self.max_iterations:
@@ -181,55 +242,60 @@ class AgentLoop:
                 data={"iteration": iterations},
             )
 
-            response = self.llm.chat(
-                self._all_messages(),
-                tools=self.tools.definitions or None,
-                temperature=self.temperature,
-                iteration=iterations,
-            )
-            self.messages.append(response)
+            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
 
-            if not response.tool_calls:
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
-                    data={"content": response.content or ""},
+                    data={"content": done.content},
                 )
                 yield AgentEvent(
                     type=AgentEventType.DONE,
-                    data=self._build_result(response.content or "", iterations).model_dump(),
+                    data=done.model_dump(),
                 )
                 return
 
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
-                    data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
+                    data={
+                        "name": tc.name,
+                        "arguments": tc.arguments,
+                        "id": tc.id,
+                    },
                 )
-                result = self.tools.execute(tc.id, tc.name, tc.arguments)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
+                result = self._resolve_tool_call_sync(tc)
+                self._record_tool_result(tc, result, iterations)
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,
-                    data={"name": result.name, "content": result.content, "is_error": result.is_error},
-                )
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
+                    data={
+                        "name": result.name,
+                        "content": result.content,
+                        "is_error": result.is_error,
+                    },
                 )
 
-        yield AgentEvent(type=AgentEventType.DONE, data={"content": "Max iterations reached"})
+        final = self._nudge_best_answer_sync(iterations)
+        yield AgentEvent(
+            type=AgentEventType.MESSAGE,
+            data={"content": final.content},
+        )
+        yield AgentEvent(
+            type=AgentEventType.DONE,
+            data=final.model_dump(),
+        )
 
     async def astream(self) -> AsyncIterator[AgentEvent]:
         iterations = 0
@@ -240,61 +306,57 @@ class AgentLoop:
                 data={"iteration": iterations},
             )
 
-            response = await self.llm.achat(
-                self._all_messages(),
-                tools=self.tools.definitions or None,
-                temperature=self.temperature,
-                iteration=iterations,
-            )
-            self.messages.append(response)
+            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
 
-            if not response.tool_calls:
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
-                    data={"content": response.content or ""},
+                    data={"content": done.content},
                 )
                 yield AgentEvent(
                     type=AgentEventType.DONE,
-                    data=self._build_result(response.content or "", iterations).model_dump(),
+                    data=done.model_dump(),
                 )
                 return
 
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
-                    data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
+                    data={
+                        "name": tc.name,
+                        "arguments": tc.arguments,
+                        "id": tc.id,
+                    },
                 )
-                result = await self.tools.aexecute(tc.id, tc.name, tc.arguments)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
+                result = await self._resolve_tool_call_async(tc)
+                self._record_tool_result(tc, result, iterations)
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,
-                    data={"name": result.name, "content": result.content, "is_error": result.is_error},
+                    data={
+                        "name": result.name,
+                        "content": result.content,
+                        "is_error": result.is_error,
+                    },
                 )
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
-                )
-
-        yield AgentEvent(type=AgentEventType.DONE, data={"content": "Max iterations reached"})
 
-    def _build_result(self, content: str, iterations: int) -> AgentResult:
-        return AgentResult(
-            content=content,
-            messages=self.messages,
-            iterations=iterations,
-            tool_calls_made=self.tool_calls_made,
-            skills_used=list(self.active_skills),
+        final = await self._nudge_best_answer_async(iterations)
+        yield AgentEvent(
+            type=AgentEventType.MESSAGE,
+            data={"content": final.content},
+        )
+        yield AgentEvent(
+            type=AgentEventType.DONE,
+            data=final.model_dump(),
         )

+ 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

+ 7 - 1
supply_agent/logging/__init__.py

@@ -2,7 +2,11 @@
 
 from supply_agent.logging.logger import AgentLogger, get_agent_logger, slugify_agent_name
 from supply_agent.logging.parser import load_run_events, summarize_run
-from supply_agent.logging.publish import publish_run_artifacts
+from supply_agent.logging.publish import (
+    get_run_artifact_publisher,
+    publish_run_artifacts,
+    set_run_artifact_publisher,
+)
 from supply_agent.logging.visualize import generate_visualization, render_html
 
 __all__ = [
@@ -12,6 +16,8 @@ __all__ = [
     "load_run_events",
     "summarize_run",
     "publish_run_artifacts",
+    "set_run_artifact_publisher",
+    "get_run_artifact_publisher",
     "generate_visualization",
     "render_html",
 ]

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů