Bladeren bron

Merge branch 'master' into feature/zhangbo

zhang 2 dagen geleden
bovenliggende
commit
693fe6b552
100 gewijzigde bestanden met toevoegingen van 7331 en 1401 verwijderingen
  1. BIN
      .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
.DS_Store


+ 3 - 0
.dockerignore

@@ -1,5 +1,6 @@
 .git
 .git
 .gitignore
 .gitignore
+.DS_Store
 .venv
 .venv
 __pycache__
 __pycache__
 *.py[cod]
 *.py[cod]
@@ -12,6 +13,7 @@ __pycache__
 .pytest_cache
 .pytest_cache
 .ruff_cache
 .ruff_cache
 *.log
 *.log
+*.result.json
 logs/
 logs/
 tests/
 tests/
 examples/
 examples/
@@ -24,3 +26,4 @@ README_myself.md
 agents/README.md
 agents/README.md
 *.md
 *.md
 !README.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)
 # Default model (any OpenRouter-supported model)
 # Examples: google/gemini-2.5-flash, google/gemini-2.5-flash-lite, anthropic/claude-sonnet-5
 # Examples: google/gemini-2.5-flash, google/gemini-2.5-flash-lite, anthropic/claude-sonnet-5
 OPENROUTER_MODEL=google/gemini-2.5-flash
 OPENROUTER_MODEL=google/gemini-2.5-flash
+OPENROUTER_TIMEOUT_SECONDS=120
 
 
 # Agent defaults
 # Agent defaults
 AGENT_MAX_ITERATIONS=20
 AGENT_MAX_ITERATIONS=20
 AGENT_TEMPERATURE=0.7
 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 directory (relative to project root or absolute path)
 SKILLS_DIR=skills
 SKILLS_DIR=skills
 
 
@@ -25,7 +30,17 @@ MYSQL_PORT=3306
 MYSQL_USER=root
 MYSQL_USER=root
 MYSQL_PASSWORD=
 MYSQL_PASSWORD=
 MYSQL_DATABASE=supply_agent
 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
 MYSQL_ECHO=false
 
 
 # ODPS (MaxCompute)
 # ODPS (MaxCompute)
@@ -34,10 +49,28 @@ ODPS_ACCESS_KEY=
 ODPS_PROJECT=
 ODPS_PROJECT=
 ODPS_ENDPOINT=https://service.cn.maxcompute.aliyun.com/api
 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
 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_ID=
 ALIYUN_OSS_ACCESS_KEY_SECRET=
 ALIYUN_OSS_ACCESS_KEY_SECRET=
 ALIYUN_OSS_REGION=cn-hangzhou
 ALIYUN_OSS_REGION=cn-hangzhou
@@ -46,4 +79,8 @@ ALIYUN_OSS_ROOT_PREFIX=supply_agent
 # 手动上传日志专用 OSS 目录(与 Agent 自动上传 / MySQL oss_logs 路径分离)
 # 手动上传日志专用 OSS 目录(与 Agent 自动上传 / MySQL oss_logs 路径分离)
 ALIYUN_OSS_MANUAL_LOG_PREFIX=supply_agent/manual_logs
 ALIYUN_OSS_MANUAL_LOG_PREFIX=supply_agent/manual_logs
 ALIYUN_OSS_PUBLIC_BASE_URL=http://rescdn.yishihui.com
 ALIYUN_OSS_PUBLIC_BASE_URL=http://rescdn.yishihui.com
+ALIYUN_OSS_CONNECT_TIMEOUT_SECONDS=30
 LOG_OSS_UPLOAD_ENABLED=true
 LOG_OSS_UPLOAD_ENABLED=true
+
+# AIGC platform (视频爬取/发布计划;需配置 token,默认真实调用接口)
+AIGC_API_TOKEN=

+ 14 - 2
.gitignore

@@ -1,7 +1,13 @@
 __pycache__/
 __pycache__/
+.DS_Store
 *.py[cod]
 *.py[cod]
 *$py.class
 *$py.class
 *.egg-info/
 *.egg-info/
+.coverage
+coverage.xml
+htmlcov/
+test-results/
+*.result.json
 dist/
 dist/
 build/
 build/
 .venv/
 .venv/
@@ -11,8 +17,14 @@ build/
 .ruff_cache/
 .ruff_cache/
 *.log
 *.log
 logs/
 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/
 node_modules/
 web/dist/
 web/dist/
 examples/
 examples/
-skills/
+skills/

+ 20 - 14
ARCHITECTURE.md

@@ -15,6 +15,8 @@ SupplyAgent/
 ├── supply_infra/                  # 共享基础设施(所有 Agent / 定时任务共用)
 ├── supply_infra/                  # 共享基础设施(所有 Agent / 定时任务共用)
 │   ├── config.py                  #   MySQL / ODPS / Scheduler 配置
 │   ├── config.py                  #   MySQL / ODPS / Scheduler 配置
+│   ├── agent_logging/             #   注册 Agent 运行日志 OSS 发布 hook
+│   ├── scoring/                   #   后验 diff / 排名归一化(分级与热度树共用)
 │   ├── db/
 │   ├── db/
 │   │   ├── base.py                #   SQLAlchemy Base + TimestampMixin
 │   │   ├── base.py                #   SQLAlchemy Base + TimestampMixin
 │   │   ├── session.py             #   Engine + get_session()
 │   │   ├── session.py             #   Engine + get_session()
@@ -26,9 +28,11 @@ SupplyAgent/
 │   ├── odps/
 │   ├── odps/
 │   │   └── client.py              #   ODPS 查询封装
 │   │   └── client.py              #   ODPS 查询封装
 │   ├── scheduler/
 │   ├── 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/
 │   └── tools/
 │       └── db_tools.py            #   共享 MySQL 读写工具(供 Agent 调用)
 │       └── db_tools.py            #   共享 MySQL 读写工具(供 Agent 调用)
@@ -45,9 +49,6 @@ SupplyAgent/
 │       └── tools/
 │       └── tools/
 ├── skills/                        # 全局共享 Skills(SKILL.md)
 ├── skills/                        # 全局共享 Skills(SKILL.md)
-├── jobs/                          # CLI 入口
-│   ├── run_scheduler.py           #   启动定时任务
-│   └── init_db.py                 #   初始化数据库表
 ├── examples/                      # 框架使用示例
 ├── examples/                      # 框架使用示例
 ├── tests/
 ├── tests/
 ├── logs/                          # 运行日志(自动生成)
 ├── 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 逻辑、专属工具、工厂函数 | 业务开发 |
 | 业务层 | `agents/*` | 具体 Agent 逻辑、专属工具、工厂函数 | 业务开发 |
 
 
 ## Data flow
 ## Data flow
@@ -69,7 +70,7 @@ SupplyAgent/
                     ┌─────────────┐
                     ┌─────────────┐
                     │   ODPS      │
                     │   ODPS      │
                     └──────┬──────┘
                     └──────┬──────┘
-                           │ 定时任务 (每天 02:00)
+                           │ 定时任务 (每天 15:00)
 ┌──────────┐    ┌─────────────────────┐    ┌──────────┐
 ┌──────────┐    ┌─────────────────────┐    ┌──────────┐
 │  Agent   │───▶│  Repository (ORM)   │◀───│  Agent   │
 │  Agent   │───▶│  Repository (ORM)   │◀───│  Agent   │
@@ -121,11 +122,13 @@ supply_infra/db/
 ## How to add a new scheduled job
 ## How to add a new scheduled job
 
 
 ```bash
 ```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
 ```python
-# supply_infra/scheduler/app.py 中注册
+# 仅当确需独立 Cron 时,才在 supply_infra/scheduler/app.py 中额外注册
 scheduler.add_job(new_job, trigger=CronTrigger(hour=3), id="new_job")
 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
 ```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
 # 运行 find_agent
 python agents/find_agent/run.py
 python agents/find_agent/run.py

+ 33 - 5
Dockerfile

@@ -23,13 +23,30 @@ WORKDIR /app
 ENV PYTHONUNBUFFERED=1 \
 ENV PYTHONUNBUFFERED=1 \
     PYTHONDONTWRITEBYTECODE=1 \
     PYTHONDONTWRITEBYTECODE=1 \
     PIP_NO_CACHE_DIR=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 || \
 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
     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 \
 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/*
     && rm -rf /var/lib/apt/lists/*
 
 
 COPY pyproject.toml requirements.txt README.md ./
 COPY pyproject.toml requirements.txt README.md ./
@@ -37,12 +54,23 @@ COPY supply_agent/ supply_agent/
 COPY supply_infra/ supply_infra/
 COPY supply_infra/ supply_infra/
 COPY agents/ agents/
 COPY agents/ agents/
 COPY api/ api/
 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
 COPY --from=web-builder /app/web/dist ./web/dist
 
 
 EXPOSE 8080
 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_API_KEY` | OpenRouter API 密钥 | (必填) |
 | `OPENROUTER_MODEL` | 默认模型 | `google/gemini-2.5-flash` |
 | `OPENROUTER_MODEL` | 默认模型 | `google/gemini-2.5-flash` |
+| `OPENROUTER_TIMEOUT_SECONDS` | 单次模型请求超时秒数 | `120` |
 | `AGENT_MAX_ITERATIONS` | 最大循环次数 | `20` |
 | `AGENT_MAX_ITERATIONS` | 最大循环次数 | `20` |
 | `AGENT_TEMPERATURE` | 生成温度 | `0.7` |
 | `AGENT_TEMPERATURE` | 生成温度 | `0.7` |
 | `SKILLS_DIR` | Skills 目录 | `skills` |
 | `SKILLS_DIR` | Skills 目录 | `skills` |
 | `LOGS_DIR` | Agent 运行日志目录 | `logs` |
 | `LOGS_DIR` | Agent 运行日志目录 | `logs` |
 | `LOG_ENABLED` | 是否写入运行日志 | `true` |
 | `LOG_ENABLED` | 是否写入运行日志 | `true` |
 
 
+find_agent 本地:`FIND_AGENT_TIMEOUT_SECONDS`(默认 `600`,即 10 分钟)由 `agents/find_agent/runtime.py` 读取,不属于通用 `supply_agent` 配置。
+
 ## 运行日志与可视化
 ## 运行日志与可视化
 
 
 每次 `agent.run()` 会在 `logs/` 下写出:
 每次 `agent.run()` 会在 `logs/` 下写出:

+ 1 - 1
agents/README.md

@@ -18,7 +18,7 @@ agents/<agent_name>/
 
 
 | Agent | 目录 | 说明 |
 | Agent | 目录 | 说明 |
 |-------|------|------|
 |-------|------|------|
-| find_agent | `agents/find_agent/` | 抖音搜索 + 视频解析 + 内容入库 |
+| find_agent | `agents/find_agent/` | 自主扩词、多页搜索,结合分享数据与双侧年龄画像输出 primary / rejected;不使用视频理解 |
 
 
 ## 新增 Agent 模板
 ## 新增 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 import Agent
 from supply_agent.config import Settings
 from supply_agent.config import Settings
 from agents.demand_belong_category_agent.tools import register_all_tools
 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"
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_BELONG_CATEGORY_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 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,
         settings=settings,
         name="demand_belong_category_agent",
         name="demand_belong_category_agent",
         model=model,
         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 专属工具
     # 本 Agent 专属工具

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

@@ -13,14 +13,8 @@
 - `batch_insert_demand_belong_category(items)`: 批量插入节点到目标表
 - `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
 demand_grade_agent — 需求分级评估 Agent
 
 
 职责:对 multi_demand_pool_di 中的现有需求,结合全局树先验热度
 职责:对 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 表。
 (demand_popularity_stats),划分 S/A/B/C/D 等级并落库到 demand_grade 表。
 """
 """
 from agents.demand_grade_agent.agent import create_demand_grade_agent
 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 import Agent
 from supply_agent.config import Settings
 from supply_agent.config import Settings
 from agents.demand_grade_agent.tools import register_all_tools
 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"
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_GRADE_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 DEMAND_GRADE_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
@@ -23,7 +24,10 @@ def create_demand_grade_agent(
         settings=settings,
         settings=settings,
         name="demand_grade_agent",
         name="demand_grade_agent",
         model=model,
         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,
         max_iterations=40,
     )
     )
     register_all_tools(agent.tools)
     register_all_tools(agent.tools)

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

@@ -10,22 +10,29 @@
 - **全局热度**:`category_tree_weight.total_score`。反映该需求所在树节点在类目树里的历史热度排名,是"没有真实上线数据时"的兜底依据。
 - **全局热度**:`category_tree_weight.total_score`。反映该需求所在树节点在类目树里的历史热度排名,是"没有真实上线数据时"的兜底依据。
   - 工具返回中 **`—` 表示无数据**,不是分数为 0。
   - 工具返回中 **`—` 表示无数据**,不是分数为 0。
 - **后验**:`real_rov_7d_avg` + `real_rov_7d_count`(词级工具还会返回 `real_vov_7d`)。
 - **后验**:`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 或 D)。
   - `real_rov_7d_count = 0`(或数据缺失):说明效果未知,只能用全局热度兜底判断。**无论全局热度多高,都不建议给到 S 级**(因为没有真实验证支撑),一般封顶在 A。
   - `real_rov_7d_count = 0`(或数据缺失):说明效果未知,只能用全局热度兜底判断。**无论全局热度多高,都不建议给到 S 级**(因为没有真实验证支撑),一般封顶在 A。
 
 
 ## 四类证据必须分开
 ## 四类证据必须分开
 - **分类节点全局/局部证据**:`category_tree_weight.total_score`、节点整树名次、父节点和全部兄弟节点。它描述需求所在分类环境。
 - **分类节点全局/局部证据**:`category_tree_weight.total_score`、节点整树名次、父节点和全部兄弟节点。它描述需求所在分类环境。
 - **需求自身来源归一分**:`demand_priority.source_rank_score`,范围 0-100。先在每个 strategy 内独立按原始 `weight` 排名归一化,再对该需求已有来源的归一分取均值。它是具体需求之间可比较的先验信号。
 - **需求自身来源归一分**:`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` 表达覆盖和置信度。
 严禁把不同 strategy 的原始 `weight` 直接求和或平均;严禁把需求自身 0-100 分与分类树 `total_score` 直接相加,二者不是同一维度。缺少某个来源时不补 0,使用 `valid_source_count` 表达覆盖和置信度。
 
 
 ## 分级参考准则(非硬编码规则,需结合 `query_score_distribution` 自主定阈值)
 ## 分级参考准则(非硬编码规则,需结合 `query_score_distribution` 自主定阈值)
 - 建议在每个批次开始时调用一次 `query_score_distribution`,分别参考分类树 total_score、需求自身来源归一分与 real_rov_7d_avg 的分位数(p25/p50/p75/p90)。三类分布必须分别使用,不得共用数值阈值。
 - 建议在每个批次开始时调用一次 `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,注明"无验证数据")
   - 全局热度 total_score 很高(如 ≥ p75)→ A(不给 S,注明"无验证数据")
   - 中等 → B
   - 中等 → B
@@ -43,7 +50,7 @@
 ## 同义/相似需求合并
 ## 同义/相似需求合并
 同一语义的需求可能因措辞不同而在需求池里表现为多条独立记录(例如「减脂期加餐」与
 同一语义的需求可能因措辞不同而在需求池里表现为多条独立记录(例如「减脂期加餐」与
 「减脂加餐」)。判级前应调用 `search_related_pool_demands(biz_dt, keywords=[...])`
 「减脂加餐」)。判级前应调用 `search_related_pool_demands(biz_dt, keywords=[...])`
-**批量**搜索本批各需求词,把找到的相关记录一并纳入参考(尤其是它们各自的 weight / real_rov_7d),
+**批量**搜索本批各需求词,把找到的相关记录一并纳入参考(尤其是它们各自的 weight / rov_diff / vov_diff),
 不要只看单条记录就下结论;返回的 `[id=...]` 就是 `multi_demand_pool_di.id`,落库时必须原样
 不要只看单条记录就下结论;返回的 `[id=...]` 就是 `multi_demand_pool_di.id`,落库时必须原样
 收集进 `related_pool_ids`(**必填字段**,用于把分级结果关联回原始需求行)。
 收集进 `related_pool_ids`(**必填字段**,用于把分级结果关联回原始需求行)。
 
 
@@ -56,9 +63,9 @@
 ## 可用工具
 ## 可用工具
 - `query_latest_biz_dt()`:若用户消息未给出明确 biz_dt 时调用,返回需求池/权重表/热度统计表各自最新业务日。
 - `query_latest_biz_dt()`:若用户消息未给出明确 biz_dt 时调用,返回需求池/权重表/热度统计表各自最新业务日。
 - `search_related_pool_demands(biz_dt, keywords)`:按同名/包含关系搜索需求池,**可一次传入多个 keyword** 批量查找同语义需求;同时返回每条需求的来源内名次、来源归一分和需求自身全日排名。
 - `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_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_demand_popularity_by_word(demand_word_names, biz_dt=None)`:按需求词粒度直接查后验热度统计,**可一次传入多个词** 交叉验证树节点级结论。
 - `query_score_distribution(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` 自动推导。
 - `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 内独立排名归一化后,对该需求已有来源取均值
               即各 strategy 内独立排名归一化后,对该需求已有来源取均值
             - category_ids (可选): 归属的树节点 id 列表,会写入 demand_grade_category_rel 映射表
             - category_ids (可选): 归属的树节点 id 列表,会写入 demand_grade_category_rel 映射表
             - prior_total_score (可选): 落库时的先验 total_score 快照
             - 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 时自动标记为「有后验数据」
               count>0 时自动标记为「有后验数据」
             - biz_dt (可选): 覆盖本项使用的业务日,不传则用调用时的 biz_dt 参数
             - biz_dt (可选): 覆盖本项使用的业务日,不传则用调用时的 biz_dt 参数
         biz_dt: 本次调用的默认业务日期 YYYYMMDD;items 内每项也可单独指定 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 collections import defaultdict
 from typing import Any
 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 = {
 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),
             "observed_source_count": len(sources),
             "sources": sources,
             "sources": sources,
             "posterior_from_exact_pool_rows": {
             "posterior_from_exact_pool_rows": {
-                "real_rov_7d": {
+                "rov_diff": {
                     "value": max(rov_values) if rov_values else None,
                     "value": max(rov_values) if rov_values else None,
                     "has_data": bool(rov_values),
                     "has_data": bool(rov_values),
                 },
                 },
-                "real_vov_7d": {
+                "vov_diff": {
                     "value": max(vov_values) if vov_values else None,
                     "value": max(vov_values) if vov_values else None,
                     "has_data": bool(vov_values),
                     "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}"
             f"来源内名次={source_rank_text} 来源归一分={normalized_text}"
         )
         )
     lines.append("  口径:来源内先排名归一化,再对已有来源取均值;禁止直接相加原始 weight。")
     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
     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:
 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)
     normalized_dt, err = normalize_biz_dt(biz_dt)
     if err:
     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
 from __future__ import annotations
 
 
@@ -100,7 +100,7 @@ def query_demand_category_and_weight(
     biz_dt: Optional[str] = None,
     biz_dt: Optional[str] = None,
 ) -> str:
 ) -> str:
     """
     """
-    需求名 → 归属树节点 → 全局热度 total_score + 后验真实效果(real_rov_7d)
+    需求名 → 归属树节点 → 全局热度 total_score + 后验 rov_diff/vov_diff(real_rov_7d/real_vov_7d 字段)
 
 
     支持批量传入多个需求名,一次调用返回各词的归属与权重;每段结果前会标注原始 demand_name。
     支持批量传入多个需求名,一次调用返回各词的归属与权重;每段结果前会标注原始 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 sqlalchemy.orm import Session
 
 
 from agents.demand_grade_agent.tools.shared import (
 from agents.demand_grade_agent.tools.shared import (
-    format_dim_with_count,
+    format_posterior_dim_with_count,
     normalize_biz_dt,
     normalize_biz_dt,
     normalize_str_list,
     normalize_str_list,
 )
 )
@@ -38,8 +38,8 @@ def _query_one_demand_popularity_by_word(
         posterior_note = "有后验数据" if row.real_rov_7d_count > 0 else "无后验数据(效果未知)"
         posterior_note = "有后验数据" if row.real_rov_7d_count > 0 else "无后验数据(效果未知)"
         lines.append(
         lines.append(
             f"[biz_dt={row.biz_dt}] {row.demand_word_name}: "
             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})"
             f"({posterior_note})"
         )
         )
     return "\n".join(lines)
     return "\n".join(lines)
@@ -66,7 +66,7 @@ def query_demand_popularity_by_word(
     Returns:
     Returns:
         每个 demand_word_name 一段,段首标注 `--- demand_word_name: xxx ---`,例如:
         每个 demand_word_name 一段,段首标注 `--- demand_word_name: xxx ---`,例如:
         --- demand_word_name: 减脂期加餐 ---
         --- 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)
     normalized_dt, err = normalize_biz_dt(biz_dt)
     if err:
     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,
     build_demand_priority_index,
 )
 )
 from agents.demand_grade_agent.tools.shared import distribution_summary, normalize_biz_dt, to_float
 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_agent.tools import tool
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository
 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% 视为全局热度很高),避免同一批次内多次判断标准漂移。
     (例如 total_score 前 10% 视为全局热度很高),避免同一批次内多次判断标准漂移。
     需求自身分按来源内 rank 归一后对已有来源取均值,不直接合并跨来源 raw weight;
     需求自身分按来源内 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:
     Args:
         biz_dt: 业务日期 YYYYMMDD,可选;不传则使用 category_tree_weight 最新业务日。
         biz_dt: 业务日期 YYYYMMDD,可选;不传则使用 category_tree_weight 最新业务日。
@@ -103,10 +105,15 @@ def query_score_distribution(biz_dt: Optional[str] = None) -> str:
 
 
         lines.append(
         lines.append(
             _format_dist(
             _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),
                 distribution_summary(posterior_values),
             )
             )
         )
         )
+        lines.append(
+            "后验效果档位:"
+            f">0 效果非常好;[{POSTERIOR_EFFECT_ACCEPTABLE_FLOOR}, 0) 可接受;"
+            f"<{POSTERIOR_EFFECT_ACCEPTABLE_FLOOR} 效果不佳(ROV/VOV 各自独立判断)。"
+        )
         lines.append(
         lines.append(
             _format_dist(
             _format_dist(
                 "需求自身来源归一分(0-100,来源内排名后对已有来源取均值)",
                 "需求自身来源归一分(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,
     format_demand_priority,
 )
 )
 from agents.demand_grade_agent.tools.shared import (
 from agents.demand_grade_agent.tools.shared import (
+    format_posterior_value,
     format_score,
     format_score,
     normalize_biz_dt,
     normalize_biz_dt,
     normalize_str_list,
     normalize_str_list,
@@ -39,13 +40,13 @@ def _search_one_related_pool_demands(
         lines.append(f"需求自身证据「{demand_name}」:")
         lines.append(f"需求自身证据「{demand_name}」:")
         lines.extend(format_demand_priority(priority_index.get(demand_name)))
         lines.extend(format_demand_priority(priority_index.get(demand_name)))
     for row in rows:
     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"])
         weight = format_score(row["weight"])
         video_count = row["video_count"] if row["video_count"] is not None else "—"
         video_count = row["video_count"] if row["video_count"] is not None else "—"
         lines.append(
         lines.append(
             f"[id={row['id']}|{row['strategy']}|weight={weight}"
             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)
     return "\n".join(lines)
 
 
@@ -69,7 +70,7 @@ def search_related_pool_demands(biz_dt: str, keywords: list[str]) -> str:
     Returns:
     Returns:
         每个 keyword 一段,段首标注 `--- keyword: xxx ---`,例如:
         每个 keyword 一段,段首标注 `--- keyword: xxx ---`,例如:
         --- keyword: 加餐 ---
         --- 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)
     normalized, err = normalize_biz_dt(biz_dt)
     if err:
     if err:

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

@@ -5,6 +5,12 @@ import json
 from decimal import Decimal
 from decimal import Decimal
 from typing import Any
 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.global_tree_category import GlobalTreeCategory
 from supply_infra.db.models.multi_demand_pool_di import MultiDemandPoolDi
 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"  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'])}",
         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})"
             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,
     build_category_path,
     format_category_weight_lines,
     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.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.session import get_session
 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:
     if not snapshots:
         return "category_ids 不能为空"
         return "category_ids 不能为空"
     lines = [
     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:
     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 types import SimpleNamespace
 from typing import Any
 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.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.session import get_session
 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()
             for row in GlobalTreeCategoryRepository(session).list_active_categories()
         ]
         ]
         weights = [
         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)
             for row in CategoryTreeWeightRepository(session).list_by_biz_dt(biz_dt)
         ]
         ]
     by_id = {row.id: row for row in categories}
     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` 中明确说明。
 - 热度为空或样本数为 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 提交格式
 ## 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,
     load_tree_state,
     path,
     path,
 )
 )
+from supply_infra.scoring.posterior import format_posterior_pair
 from supply_agent.tools import tool
 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)
     positions = global_heat_positions(weights)
     unassigned_nodes = get_unassigned_hanging_category_ids(biz_dt)
     unassigned_nodes = get_unassigned_hanging_category_ids(biz_dt)
     lines = [
     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:
     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)
         position = positions.get(category_id)
         is_unassigned = category_id in unassigned_nodes
         is_unassigned = category_id in unassigned_nodes
         suffix = " +" if is_unassigned and has_hung_demand(weight) else ""
         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 (
         return (
             f"[{category_id}]{category.name or ''}"
             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):
     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 import Agent
 from supply_agent.config import Settings
 from supply_agent.config import Settings
 from agents.demand_video_expand_agent.tools import register_all_tools
 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"
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 DEMAND_VIDEO_EXPAND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 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,
         settings=settings,
         name="demand_video_expand_agent",
         name="demand_video_expand_agent",
         model=model,
         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,
         max_iterations=10,
     )
     )
     register_all_tools(agent.tools)
     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.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 __future__ import annotations
 
 
+from pathlib import Path
+
 from supply_agent import Agent
 from supply_agent import Agent
 from supply_agent.config import Settings
 from supply_agent.config import Settings
 from agents.find_agent.tools import register_all_tools
 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(
 def create_find_agent(
@@ -33,8 +25,10 @@ def create_find_agent(
     agent = Agent(
     agent = Agent(
         settings=settings,
         settings=settings,
         name="find_agent",
         name="find_agent",
-        model=model,
+        model=model or "google/gemini-3-flash-preview",
         system_prompt=FIND_AGENT_SYSTEM_PROMPT,
         system_prompt=FIND_AGENT_SYSTEM_PROMPT,
+        max_iterations=60,
+        temperature=0.2,
     )
     )
 
 
     # 本 Agent 专属工具
     # 本 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
 #!/usr/bin/env python3
 """Run find_agent interactively."""
 """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:
 def main() -> None:
@@ -18,7 +18,7 @@ def main() -> None:
             break
             break
         if not user_input or user_input.lower() in ("exit", "quit", "q"):
         if not user_input or user_input.lower() in ("exit", "quit", "q"):
             break
             break
-        result = agent.run(user_input)
+        result = run_find_agent(user_input)
         print(f"\nAgent> {result.content}\n")
         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_detail import douyin_detail
 from agents.find_agent.tools.douyin_search import douyin_search
 from agents.find_agent.tools.douyin_search import douyin_search
-from agents.find_agent.tools.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
 from supply_agent.tools.registry import ToolRegistry
 
 
 ALL_TOOLS: list[Callable[..., Any]] = [
 ALL_TOOLS: list[Callable[..., Any]] = [
     douyin_search,
     douyin_search,
+    douyin_search_tikhub,
+    douyin_user_videos,
     douyin_detail,
     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__ = [
     "ALL_TOOLS",
     "ALL_TOOLS",
     "douyin_search",
     "douyin_search",
+    "douyin_search_tikhub",
+    "douyin_user_videos",
     "douyin_detail",
     "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",
     "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"
 DOUYIN_DETAIL_API = "http://8.217.190.241:8888/crawler/dou_yin/detail"
 DEFAULT_TIMEOUT = 60.0
 DEFAULT_TIMEOUT = 60.0
+MAX_DETAIL_ITEMS = 8
 
 
 _PLAY_URL_MARKER = "douyin.com/aweme/v1/play/"
 _PLAY_URL_MARKER = "douyin.com/aweme/v1/play/"
 
 
@@ -197,8 +198,16 @@ def _build_output_summary(
     return "\n".join(lines).rstrip()
     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:
 async def _wait_rate_limit() -> None:
@@ -265,6 +274,11 @@ async def douyin_detail(
 
 
     if not ids:
     if not ids:
         return _error_result("content_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]] = []
     details: list[dict[str, Any]] = []
     errors: list[dict[str, str]] = []
     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
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import hashlib
+import importlib
 import json
 import json
 import logging
 import logging
 import os
 import os
+import re
+import shutil
+import subprocess
+import tempfile
 import time
 import time
-from typing import Optional
+from pathlib import Path
+from typing import Any, Optional
 
 
+import httpx
 from dotenv import load_dotenv
 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.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__)
 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_MODEL = "qwen3.7-plus"
 DEFAULT_PROMPT = "描述这段视频的内容"
 DEFAULT_PROMPT = "描述这段视频的内容"
 DEFAULT_FPS = 2.0
 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
 _env_loaded = False
 
 
 
 
+class ToolTimeoutError(RuntimeError):
+    """A bounded local tool operation exceeded its deadline."""
+
+
 def _ensure_env_loaded() -> None:
 def _ensure_env_loaded() -> None:
     """从项目根目录加载 .env,使 os.getenv 能读到其中的变量。"""
     """从项目根目录加载 .env,使 os.getenv 能读到其中的变量。"""
     global _env_loaded
     global _env_loaded
@@ -47,6 +70,288 @@ def _get_client() -> OpenAI:
     return OpenAI(api_key=api_key, base_url=DASHSCOPE_BASE_URL)
     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(
 def _analyze_video_sync(
     video_url: str,
     video_url: str,
     prompt: str,
     prompt: str,
@@ -81,8 +386,10 @@ def _success_result(
     content: str,
     content: str,
     model: str,
     model: str,
     duration_ms: int,
     duration_ms: int,
+    *,
+    extra: dict[str, Any] | None = None,
 ) -> str:
 ) -> str:
-    payload = {
+    payload: dict[str, Any] = {
         "title": "视频解析结果",
         "title": "视频解析结果",
         "video_url": video_url,
         "video_url": video_url,
         "prompt": prompt,
         "prompt": prompt,
@@ -91,44 +398,136 @@ def _success_result(
         "output": content,
         "output": content,
         "duration_ms": duration_ms,
         "duration_ms": duration_ms,
     }
     }
+    if extra:
+        payload.update(extra)
     return json.dumps(payload, ensure_ascii=False)
     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(
 async def qwen_video_analyze(
     video_url: str,
     video_url: str,
     prompt: str = DEFAULT_PROMPT,
     prompt: str = DEFAULT_PROMPT,
     fps: float = DEFAULT_FPS,
     fps: float = DEFAULT_FPS,
     model: str = DEFAULT_MODEL,
     model: str = DEFAULT_MODEL,
     timeout: Optional[float] = None,
     timeout: Optional[float] = None,
+    max_duration_seconds: Optional[float] = DEFAULT_MAX_DURATION_SECONDS,
+    download_timeout: Optional[float] = None,
 ) -> str:
 ) -> str:
     """
     """
-    千问视频内容解析
+    历史视频解析函数,不注册为 find_agent 工具。
 
 
     通过阿里云百炼平台调用 qwen3.7-plus 模型,分析视频 URL 并返回文字描述。
     通过阿里云百炼平台调用 qwen3.7-plus 模型,分析视频 URL 并返回文字描述。
-    需要设置环境变量 DASHSCOPE_API_KEY。
+    需要设置环境变量 DASHSCOPE_API_KEY。超长视频会先截断再解析。
 
 
     Args:
     Args:
         video_url: 视频地址(需公网可访问的 mp4 等格式)
         video_url: 视频地址(需公网可访问的 mp4 等格式)
         prompt: 解析提示词,默认 "描述这段视频的内容"
         prompt: 解析提示词,默认 "描述这段视频的内容"
         fps: 视频抽帧频率,默认 2(每秒采样 2 帧)
         fps: 视频抽帧频率,默认 2(每秒采样 2 帧)
         model: 模型名称,默认 "qwen3.7-plus"
         model: 模型名称,默认 "qwen3.7-plus"
-        timeout: 请求超时时间(秒),默认 120
+        timeout: 请求超时时间(秒),默认 300
+        max_duration_seconds: 解析前最长保留秒数,默认 180(3 分钟);
+            传 None 表示不截断
+        download_timeout: 下载原视频超时时间(秒),默认 120
 
 
     Returns:
     Returns:
         JSON 字符串,包含 content(解析文本)和 output(同 content,供 LLM 阅读)。
         JSON 字符串,包含 content(解析文本)和 output(同 content,供 LLM 阅读)。
     """
     """
     start_time = time.time()
     start_time = time.time()
     request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
     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:
     try:
+        analysis_url, truncate_meta = await _prepare_analysis_url(
+            video_url,
+            max_duration_seconds,
+            request_download_timeout,
+        )
         content = await asyncio.to_thread(
         content = await asyncio.to_thread(
             _analyze_video_sync,
             _analyze_video_sync,
-            video_url,
+            analysis_url,
             prompt,
             prompt,
             fps,
             fps,
             model,
             model,
@@ -137,16 +536,80 @@ async def qwen_video_analyze(
 
 
         duration_ms = int((time.time() - start_time) * 1000)
         duration_ms = int((time.time() - start_time) * 1000)
         logger.info(
         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,
             video_url,
+            analysis_url,
+            truncate_meta.get("truncated"),
             model,
             model,
             duration_ms,
             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:
     except ValueError as e:
         logger.error("qwen_video_analyze config error: %s", e)
         logger.error("qwen_video_analyze config error: %s", e)
         return _error_result(str(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:
     except Exception as e:
         logger.error(
         logger.error(
             "qwen_video_analyze error: video_url=%s error=%s",
             "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 import FastAPI, HTTPException, Query
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 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.category_tree import build_category_tree
 from api.services.demand_belong_category import list_demand_belong_categories
 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 import list_demand_grades
 from api.services.demand_grade_videos import list_videos_for_demand_grade
 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.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
 @asynccontextmanager
 async def lifespan(_app: FastAPI):
 async def lifespan(_app: FastAPI):
-    init_db()
-    start_scheduler()
+    if get_infra_settings().database_auto_create:
+        init_db()
     yield
     yield
-    stop_scheduler()
+    dispose_engine()
 
 
 
 
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
+app.include_router(pipeline_router)
 
 
 app.add_middleware(
 app.add_middleware(
     CORSMiddleware,
     CORSMiddleware,
@@ -47,10 +85,105 @@ def health() -> dict[str, str]:
     return {"status": "ok"}
     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")
 @app.get("/api/scheduler/status")
 def scheduler_status() -> dict:
 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")
 @app.get("/api/category-tree")
@@ -101,6 +234,26 @@ def demand_grade_videos(demand_grade_id: int) -> dict:
     return result
     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")
 @app.get("/api/demand-belong-oss-logs")
 def demand_belong_oss_logs() -> dict:
 def demand_belong_oss_logs() -> dict:
     """Return demand_belong_category_agent oss_logs ordered by create_time desc."""
     """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}
     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"
 _web_dist = Path(__file__).resolve().parent.parent / "web" / "dist"
 if _web_dist.is_dir():
 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_sust_pop", "label": "平台持续热度"},
     {"key": "plat_ly_pop", "label": "去年同期热度"},
     {"key": "plat_ly_pop", "label": "去年同期热度"},
     {"key": "recent_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"
 _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]]:
 def list_demand_belong_oss_logs() -> list[dict[str, Any]]:
     """List demand_belong_category_agent oss logs, newest first."""
     """List demand_belong_category_agent oss logs, newest first."""
     with get_session() as session:
     with get_session() as session:
         repo = OssLogRepository(session)
         repo = OssLogRepository(session)
         rows = repo.list_by_agent_name(_DEMAND_AGENT)
         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",
     "rich>=13.0",
     "sqlalchemy>=2.0",
     "sqlalchemy>=2.0",
     "pymysql>=1.1",
     "pymysql>=1.1",
+    "alembic>=1.13",
+    "requests>=2.32",
     "apscheduler>=3.10",
     "apscheduler>=3.10",
     "python-dotenv>=1.0",
     "python-dotenv>=1.0",
     "oss2>=2.18.0",
     "oss2>=2.18.0",
@@ -26,6 +28,7 @@ dependencies = [
 
 
 [project.optional-dependencies]
 [project.optional-dependencies]
 odps = ["pyodps>=0.12"]
 odps = ["pyodps>=0.12"]
+media = ["imageio-ffmpeg>=0.5.1"]
 dev = [
 dev = [
     "pytest>=8.0",
     "pytest>=8.0",
     "pytest-asyncio>=0.24",
     "pytest-asyncio>=0.24",
@@ -36,9 +39,10 @@ dev = [
 packages = ["supply_agent", "supply_infra", "agents", "api"]
 packages = ["supply_agent", "supply_infra", "agents", "api"]
 
 
 [project.scripts]
 [project.scripts]
-supply-scheduler = "supply_infra.scheduler.app:run_scheduler"
 supply-visualize = "supply_agent.logging.cli:main"
 supply-visualize = "supply_agent.logging.cli:main"
 supply-api = "api.run:main"
 supply-api = "api.run:main"
+supply-scheduler = "supply_infra.scheduler.__main__:main"
+supply-pipeline = "supply_infra.pipeline.cli:main"
 
 
 [tool.ruff]
 [tool.ruff]
 line-length = 100
 line-length = 100

+ 4 - 0
requirements.txt

@@ -4,6 +4,8 @@ openai>=1.50.0
 pydantic>=2.0
 pydantic>=2.0
 pydantic-settings>=2.0
 pydantic-settings>=2.0
 httpx>=0.27.0
 httpx>=0.27.0
+# 本地视频截断 fallback(无系统 ffmpeg 时使用);也可 pip install ".[media]"
+imageio-ffmpeg>=0.5.1
 rich>=13.0
 rich>=13.0
 python-dotenv>=1.0.0
 python-dotenv>=1.0.0
 markdown>=3.6
 markdown>=3.6
@@ -11,6 +13,8 @@ markdown>=3.6
 # Database
 # Database
 sqlalchemy>=2.0
 sqlalchemy>=2.0
 pymysql>=1.1
 pymysql>=1.1
+alembic>=1.13
+requests>=2.32
 
 
 # Scheduler
 # Scheduler
 apscheduler>=3.10
 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"
 cd "$BUILD_DIR"
 docker build -t "$IMAGE_TAG" -t "$LATEST_TAG" .
 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
 if [[ "$PUSH" == "true" ]]; then
   echo "==> Pushing: ${IMAGE_TAG}"
   echo "==> Pushing: ${IMAGE_TAG}"
   docker push "$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)
         self._finish_run(result)
         return result
         return result
 
 
-    async def arun(
+    async def arun_core(
         self, user_input: str, *, history: list[Message] | None = None
         self, user_input: str, *, history: list[Message] | None = None
     ) -> AgentResult:
     ) -> 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)
         self.logger.start_run(user_input, model=self.model, agent_name=self.name)
         messages = list(history or [])
         messages = list(history or [])
         messages.append(Message(role=Role.USER, content=user_input))
         messages.append(Message(role=Role.USER, content=user_input))
         loop = self._create_loop(messages)
         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)
         self._finish_run(result)
         return result
         return result
 
 

+ 214 - 152
supply_agent/agent/loop.py

@@ -2,9 +2,9 @@ from __future__ import annotations
 
 
 import json
 import json
 from collections.abc import AsyncIterator, Callable, Iterator
 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.tools.registry import ToolRegistry
 from supply_agent.types import (
 from supply_agent.types import (
     AgentEvent,
     AgentEvent,
@@ -12,17 +12,25 @@ from supply_agent.types import (
     AgentResult,
     AgentResult,
     Message,
     Message,
     Role,
     Role,
+    ToolCall,
+    ToolDefinition,
+    ToolResult,
 )
 )
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from supply_agent.logging.logger import AgentLogger
     from supply_agent.logging.logger import AgentLogger
 
 
+# Outcome of one assistant turn before tools / completion.
+_TurnKind = Literal["done", "tools"]
+
 
 
 class AgentLoop:
 class AgentLoop:
     """
     """
     ReAct-style agent loop: Reason → Act (tool call) → Observe → Repeat.
     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__(
     def __init__(
@@ -45,7 +53,8 @@ class AgentLoop:
         self.max_iterations = max_iterations
         self.max_iterations = max_iterations
         self.temperature = temperature
         self.temperature = temperature
         self.logger = logger
         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
         self.tool_calls_made = 0
 
 
     def _all_messages(self) -> list[Message]:
     def _all_messages(self) -> list[Message]:
@@ -64,50 +73,85 @@ class AgentLoop:
         if self.system_message_builder:
         if self.system_message_builder:
             self.system_message = 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(
         self.messages.append(
             Message(
             Message(
                 role=Role.USER,
                 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(
         final = self.llm.chat(
@@ -118,50 +162,13 @@ class AgentLoop:
         self.messages.append(final)
         self.messages.append(final)
         return self._build_result(final.content or "", iterations)
         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(
         self.messages.append(
             Message(
             Message(
                 role=Role.USER,
                 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(
         final = await self.llm.achat(
@@ -172,6 +179,60 @@ class AgentLoop:
         self.messages.append(final)
         self.messages.append(final)
         return self._build_result(final.content or "", iterations)
         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]:
     def stream(self) -> Iterator[AgentEvent]:
         iterations = 0
         iterations = 0
         while iterations < self.max_iterations:
         while iterations < self.max_iterations:
@@ -181,55 +242,60 @@ class AgentLoop:
                 data={"iteration": iterations},
                 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(
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
                     type=AgentEventType.MESSAGE,
-                    data={"content": response.content or ""},
+                    data={"content": done.content},
                 )
                 )
                 yield AgentEvent(
                 yield AgentEvent(
                     type=AgentEventType.DONE,
                     type=AgentEventType.DONE,
-                    data=self._build_result(response.content or "", iterations).model_dump(),
+                    data=done.model_dump(),
                 )
                 )
                 return
                 return
 
 
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
                 self.tool_calls_made += 1
                 yield AgentEvent(
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
                     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(
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,
                     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]:
     async def astream(self) -> AsyncIterator[AgentEvent]:
         iterations = 0
         iterations = 0
@@ -240,61 +306,57 @@ class AgentLoop:
                 data={"iteration": iterations},
                 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(
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
                     type=AgentEventType.MESSAGE,
-                    data={"content": response.content or ""},
+                    data={"content": done.content},
                 )
                 )
                 yield AgentEvent(
                 yield AgentEvent(
                     type=AgentEventType.DONE,
                     type=AgentEventType.DONE,
-                    data=self._build_result(response.content or "", iterations).model_dump(),
+                    data=done.model_dump(),
                 )
                 )
                 return
                 return
 
 
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
                 self.tool_calls_made += 1
                 yield AgentEvent(
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
                     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(
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,
                     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_url: str = Field(default="", alias="OPENROUTER_SITE_URL")
     openrouter_site_name: str = Field(default="SupplyAgent", alias="OPENROUTER_SITE_NAME")
     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
     agent_max_iterations: int = Field(default=20, alias="AGENT_MAX_ITERATIONS")
     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
 from __future__ import annotations
 
 
+import logging
 from collections.abc import AsyncIterator, Iterator
 from collections.abc import AsyncIterator, Iterator
 from typing import TYPE_CHECKING, Any
 from typing import TYPE_CHECKING, Any
 
 
@@ -11,6 +12,19 @@ from supply_agent.types import Message, Role, ToolCall, ToolDefinition
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from supply_agent.logging.logger import AgentLogger
     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:
 class LLMClient:
     """OpenRouter LLM client using the OpenAI-compatible API."""
     """OpenRouter LLM client using the OpenAI-compatible API."""
@@ -72,12 +86,35 @@ class LLMClient:
             "messages": [m.to_api_dict() for m in messages],
             "messages": [m.to_api_dict() for m in messages],
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
             "temperature": temp,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         }
         extra_body = self._extra_body()
         extra_body = self._extra_body()
         if extra_body:
         if extra_body:
             kwargs["extra_body"] = 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
         raw_message = response.choices[0].message
         result = self._parse_response(raw_message)
         result = self._parse_response(raw_message)
 
 
@@ -105,12 +142,35 @@ class LLMClient:
             "messages": [m.to_api_dict() for m in messages],
             "messages": [m.to_api_dict() for m in messages],
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
             "temperature": temp,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         }
         extra_body = self._extra_body()
         extra_body = self._extra_body()
         if extra_body:
         if extra_body:
             kwargs["extra_body"] = 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
         raw_message = response.choices[0].message
         result = self._parse_response(raw_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,
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
             "temperature": temp,
             "stream": True,
             "stream": True,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         }
         extra_body = self._extra_body()
         extra_body = self._extra_body()
         if extra_body:
         if extra_body:
@@ -179,6 +240,7 @@ class LLMClient:
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "tools": [t.to_api_dict() for t in tools] if tools else None,
             "temperature": temp,
             "temperature": temp,
             "stream": True,
             "stream": True,
+            "timeout": getattr(self.settings, "openrouter_timeout_seconds", 120.0),
         }
         }
         extra_body = self._extra_body()
         extra_body = self._extra_body()
         if extra_body:
         if extra_body:
@@ -220,6 +282,20 @@ class LLMClient:
             reasoning=reasoning,
             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:
     def set_model(self, model: str) -> None:
         """Switch to a different model at runtime."""
         """Switch to a different model at runtime."""
         self.model = model
         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.logger import AgentLogger, get_agent_logger, slugify_agent_name
 from supply_agent.logging.parser import load_run_events, summarize_run
 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
 from supply_agent.logging.visualize import generate_visualization, render_html
 
 
 __all__ = [
 __all__ = [
@@ -12,6 +16,8 @@ __all__ = [
     "load_run_events",
     "load_run_events",
     "summarize_run",
     "summarize_run",
     "publish_run_artifacts",
     "publish_run_artifacts",
+    "set_run_artifact_publisher",
+    "get_run_artifact_publisher",
     "generate_visualization",
     "generate_visualization",
     "render_html",
     "render_html",
 ]
 ]

Some files were not shown because too many files changed in this diff