فهرست منبع

增加寻找agent可视化页面

xueyiming 1 هفته پیش
والد
کامیت
22eaf63a55

+ 818 - 0
agents/find_agent/TOOLS_PRD.md

@@ -0,0 +1,818 @@
+# find_agent 工具能力与接口 PRD
+
+> 文档版本:v1.0  
+> 代码基线:2026-08-03  
+> 文档性质:As-Is 工具产品说明与接口契约  
+> 适用对象:产品、算法、Agent Prompt、后端、测试与运维人员
+
+## 1. 文档目标
+
+本文说明“寻找视频 Agent”`find_agent` 当前真实可调用的工具,包括:
+
+- 工具清单、定位和推荐调用阶段;
+- 每个工具对模型暴露的参数 Schema;
+- 参数的业务含义、默认值、枚举、数量限制和组合规则;
+- 成功、部分成功和失败时的返回结构;
+- 搜索、详情、画像、候选更新和运行结束之间的数据衔接;
+- 当前接口限制、风险和验收口径。
+
+本文以运行时 `create_find_agent().tools` 生成的 Schema 和当前实现代码为准,不把仅为旧调用方
+保留、但未注册给 Agent 的函数算作可用工具。
+
+## 2. 产品定位
+
+`find_agent` 面向一条已经分级并完成视频点位拓展的需求,使用多个抖音数据来源完成:
+
+1. 搜索候选视频;
+2. 保存每次搜索及其候选;
+3. 拉取视频详情和受众画像;
+4. 判断相关性 `R`、老年受众倾向 `E`、分享价值 `S` 和综合价值 `V`;
+5. 将候选放入 `primary` 或 `rejected`;
+6. 完成运行并输出 `goal_met`、`partial` 或 `no_match`。
+
+工具服务于模型的 ReAct 决策,但搜索记录、证据、候选门禁和最终运行结果均由代码持久化,
+不能只依赖模型最终文本。
+
+## 3. 工具总览
+
+Agent 初始化后共有 13 个可调用工具:1 个框架内置工具和 12 个 `find_agent` 专属工具。
+
+| # | 工具 | 类型 | 主要用途 | 是否写库 |
+|---:|---|---|---|---:|
+| 1 | `load_skill` | 框架内置 | 按名称加载 Skill 指令 | 否 |
+| 2 | `batch_search_and_record` | 批量搜索 | 一次提交最多 6 个关键词任务,每项最多 2 页 | 是 |
+| 3 | `douyin_search` | 关键词搜索 | 使用内部搜索源获取一页视频 | 是 |
+| 4 | `douyin_search_tikhub` | 关键词搜索 | 使用 TikHub 获取一页视频和完整分页状态 | 是 |
+| 5 | `douyin_user_videos` | 作者扩展 | 按作者 `sec_uid` 获取一页最热或最新作品 | 是 |
+| 6 | `douyin_detail` | 详情证据 | 批量获取视频详情;新模式自动保存证据并重算门禁 | 新模式是 |
+| 7 | `get_content_fans_portrait` | 单条画像 | 获取视频点赞用户画像 | 否 |
+| 8 | `get_account_fans_portrait` | 单条画像 | 获取作者粉丝画像 | 否 |
+| 9 | `batch_fetch_portraits` | 批量画像 | 批量获取视频/作者画像;新模式自动保存并重算门禁 | 新模式是 |
+| 10 | `normalize_age_portraits` | 证据处理 | 将两侧年龄桶标准化为统一证据 | 否 |
+| 11 | `batch_update_video_discovery_candidates` | 候选决策 | 按 `candidate_id` 更新评分、理由和最终分池 | 是 |
+| 12 | `update_video_discovery_run_status` | 运行控制 | 更新运行状态、意图摘要和停止原因 | 是 |
+| 13 | `query_video_discovery_state` | 状态查询 | 读取运行、搜索轨迹和候选快照 | 否 |
+
+以下文件存在,但当前没有注册给 `find_agent`:
+
+- `create_video_discovery_run`:运行由调度器在模型循环前预创建;
+- `qwen_video_analyze`:当前不开放视频理解;
+- `batch_save_video_candidate_evaluations`、`audit_video_discovery_run` 等旧接口:不在实际工具表中。
+
+## 4. 推荐调用链
+
+```mermaid
+flowchart LR
+    A["调度器预创建 run_id"] --> B["批量或单页搜索"]
+    B --> C["返回 search_id 和 candidate_id"]
+    C --> D["douyin_detail 拉详情"]
+    D --> E["batch_fetch_portraits 拉双侧画像"]
+    E --> F["候选 R/E/S/V 判断"]
+    F --> G["batch_update... 写入分池"]
+    G --> H["query... 核对最终状态"]
+    H --> I["update... status=finished"]
+    I --> J["输出业务结果"]
+```
+
+推荐优先使用带 `run_id + candidate_ids` 的详情和批量画像新模式。该模式由工具从数据库读取
+`aweme_id` 和作者 ID,自动保存原始证据、刷新候选字段并重算门禁,减少模型搬运大 JSON 的错误。
+
+## 5. 全局接口约定
+
+### 5.1 返回值类型
+
+所有工具在 Python 层都返回 `str`。除 `load_skill` 成功时返回 Skill 上下文文本外,其余工具
+返回值均为 JSON 字符串,调用方必须先解析 JSON,不能把它当成已经反序列化的对象。
+
+通用成功字段通常包括:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `title` | string | 结果标题,主要用于日志和模型阅读 |
+| `output` | string | 人类可读摘要,不是最终业务事实的唯一来源 |
+
+通用错误字段:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `error` | string | 参数、上游接口、网络或数据库错误文本 |
+| `input_error` | boolean,可选 | `true` 表示调用参数错误;缺失不代表成功 |
+| `title` | string,可选 | 错误场景标题 |
+| `errors` | array,可选 | 批量调用中的逐项错误 |
+
+框架通过返回内容中是否存在错误字段识别工具错误,但不会自动重试、回滚整条 Agent 运行或切换
+备用工具。模型必须读取返回结果后决定下一步。
+
+### 5.2 核心 ID
+
+| ID | 来源 | 用途 | 约束 |
+|---|---|---|---|
+| `run_id` | 调度器预创建 | 贯穿一次需求的视频发现过程 | 所有写库工具必须复用同一个值 |
+| `search_id` | 搜索工具返回 | 标识一页搜索记录 | 翻页/标签/作者扩展可作为 `parent_search_id` |
+| `candidate_id` | 搜索工具返回 | 标识一次搜索命中的候选记录 | 详情、画像、评分和分池更新的首选主键 |
+| `aweme_id` / `content_id` | 抖音内容 | 标识视频本身 | 同一视频被多个搜索命中时可对应多个 `candidate_id` |
+| `author.sec_uid` / `account_id` | 搜索或详情结果 | 标识抖音作者 | 作者画像及作者作品扩展使用完整 `sec_uid` |
+
+### 5.3 当前 Schema 能力边界
+
+工具框架可从 Python 类型注解生成顶层 JSON Schema,但当前不生成枚举、取值范围、字符串格式,
+也不展开 `list[dict]` 内部对象字段。因此模型可见 Schema 中:
+
+- `searches` 和 `items` 只显示为“对象数组”;
+- `source_type`、`status`、排序方式等只显示为普通字符串;
+- 单批上限、字段组合关系只存在于工具描述和运行时代码校验中。
+
+本文后续表格补齐这些语义约束,但它们尚未全部成为可被模型供应方提前校验的 JSON Schema。
+
+## 6. 搜索类工具
+
+### 6.1 `batch_search_and_record`
+
+#### 功能
+
+顺序执行多个关键词搜索任务。每完成一页立即新增搜索记录,并为该页每条有效结果新增候选。
+适合启动阶段一次性提交 2~3 个语义不同的根搜索,也可用于受控翻页。
+
+#### 顶层参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `run_id` | string | 是 | - | 预创建的运行 ID,不能为空 |
+| `searches` | array<object> | 是 | - | 搜索任务数组,必须非空,最多 6 项 |
+
+#### `searches[]` 对象
+
+| 字段 | 类型 | 必填 | 默认/取值 | 说明 |
+|---|---|---:|---|---|
+| `keyword` | string | 是 | - | 实际搜索词,不能为空 |
+| `query_reason` | string | 是 | - | 为什么执行该搜索,不能为空 |
+| `source_type` | string | 是 | `demand` / `seed` / `point` / `tag` / `pagination` / `mixed` | 搜索来源类型 |
+| `provider` | string | 否 | `internal_keyword` | 可选 `internal_keyword`、`tikhub` |
+| `max_pages` | integer | 否 | `1` | 自动限制到 `1~2`;超范围不会报错而会被截断 |
+| `cursor` | string 或 integer | 否 | 内部源为 `"0"`,TikHub 为 `0` | 起始游标 |
+| `search_id` | string | 否 | `""` | TikHub 翻页状态,不是数据库 `search_id` |
+| `backtrace` | string | 否 | `""` | TikHub 翻页回溯状态 |
+| `source_value` | string | 否 | `null` | 来源值,如点位文本、标签词 |
+| `parent_search_id` | integer | 否 | `null` | 父搜索页的数据库 ID |
+| `content_type` | string | 否 | `视频` | 传给底层搜索源 |
+| `sort_type` | string | 否 | `综合排序` | 传给底层搜索源 |
+| `publish_time` | string | 否 | `不限` | 发布时间筛选 |
+| `filter_duration` | string | 否 | `不限` | 仅 TikHub 使用 |
+| `min_duration_seconds` | integer | 否 | `30` | 实际最小值固定不低于 30 秒 |
+
+#### 返回结果
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | 固定为“批量搜索并自动落库” |
+| `run_id` | string | 本次运行 ID |
+| `task_count` | integer | 通过顶层校验并进入结果的任务数;不一定等于输入项数 |
+| `saved_page_count` | integer | 已成功持久化的搜索页数 |
+| `new_candidate_count` | integer | 所有成功页新增候选总数 |
+| `error_count` | integer | 任务校验或分页执行错误数 |
+| `errors` | array<string> | 带任务下标、关键词和页码的错误摘要 |
+| `tasks` | array<object> | 各搜索任务的执行结果 |
+| `tasks[].index` | integer | 原始输入数组下标 |
+| `tasks[].keyword` | string | 搜索词 |
+| `tasks[].provider` | string | 实际搜索源 |
+| `tasks[].pages` | array<object> | 各页结果 |
+| `pages[].page_no` | integer | 页码 |
+| `pages[].results_count` | integer | 该页结果数 |
+| `pages[].has_more` | boolean | 是否还有下一页 |
+| `pages[].next_cursor` | string 或 integer | 下一页游标 |
+| `pages[].search_id` | integer | 数据库搜索记录 ID |
+| `pages[].new_candidate_count` | integer | 该页新增候选数 |
+| `pages[].candidates` | array<object> | 该页候选摘要,结构见 6.5 |
+| `pages[].persisted` | boolean | 是否成功落库 |
+| `pages[].error` | string/null | 该页错误 |
+| `output` | string | 批量结果摘要 |
+| `error` | string,可选 | 所有页均未成功保存时出现 |
+
+执行是串行的,不并发。单个任务某页报错后停止该任务后续翻页,但会继续处理后面的任务;因此
+返回结果可能是部分成功。
+
+### 6.2 `douyin_search`
+
+#### 功能
+
+通过内部关键词搜索源获取一页抖音视频,并自动保存搜索页、原始搜索证据和候选记录。
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `run_id` | string | 是 | - | 运行 ID |
+| `keyword` | string | 是 | - | 搜索词 |
+| `query_reason` | string | 是 | - | 搜索原因 |
+| `source_type` | string | 是 | - | 可选 `demand/seed/point/tag/author/pagination/mixed` |
+| `source_value` | string/null | 否 | `null` | 来源原值 |
+| `parent_search_id` | integer/null | 否 | `null` | 父搜索记录 ID |
+| `page_no` | integer | 否 | `1` | 小于 1 时按 1 保存;大于 1 自动改为 `pagination` |
+| `content_type` | string | 否 | `视频` | 上游支持 `视频/图文` |
+| `sort_type` | string | 否 | `综合排序` | 上游支持 `综合排序/最新发布/最多点赞` |
+| `publish_time` | string | 否 | `不限` | 上游支持 `不限/一天内/一周内/半年内` |
+| `cursor` | string | 否 | `"0"` | 翻页游标 |
+| `account_id` | string | 否 | `771431222` | 内部搜索接口账号 ID |
+| `min_duration_seconds` | integer | 否 | `30` | 客户端短视频过滤阈值,实际不低于 30 秒 |
+| `timeout` | number/null | 否 | `null` | 请求超时;`null` 时底层默认 60 秒 |
+
+#### 返回结果
+
+成功后返回“搜索页公共结构”,见 6.5。内部源特有或常用字段还包括:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | `抖音搜索: {keyword}` |
+| `output` | string | 含标题、作者、点赞、评论、分享的文本摘要 |
+| `filtered_count` | integer | 因时长不足被过滤的数量 |
+| `duration_ms` | integer | 上游调用耗时 |
+
+### 6.3 `douyin_search_tikhub`
+
+#### 功能
+
+使用 TikHub 独立搜索源获取一页抖音视频,提供收藏、播放、话题以及完整分页状态,并自动落库。
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `run_id` | string | 是 | - | 运行 ID |
+| `keyword` | string | 是 | - | 搜索词,不能为空 |
+| `query_reason` | string | 是 | - | 搜索原因 |
+| `source_type` | string | 是 | - | 可选值同单页内部搜索 |
+| `source_value` | string/null | 否 | `null` | 来源原值 |
+| `parent_search_id` | integer/null | 否 | `null` | 父搜索记录 ID |
+| `page_no` | integer | 否 | `1` | 大于 1 自动记录为 `pagination` |
+| `content_type` | string | 否 | `视频` | `不限/视频/图片/图文/文章`,也兼容 `0/1/2/3` |
+| `sort_type` | string | 否 | `综合排序` | `综合排序/最多点赞/最新发布`,也兼容 `0/1/2` |
+| `publish_time` | string | 否 | `不限` | `不限/一天内/一周内/半年内`,也兼容 `0/1/7/180` |
+| `cursor` | integer | 否 | `0` | 首次为 0;翻页原样使用上次 `next_cursor` |
+| `filter_duration` | string | 否 | `不限` | `不限/一分钟内/1-5分钟/5分钟以上`,兼容供应商代码 |
+| `search_id` | string | 否 | `""` | 供应商分页状态;翻页必须原样回传 |
+| `backtrace` | string | 否 | `""` | 供应商回溯状态;翻页必须原样回传 |
+| `min_duration_seconds` | integer | 否 | `30` | 客户端二次过滤,实际不低于 30 秒 |
+| `timeout` | number/null | 否 | `null` | `null` 时默认 60 秒 |
+
+#### 返回结果
+
+除 6.5 的搜索页公共结构外,还返回:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `provider` | string | `tikhub` |
+| `request_params` | object | 传给 TikHub 的规范化请求参数 |
+| `filtered_count` | integer | 被时长过滤的数量 |
+| `provider_search_id` | string,可选 | 供应商返回的分页 `search_id`;避免与数据库 `search_id` 混淆 |
+| `backtrace` | string | 下一页回传值 |
+| `duration_ms` | integer | 上游调用耗时 |
+
+分页时必须同时复用上一页的 `next_cursor`、`provider_search_id` 和 `backtrace`。数据库
+`search_id` 只能用于父子搜索关系,不能作为 TikHub 的供应商分页参数。
+
+### 6.4 `douyin_user_videos`
+
+#### 功能
+
+按作者完整 `sec_uid` 获取一页最热或最新作品,适合从高潜作者扩展更多候选;结果自动落库。
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `run_id` | string | 是 | - | 运行 ID |
+| `account_id` | string | 是 | - | 作者 `sec_uid` |
+| `query_reason` | string | 是 | - | 作者扩展原因 |
+| `source_value` | string/null | 否 | `null` | 缺省时使用 `account_id` |
+| `parent_search_id` | integer/null | 否 | `null` | 触发作者扩展的父搜索页 ID |
+| `page_no` | integer | 否 | `1` | 页码 |
+| `sort_type` | string | 否 | `最热` | 仅支持 `最热`、`最新` |
+| `cursor` | string | 否 | `""` | 作者作品分页游标 |
+| `min_duration_seconds` | integer | 否 | `30` | 实际不低于 30 秒 |
+| `timeout` | number/null | 否 | `null` | `null` 时默认 60 秒 |
+
+#### 返回结果
+
+使用 6.5 的搜索页公共结构,并保留:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `account_id` | string | 作者 `sec_uid` |
+| `sort_type` | string | 实际排序方式 |
+| `filtered_count` | integer | 被时长过滤的数量 |
+| `duration_ms` | integer | 上游调用耗时 |
+
+持久化返回前会移除原始 `user_videos` 和 `search_results` 大数组,只保留数据库生成的候选摘要。
+
+### 6.5 搜索页公共返回结构
+
+三个单页搜索工具成功持久化后共同返回:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `run_id` | string | 运行 ID |
+| `search_id` | integer | 数据库搜索页 ID |
+| `keyword` | string | 本页搜索词;作者作品为 `author:{account_id}` |
+| `query_reason` | string | 搜索原因 |
+| `source_type` | string | 实际保存的来源类型 |
+| `source_value` | string/null | 来源值 |
+| `parent_search_id` | integer/null | 父搜索页 ID |
+| `provider` | string | `internal_keyword` / `tikhub` / `internal_blogger` |
+| `provider_state` | object | 保存的供应商分页状态 |
+| `content_type` | string | 内容类型 |
+| `sort_type` | string | 排序方式 |
+| `publish_time` | string | 发布时间筛选 |
+| `cursor` | string | 本页请求游标 |
+| `page_no` | integer | 规范化页码 |
+| `results_count` | integer | 上游本页结果数 |
+| `new_candidate_count` | integer | 本页实际新增候选数 |
+| `has_more` | boolean | 是否有下一页 |
+| `next_cursor` | string 或 integer/null | 下一页游标 |
+| `status` | string | `success` 或 `failed` |
+| `error_message` | string/null | 持久化的搜索错误 |
+| `raw_evidence_id` | integer/null | 原始搜索证据 ID |
+| `candidates` | array<object> | 本页新建候选摘要 |
+| `persisted` | boolean | 成功落库时为 `true` |
+
+`candidates[]` 摘要字段:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `candidate_id` | integer | 后续详情、画像和更新使用的数据库 ID |
+| `search_id` | integer | 所属搜索页 |
+| `aweme_id` | string | 抖音视频 ID |
+| `title` | string/null | 标题或描述 |
+| `author_name` | string/null | 作者名 |
+| `author_sec_uid` | string/null | 作者完整 `sec_uid` |
+| `publish_at` | string/null | 发布时间 |
+| `duration_seconds` | number/null | 视频时长,秒 |
+| `share_count` | integer/null | 分享数 |
+| `gate_status` | string | 初始通常为 `pending` |
+
+相同 `aweme_id` 被不同搜索命中会创建不同 `candidate_id`,不会按视频 ID 合并覆盖。
+
+## 7. 详情工具
+
+### 7.1 `douyin_detail`
+
+#### 功能
+
+批量获取视频详情、真实播放链接、作者、互动、话题和时间信息。单次最多处理 8 个去重后的视频。
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `content_ids` | array<string>/null | 否 | `null` | 旧模式视频 ID 列表;新流程不推荐 |
+| `timeout` | number/null | 否 | `null` | 单请求超时,`null` 时默认 60 秒 |
+| `run_id` | string/null | 条件必填 | `null` | 使用 `candidate_ids` 时必填 |
+| `candidate_ids` | array<integer>/null | 条件必填 | `null` | 新模式候选 ID,必须属于同一 `run_id` |
+
+组合规则:
+
+- 有 `candidate_ids` 时忽略 `content_ids`,从数据库读取 `aweme_id`;
+- `candidate_ids` 模式缺少 `run_id` 返回 `input_error=true`;
+- ID 不存在或不属于本次运行时整次输入失败;
+- 去重后为空会报错;超过 8 条会报错;
+- 新模式会追加保存每条成功或失败的原始证据,并重算对应候选门禁。
+
+#### 新模式返回结果
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | 成功数/请求数摘要 |
+| `output` | string | 详情与失败项文本摘要 |
+| `results_count` | integer | 请求视频数 |
+| `success_count` | integer | 成功详情数 |
+| `failed_count` | integer | 失败数 |
+| `errors` | array<object> | `content_id`、`error`;错误文本最多保留 500 字符 |
+| `duration_ms` | integer | 整批耗时 |
+| `candidates` | array<object> | 刷新后的候选门禁摘要 |
+| `candidates[].candidate_id` | integer | 候选 ID |
+| `candidates[].aweme_id` | string | 视频 ID |
+| `candidates[].gate_status` | string | `pending/pass/fail` |
+| `candidates[].failed_reason_codes` | array<string> | 未通过门禁原因 |
+| `candidates[].evidence_version` | integer | 当前证据版本 |
+
+#### 旧模式额外返回
+
+旧模式不自动关联候选,返回 `details` 数组;单条成功时额外返回 `detail`。每条详情常用字段包括:
+
+- `content_id`、`channel_content_id`、`content_link`;
+- `title`、`body_text`、`content_type`、`topic_list`;
+- `video_url`、`video_duration`、`duration_seconds`、`cover_url`;
+- `channel_account_id`、`channel_account_name`、`channel_account_avatar`;
+- `play_count/view_count`、`like_count`、`comment_count`、`collect_count`、`share_count`;
+- `publish_at` 及源数据中的创建、发布、修改时间;
+- `image_url_list`、`video_url_list`、`multi_bitrate`、`bgm_data`、`is_original`。
+
+## 8. 画像与标准化工具
+
+### 8.1 `get_content_fans_portrait`
+
+获取单个视频的点赞用户画像。`content_id` 必须为 15~25 位纯数字 `aweme_id`。
+
+### 8.2 `get_account_fans_portrait`
+
+获取单个作者的粉丝画像。`account_id` 必须是完整 `sec_uid`,并以 `MS4wLjABAAAA` 开头。
+
+### 8.3 两个单条画像工具的公共参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `content_id` / `account_id` | string | 是 | - | 内容或作者 ID |
+| `need_province` | boolean | 否 | `false` | 是否返回省份分布 |
+| `need_city` | boolean | 否 | `false` | 是否返回城市分布 |
+| `need_city_level` | boolean | 否 | `false` | 是否返回城市等级 |
+| `need_gender` | boolean | 否 | `false` | 是否返回性别分布 |
+| `need_age` | boolean | 否 | `true` | 是否返回年龄分布 |
+| `need_phone_brand` | boolean | 否 | `false` | 是否返回手机品牌 |
+| `need_phone_price` | boolean | 否 | `false` | 是否返回手机价格 |
+| `timeout` | number/null | 否 | `null` | `null` 时默认 60 秒 |
+
+### 8.4 两个单条画像工具的公共返回
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | 内容或账号画像标题 |
+| `output` | string | 各画像维度的人类可读摘要;省份和城市只展示 TOP5 |
+| `has_portrait` | boolean | 是否存在任一非空画像维度 |
+| `portrait_data` | object | 画像维度对象;常见维度为年龄、性别、地域等 |
+| `raw_data` | object | 上游原始完整响应 |
+| `duration_ms` | integer | 调用耗时 |
+
+单条工具不自动写入候选证据。需要持久化时应优先使用 `batch_fetch_portraits` 新模式。
+
+### 8.5 `batch_fetch_portraits`
+
+#### 功能
+
+按输入顺序逐条获取内容点赞画像;`fetch_account_portrait=true` 时同时获取作者粉丝画像,
+否则仅在内容画像缺失且允许兜底时请求作者画像。单次最多 8 项。
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `candidates_json` | string | 否 | `""` | 旧模式 JSON 数组字符串 |
+| `fetch_account_portrait` | boolean | 否 | `false` | 是否始终同时取作者画像;老年受众判断建议为 `true` |
+| `need_province` | boolean | 否 | `false` | 同单条画像 |
+| `need_city` | boolean | 否 | `false` | 同单条画像 |
+| `need_city_level` | boolean | 否 | `false` | 同单条画像 |
+| `need_gender` | boolean | 否 | `false` | 同单条画像 |
+| `need_age` | boolean | 否 | `true` | 同单条画像 |
+| `need_phone_brand` | boolean | 否 | `false` | 同单条画像 |
+| `need_phone_price` | boolean | 否 | `false` | 同单条画像 |
+| `timeout` | number/null | 否 | `null` | `null` 时默认 60 秒 |
+| `run_id` | string/null | 条件必填 | `null` | 使用 `candidate_ids` 时必填 |
+| `candidate_ids` | array<integer>/null | 条件必填 | `null` | 新模式候选 ID,最多 8 条 |
+
+旧模式 `candidates_json` 每项:
+
+| 字段 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `aweme_id` | string | 是 | - | 视频 ID;也兼容字段名 `content_id` |
+| `author_sec_uid` | string | 否 | - | 作者 ID;也兼容字段名 `account_id` |
+| `try_account_fallback` | boolean | 否 | `true` | 内容画像缺失时是否尝试作者画像 |
+
+#### 新模式返回
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | 批量完成标题 |
+| `output` | string | 各候选画像获取摘要 |
+| `count` | integer | 处理项数 |
+| `duration_ms` | integer | 整批耗时 |
+| `candidates` | array<object> | 刷新后的候选门禁摘要 |
+| `candidates[].candidate_id` | integer | 候选 ID |
+| `candidates[].aweme_id` | string | 视频 ID |
+| `candidates[].gate_status` | string | `pending/pass/fail` |
+| `candidates[].failed_reason_codes` | array<string> | 门禁失败原因 |
+| `candidates[].evidence_version` | integer | 证据版本 |
+
+#### 旧模式返回
+
+旧模式返回 `results`,每项包含:
+
+- `aweme_id`、`author_sec_uid`、`try_account_fallback`、`fetch_account_portrait`;
+- `content`:`ok/error/has_portrait/portrait_data/output`;
+- `account`:`attempted/error/skipped_reason/has_portrait/portrait_data/output`;
+- `age_normalization`:两侧年龄标准化结果;
+- `error`:条目级错误。
+
+批量画像允许逐项失败,不会因为一条上游失败而丢弃整批结果。
+
+### 8.6 `normalize_age_portraits`
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `content_portrait` | object | 是 | - | 视频 `portrait_data` 或其中的年龄字典 |
+| `account_portrait` | object/null | 否 | `null` | 作者 `portrait_data` 或其中的年龄字典 |
+
+#### 返回结果
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | “年龄画像标准化” |
+| `content` | object | 视频侧标准化结果 |
+| `account` | object | 作者侧标准化结果 |
+| `consistency` | string | `aligned/conflict/content_only/account_only/missing` |
+| `elder_score_cap` | number | 当前证据条件下建议的 E 分数上限 |
+| `output` | string | 两侧强度、一致性与上限摘要 |
+
+`content`、`account` 公共字段:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `has_age_portrait` | boolean | 是否识别到年龄维度 |
+| `older_ratio` | number | 50+ 桶占比,范围 0~1 |
+| `older_tgi` | number/null | 50+ 桶按占比加权 TGI |
+| `mature_ratio` | number | 40~50 等成熟代理桶占比 |
+| `strength` | string | `strong/moderate/weak/missing` |
+| `buckets` | array<object> | 原桶的 `label/kind/ratio/tgi` |
+
+当前 `elder_score_cap` 规则:
+
+| 证据情况 | 上限 |
+|---|---:|
+| 内容与作者两侧都有画像 | `1.0` |
+| 仅内容侧 | `1.0` |
+| 仅作者侧且强证据 | `0.85` |
+| 仅作者侧且非强证据 | `0.75` |
+| 两侧均缺失 | `0.50` |
+
+此上限是返回给模型的决策建议,候选更新工具不会硬校验 `elder_score <= elder_score_cap`。
+
+## 9. 候选更新工具
+
+### 9.1 `batch_update_video_discovery_candidates`
+
+#### 功能
+
+严格按 `candidate_id` 批量更新候选详情、画像证据、R/E/S/V 评分、理由和分池。该工具只更新
+候选,不新增候选、不修改搜索记录,也不结束运行。
+
+#### 顶层参数
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `run_id` | string | 是 | 运行 ID |
+| `items` | array<object> | 是 | 非空候选更新数组 |
+
+#### `items[]` 字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `candidate_id` | integer | 是 | 必须大于 0、存在且属于 `run_id` |
+| `decision_bucket` | string | 是 | 仅 `primary` 或 `rejected` |
+| `title` | string | 否 | 标题,最多 512 字符 |
+| `content_link` | string | 否 | 内容链接,最多 1024 字符 |
+| `author_name` | string | 否 | 作者名,最多 256 字符 |
+| `author_sec_uid` | string | 否 | 作者 ID,最多 256 字符 |
+| `tags` | array/string | 否 | 标签;与已有标签合并 |
+| `publish_at` | string/number | 否 | ISO 时间、日期或秒/毫秒时间戳 |
+| `duration_seconds` | number | 否 | 秒;也兼容 `video_duration` |
+| `play_count` | integer | 否 | 播放数,负值归零 |
+| `like_count` | integer | 否 | 点赞数,负值归零 |
+| `comment_count` | integer | 否 | 评论数,负值归零 |
+| `collect_count` | integer | 否 | 收藏数,负值归零 |
+| `share_count` | integer | 否 | 分享数,负值归零 |
+| `content_age_evidence` | object | 否 | 视频侧原始/结构化年龄证据 |
+| `account_age_evidence` | object | 否 | 作者侧原始/结构化年龄证据 |
+| `age_normalization` | object | 否 | 标准化结果;兼容包在 `result` 等字段内 |
+| `content_50_plus_ratio` | number | 否 | 视频侧 50+ 占比,必须在 0~1 |
+| `content_50_plus_tgi` | number | 否 | 视频侧 50+ TGI |
+| `account_50_plus_ratio` | number | 否 | 作者侧 50+ 占比,必须在 0~1 |
+| `account_50_plus_tgi` | number | 否 | 作者侧 50+ TGI |
+| `temporal_type` | string | 否 | 建议 `evergreen/event/seasonal/festival/daypart` |
+| `temporal_status` | string | 否 | 显式 `fail/unknown` 会参与时间门禁;其他值由规则重算 |
+| `temporal_evidence` | object | 否 | 时效判断证据 |
+| `reject_reason_code` | string | 否 | 拒绝原因码,最多 64 字符 |
+| `relevance_score` | number | 否 | `R`,保存 6 位小数 |
+| `elder_score` | number | 否 | `E`,保存 6 位小数 |
+| `share_score` | number | 否 | `S`,保存 6 位小数 |
+| `value_score` | number | 否 | `V`,保存 2 位小数 |
+| `decision_reason` | string | 否 | 最终判断理由 |
+
+#### 校验和重分类
+
+- 任一 `items[]` 不是对象、缺少有效 `candidate_id` 或分池非法,整批在写库前失败;
+- 不存在或不属于本次运行的 `candidate_id` 会使数据库更新失败;
+- `content/account_50_plus_ratio` 被硬校验为 0~1;
+- R/E/S/V 当前只做数字转换和精度处理,不硬校验 0~1,也不自动计算 `V`;
+- 请求 `primary` 但证据未齐时保存为 `pending_evaluation`;
+- 请求 `primary` 但 P0 门禁失败时自动保存为 `rejected`;
+- 请求 `rejected` 时即使门禁通过也仍保存为 `rejected`。
+
+默认 P0 规则快照包含:时效有效、时长至少 30 秒、分享至少 1000、视频或作者任一侧 50+ 占比
+至少 20%。部分缺失项可由代码中的高质量信号补偿;运行使用预创建时保存的规则快照,环境变量
+可修改阈值。
+
+#### 返回结果
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | “候选已更新” |
+| `run_id` | string | 运行 ID |
+| `updated_count` | integer | 成功更新候选数 |
+| `reclassified_count` | integer | 门禁导致分池变化的数量 |
+| `reclassified` | array<object> | `candidate_id/requested_bucket/saved_bucket/failed_reason_codes` |
+| `candidates` | array<object> | 更新后候选摘要 |
+| `candidates[].candidate_id` | integer | 候选 ID |
+| `candidates[].aweme_id` | string | 视频 ID |
+| `candidates[].decision_bucket` | string | 实际保存的分池 |
+| `candidates[].gate_status` | string | `pending/pass/fail` |
+| `candidates[].failed_reason_codes` | array<string> | 门禁失败码 |
+| `candidates[].pending_reason_codes` | array<string> | 尚缺证据的原因码 |
+| `candidates[].evidence_version` | integer | 候选证据版本 |
+| `candidates[].gate_evidence_version` | integer/null | 门禁使用的证据版本 |
+| `candidates[].latest_gate_evaluation_id` | integer/null | 最近门禁评估记录 ID |
+| `output` | string | 更新与重分类摘要 |
+
+## 10. 运行控制与查询工具
+
+### 10.1 `update_video_discovery_run_status`
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `run_id` | string | 是 | - | 运行 ID |
+| `status` | string | 是 | - | 仅 `running/finished/failed` |
+| `intent_summary` | string/null | 否 | `null` | Agent 对需求真实意图的总结 |
+| `stop_reason` | string/null | 否 | `null` | 停止搜索或失败原因 |
+
+#### 行为
+
+- `finished`:把所有剩余 `pending_evaluation/unreviewed` 候选归档为 `rejected`,刷新计数并计算业务结果;
+- `failed`:设置 `outcome_status=failed`;
+- `running`:只更新运行状态及可选说明,不清空既有搜索和候选。
+
+业务结果:有效且按 `aweme_id` 去重的 `primary` 至少 5 条为 `goal_met`,1~4 条为 `partial`,
+0 条为 `no_match`。
+
+#### 返回结果
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | 状态更新标题 |
+| `run` | object | 完整运行摘要,见 10.3 |
+| `output` | string | `run_id/status/primary_count` 摘要 |
+
+### 10.2 `query_video_discovery_state`
+
+#### 参数
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---|---|
+| `run_id` | string | 是 | - | 运行 ID |
+| `include_rejected` | boolean | 否 | `true` | 是否包含 `rejected`;为 false 时仍包含 `primary` 和待评估候选 |
+| `limit` | integer | 否 | `100` | 候选返回上限,代码约束为 `1~500` |
+
+#### 返回结果
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` | string | 查询标题 |
+| `run` | object | 运行摘要 |
+| `searches` | array<object> | 所有搜索页,结构见 10.4 |
+| `candidates` | array<object> | 候选详情,结构见 10.5 |
+| `output` | string | 搜索页数和主推荐数摘要 |
+
+### 10.3 `run` 对象
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `run_id` | string | 运行 ID |
+| `biz_dt` | string/null | 业务日期 |
+| `demand_grade_id` | integer/null | 需求分级记录 ID |
+| `demand_word` | string | 需求词 |
+| `seed_video_id` | string/null | 兼容旧流程的主参考视频 ID |
+| `seed_video_title` | string/null | 兼容旧流程的主参考视频标题 |
+| `intent_summary` | string/null | 意图总结 |
+| `status` | string | `running/finished/failed` |
+| `search_count` | integer | 已保存搜索页数 |
+| `primary_count` | integer | 当前 primary 记录数 |
+| `valid_primary_count` | integer | 门禁通过且按视频去重后的 primary 数 |
+| `outcome_status` | string/null | `goal_met/partial/no_match/failed` |
+| `attempt_count` | integer | 执行次数 |
+| `stop_reason` | string/null | 停止或失败原因 |
+| `rule_version` | string | P0 规则版本 |
+| `rule_config` | object | 本次运行冻结的门禁配置和评估时间 |
+
+### 10.4 `searches[]` 对象
+
+字段与 6.5 搜索页公共结构一致,但不含 `run_id`、`candidates` 和 `persisted`。查询态包含:
+`search_id`、关键词与原因、来源和父子关系、供应商及分页状态、筛选参数、结果数、候选数、
+`has_more/next_cursor`、执行状态、错误信息和原始证据 ID。
+
+### 10.5 `candidates[]` 完整对象
+
+| 字段组 | 字段 |
+|---|---|
+| 标识与来源 | `candidate_id`、`search_id`、`aweme_id`、`source_keywords`、`source_search_ids` |
+| 内容与作者 | `title`、`content_link`、`author_name`、`author_sec_uid`、`tags`、`publish_at`、`duration_seconds` |
+| 互动数据 | `play_count`、`like_count`、`comment_count`、`collect_count`、`share_count` |
+| 模型评分 | `relevance_score`、`elder_score`、`share_score`、`value_score`、`decision_reason` |
+| 分池 | `decision_bucket`、`reject_reason_code` |
+| 年龄证据 | `content_age_evidence`、`account_age_evidence`、`age_normalization`、两侧 `50_plus_ratio/tgi` |
+| 画像状态 | `content_portrait_status`、`account_portrait_status`、`portrait_conflict` |
+| 时效门禁 | `temporal_type`、`temporal_status`、`temporal_evidence` |
+| 门禁结果 | `gate_status`、`gate_results`、`rule_version` |
+| 证据引用 | 四类 `latest_*_evidence_id`、三类 `*_fetch_status` |
+| 版本审计 | `evidence_version`、`gate_evidence_version`、`latest_gate_evaluation_id`、`gate_evaluated_at` |
+
+数据库旧值 `unreviewed` 在返回时统一映射为 `pending_evaluation`。
+
+## 11. 内置工具 `load_skill`
+
+### 参数
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `name` | string | 是 | Skill 名称 |
+
+### 返回
+
+- 找到 Skill:返回包含 Skill 名称、说明和完整指令的上下文文本;
+- 未找到:返回 JSON 字符串 `{"error": "Skill '...' not found. Available: ..."}`。
+
+当前 `find_agent` 没有预加载 Skill,项目 `skills/` 下也没有可发现的 `SKILL.md`,因此正常视频
+发现流程不依赖此工具。
+
+## 12. 非功能要求
+
+### 12.1 性能与限流
+
+- 内部关键词搜索:进程内相邻请求至少间隔约 10.1 秒;
+- 作者作品:进程内相邻请求至少间隔约 10.1 秒;
+- 视频详情:进程内逐条请求至少间隔约 10.1 秒;
+- TikHub 搜索:进程内相邻请求至少间隔 1 秒;
+- 批量搜索和批量画像均按顺序执行,不承诺并发;
+- 单次 Agent 运行默认总超时 600 秒,工具自身默认上游超时通常为 60 秒。
+
+### 12.2 可追溯性
+
+- 每次搜索页都新增记录,不覆盖同参数历史页;
+- 搜索、详情和批量画像新模式保存原始证据;
+- 候选门禁记录证据版本和最近评估 ID;
+- 运行完成后以数据库 `outcome_status` 和 `valid_primary_count` 为准,不能仅看最终文案。
+
+### 12.3 数据安全与体积
+
+- 单条画像工具返回 `raw_data`,可能体积较大;Agent 主流程应优先使用新模式批量画像的候选摘要;
+- 搜索落库返回会移除原始响应和统一搜索结果大数组,但原始响应已保存到证据表;
+- 错误文本可能包含上游响应内容,展示到前台前应做长度控制和敏感信息过滤。
+
+## 13. 已知问题与产品改进项
+
+| 优先级 | 问题 | 影响 | 建议 |
+|---|---|---|---|
+| P0 | `list[dict]` 内部结构未进入 JSON Schema | 模型容易漏填 `keyword/query_reason/candidate_id/decision_bucket` | 使用 Pydantic 模型或 TypedDict 生成嵌套 Schema |
+| P0 | 多数枚举和范围未进入 JSON Schema | 非法 `source_type/status/sort_type` 只能运行时发现 | 为参数增加 enum、minimum、maximum |
+| P1 | 所有工具都返回字符串化 JSON | 调用方易发生二次解析遗漏 | 在框架层明确结构化工具结果协议 |
+| P1 | 搜索工具的数据库 `search_id` 与 TikHub `search_id` 同名 | 翻页时容易传错 ID | 对外统一命名为 `db_search_id/provider_search_id` |
+| P1 | R/E/S/V 不做范围与公式校验 | 可保存越界分数或错误 V | 强制 0~1 并由代码计算 V |
+| P1 | `elder_score_cap` 只提示、不执行 | 模型可能给出高于证据上限的 E | 更新候选时加入硬校验或自动截断 |
+| P1 | 单条画像工具不写证据 | 使用旧模式会出现结果存在但不可追溯 | Prompt 和调用层只开放批量新模式,或给单条工具增加候选模式 |
+| P2 | 批量搜索只返回部分 TikHub 分页字段 | 外部调用方难以手动接管中途分页 | 在 `pages[]` 明确返回 `provider_search_id/backtrace` |
+| P2 | 错误结构不完全统一 | 调用方需兼容 `error/errors/error_message` | 定义统一 `ok/code/error/data` 包装 |
+
+其中年龄上限应以本文件记录的当前代码值为准:仅作者强证据 `0.85`、仅作者非强证据 `0.75`、
+两侧缺失 `0.50`。旧文档中的 `0.65/0.35` 已不符合当前实现。
+
+## 14. 验收标准
+
+### 14.1 文档验收
+
+- 工具数量与 `create_find_agent().tools.list_tools()` 一致;
+- 每个工具的顶层必填项、类型和默认值与运行时 Schema 一致;
+- 搜索任务、候选更新项等嵌套字段与运行时代码校验一致;
+- 返回字段可在对应实现或 Service 序列化函数中逐项定位;
+- 明确区分数据库 ID、平台 ID 和供应商分页 ID。
+
+### 14.2 调用验收
+
+- 搜索成功后必须得到 `search_id` 和至少零个 `candidate_id`,且 `persisted=true`;
+- 详情/画像新模式必须使用同一 `run_id` 下的 `candidate_ids`;
+- 候选更新后必须读取实际 `decision_bucket` 和 `gate_status`,不能假设请求分池等于保存分池;
+- 结束前必须调用状态查询核对搜索数、候选分池和门禁;
+- 正常业务结束必须调用 `status=finished`,并以返回的 `outcome_status` 为最终结果;
+- 任一返回含 `error` 时都必须显式处理,不得视为成功继续。
+
+## 15. 代码依据
+
+| 范围 | 代码位置 |
+|---|---|
+| Agent 注册工具 | `agents/find_agent/tools/__init__.py` |
+| 运行时 Tool Schema | `supply_agent/tools/base.py` |
+| 内置 `load_skill` | `supply_agent/agent/core.py` |
+| 搜索工具 | `agents/find_agent/tools/` 与 `agents/find_agent/support/douyin_*.py` |
+| 搜索持久化 | `agents/find_agent/support/search_persistence.py` |
+| 详情与画像 | `agents/find_agent/support/douyin_detail.py`、`portrait.py`、`age_portrait.py` |
+| 候选与运行工具 | `agents/find_agent/support/video_discovery.py` |
+| 返回对象序列化 | `supply_infra/services/video_discovery_service.py` |
+| P0 门禁 | `supply_infra/video_discovery_gates.py` |
+

+ 167 - 90
agents/find_agent/support/qwen_video_analysis.py

@@ -1,8 +1,7 @@
-"""
-历史千问视频解析内部实现。
+"""千问视频解析的内部实现。
 
-当前 find_agent 明确不使用视频理解,本模块未注册到 Agent,也不属于在线能力
-保留文件仅用于历史兼容,不得因文件或数据库字段存在而推断能力已启用
+视频在交给模型前会先下载,最多保留前 30 秒,并上传到项目 OSS
+本模块仍由调用方按需使用,不在此处决定是否注册到 Agent
 """
 from __future__ import annotations
 
@@ -36,8 +35,11 @@ DEFAULT_MODEL = "qwen3.7-plus"
 DEFAULT_PROMPT = "描述这段视频的内容"
 DEFAULT_FPS = 2.0
 DEFAULT_TIMEOUT = 300.0
-DEFAULT_DOWNLOAD_TIMEOUT = 120.0
-DEFAULT_MAX_DURATION_SECONDS = 180.0
+DEFAULT_DOWNLOAD_TIMEOUT = 60.0
+DEFAULT_UPLOAD_TIMEOUT = 60.0
+DEFAULT_MAX_DURATION_SECONDS = 30.0
+DEFAULT_TOOL_TIMEOUT = 480.0
+DOWNLOAD_ATTEMPTS = 2
 FFPROBE_TIMEOUT_SECONDS = 30.0
 FFMPEG_TIMEOUT_SECONDS = 300.0
 
@@ -53,6 +55,14 @@ class ToolTimeoutError(RuntimeError):
     """A bounded local tool operation exceeded its deadline."""
 
 
+class VideoDownloadError(RuntimeError):
+    """The source video could not be downloaded within the allowed attempts."""
+
+    def __init__(self, message: str, *, timed_out: bool = False) -> None:
+        super().__init__(message)
+        self.timed_out = timed_out
+
+
 def _ensure_env_loaded() -> None:
     """从项目根目录加载 .env,使 os.getenv 能读到其中的变量。"""
     global _env_loaded
@@ -99,7 +109,7 @@ 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"
+            "转存视频需要配置 ALIYUN_OSS_ACCESS_KEY_ID 和 ALIYUN_OSS_ACCESS_KEY_SECRET"
         )
 
 
@@ -244,7 +254,7 @@ def _truncate_video(
         raise RuntimeError(f"ffmpeg 截断视频失败: {detail}")
 
 
-async def _download_video(url: str, dest: Path, timeout: float) -> None:
+async def _download_video_once(url: str, dest: Path, timeout: float) -> None:
     async with httpx.AsyncClient(
         timeout=timeout,
         trust_env=False,
@@ -258,6 +268,45 @@ async def _download_video(url: str, dest: Path, timeout: float) -> None:
                     file_obj.write(chunk)
 
 
+async def _download_video(url: str, dest: Path, timeout: float) -> None:
+    """下载视频,失败后只重试一次。
+
+    httpx 自身的 timeout 是分阶段超时;外层 asyncio.timeout 确保整次
+    下载也有硬截止时间,不会被持续滴流的服务器无限拖住。
+    """
+    last_error: BaseException | None = None
+    timed_out = False
+
+    for attempt in range(1, DOWNLOAD_ATTEMPTS + 1):
+        _safe_unlink(dest)
+        try:
+            async with asyncio.timeout(timeout):
+                await _download_video_once(url, dest, timeout)
+            if not dest.is_file() or dest.stat().st_size <= 0:
+                raise httpx.DecodingError("downloaded video is empty")
+            return
+        except (TimeoutError, httpx.TimeoutException) as exc:
+            last_error = exc
+            timed_out = True
+        except (httpx.HTTPError, OSError) as exc:
+            last_error = exc
+
+        logger.warning(
+            "video download attempt %d/%d failed: url=%s error=%s",
+            attempt,
+            DOWNLOAD_ATTEMPTS,
+            url,
+            last_error,
+        )
+
+    _safe_unlink(dest)
+    reason = str(last_error) or type(last_error).__name__
+    raise VideoDownloadError(
+        f"下载视频失败,已尝试 {DOWNLOAD_ATTEMPTS} 次: {reason}",
+        timed_out=timed_out,
+    ) from last_error
+
+
 def _safe_unlink(path: Path) -> None:
     """删除临时视频文件,失败时仅记录日志。"""
     try:
@@ -281,72 +330,77 @@ async def _prepare_analysis_url(
     video_url: str,
     max_duration_seconds: float | None,
     download_timeout: float,
+    upload_timeout: float = DEFAULT_UPLOAD_TIMEOUT,
 ) -> tuple[str, dict[str, Any]]:
+    effective_max_duration = min(
+        max_duration_seconds
+        if max_duration_seconds is not None
+        else DEFAULT_MAX_DURATION_SECONDS,
+        DEFAULT_MAX_DURATION_SECONDS,
+    )
+    if effective_max_duration <= 0:
+        raise ValueError("max_duration_seconds 必须大于 0")
+
     meta: dict[str, Any] = {
         "original_video_url": video_url,
         "truncated": False,
+        "converted_to_oss": False,
+        "max_duration_seconds": effective_max_duration,
     }
-    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()
+    _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)
+        duration = await asyncio.to_thread(_probe_duration, ffmpeg, str(source_path))
+        if duration is not None:
+            meta["original_duration_seconds"] = duration
+
+        upload_path = source_path
+        if duration is None or duration > effective_max_duration:
+            await asyncio.to_thread(
+                _truncate_video,
+                ffmpeg,
+                source_path,
+                clipped_path,
+                effective_max_duration,
+            )
+            upload_path = clipped_path
+            meta["truncated"] = True
+        else:
+            logger.info(
+                "downloaded video within limit; uploading the complete file: "
+                "duration=%.1fs max=%.1fs",
+                duration,
+                effective_max_duration,
+            )
 
-        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)
+        try:
+            analysis_url = await asyncio.wait_for(
+                asyncio.to_thread(
+                    _upload_clip,
+                    upload_path,
+                    video_url,
+                    effective_max_duration,
+                ),
+                timeout=upload_timeout,
+            )
+        except TimeoutError:
+            raise ToolTimeoutError(
+                f"OSS 上传视频超时({upload_timeout:.0f}秒)"
+            ) from None
 
-    meta["truncated"] = True
+    meta["converted_to_oss"] = 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",
+        "video prepared: original_duration=%s max=%.1fs truncated=%s analysis_url=%s",
         meta.get("original_duration_seconds"),
-        max_duration_seconds,
+        effective_max_duration,
+        meta["truncated"],
         analysis_url,
     )
     return analysis_url, meta
@@ -493,12 +547,12 @@ async def qwen_video_analyze(
     timeout: Optional[float] = None,
     max_duration_seconds: Optional[float] = DEFAULT_MAX_DURATION_SECONDS,
     download_timeout: Optional[float] = None,
+    tool_timeout: Optional[float] = None,
 ) -> str:
     """
-    历史视频解析函数,不注册为 find_agent 工具。
-
     通过阿里云百炼平台调用 qwen3.7-plus 模型,分析视频 URL 并返回文字描述。
-    需要设置环境变量 DASHSCOPE_API_KEY。超长视频会先截断再解析。
+    需要设置环境变量 DASHSCOPE_API_KEY。所有视频都会先下载并转存到项目 OSS;
+    超过 30 秒时只保留前 30 秒,不超过时保留完整视频。
 
     Args:
         video_url: 视频地址(需公网可访问的 mp4 等格式)
@@ -506,9 +560,10 @@ async def qwen_video_analyze(
         fps: 视频抽帧频率,默认 2(每秒采样 2 帧)
         model: 模型名称,默认 "qwen3.7-plus"
         timeout: 请求超时时间(秒),默认 300
-        max_duration_seconds: 解析前最长保留秒数,默认 180(3 分钟);
-            传 None 表示不截断
-        download_timeout: 下载原视频超时时间(秒),默认 120
+        max_duration_seconds: 兼容参数,最大值固定限制为 30 秒;None 也按 30 秒处理
+        download_timeout: 单次下载原视频的整体超时时间(秒),默认 60;
+            下载失败后只重试一次
+        tool_timeout: 整个工具的硬超时时间(秒),默认 480
 
     Returns:
         JSON 字符串,包含 content(解析文本)和 output(同 content,供 LLM 阅读)。
@@ -518,22 +573,41 @@ async def qwen_video_analyze(
     request_download_timeout = (
         download_timeout if download_timeout is not None else DEFAULT_DOWNLOAD_TIMEOUT
     )
+    request_tool_timeout = tool_timeout if tool_timeout is not None else DEFAULT_TOOL_TIMEOUT
 
-    try:
-        analysis_url, truncate_meta = await _prepare_analysis_url(
-            video_url,
-            max_duration_seconds,
-            request_download_timeout,
-        )
-        content = await asyncio.to_thread(
-            _analyze_video_sync,
-            analysis_url,
-            prompt,
-            fps,
-            model,
-            request_timeout,
+    if not video_url.strip():
+        return _error_result("视频地址不能为空", error_code="invalid_argument", retryable=False)
+    if request_timeout <= 0 or request_download_timeout <= 0 or request_tool_timeout <= 0:
+        return _error_result(
+            "timeout、download_timeout 和 tool_timeout 必须大于 0",
+            error_code="invalid_argument",
+            retryable=False,
         )
 
+    try:
+        async with asyncio.timeout(request_tool_timeout):
+            analysis_url, truncate_meta = await _prepare_analysis_url(
+                video_url,
+                max_duration_seconds,
+                request_download_timeout,
+            )
+            try:
+                content = await asyncio.wait_for(
+                    asyncio.to_thread(
+                        _analyze_video_sync,
+                        analysis_url,
+                        prompt,
+                        fps,
+                        model,
+                        request_timeout,
+                    ),
+                    timeout=request_timeout,
+                )
+            except TimeoutError:
+                raise ToolTimeoutError(
+                    f"视频模型请求超时({request_timeout:.0f}秒)"
+                ) from None
+
         duration_ms = int((time.time() - start_time) * 1000)
         logger.info(
             "qwen_video_analyze completed: video_url=%s analysis_url=%s truncated=%s model=%s duration_ms=%d",
@@ -552,6 +626,17 @@ async def qwen_video_analyze(
             extra=truncate_meta,
         )
 
+    except VideoDownloadError as e:
+        logger.warning(
+            "qwen_video_analyze download abandoned: video_url=%s error=%s",
+            video_url,
+            e,
+        )
+        return _error_result(
+            str(e),
+            error_code="download_timeout" if e.timed_out else "download_failed",
+            retryable=False,
+        )
     except ToolTimeoutError as e:
         logger.warning(
             "qwen_video_analyze local operation timed out: video_url=%s error=%s",
@@ -563,28 +648,20 @@ async def qwen_video_analyze(
             error_code="tool_timeout",
             retryable=True,
         )
-    except ValueError as e:
-        logger.error("qwen_video_analyze config error: %s", e)
-        return _error_result(str(e))
-    except httpx.TimeoutException as e:
+    except TimeoutError:
         logger.warning(
-            "qwen_video_analyze download timed out: video_url=%s error=%s",
+            "qwen_video_analyze exceeded overall timeout: video_url=%s timeout=%s",
             video_url,
-            e,
+            request_tool_timeout,
         )
         return _error_result(
-            f"下载视频超时: {e}",
+            f"视频解析工具超时({request_tool_timeout:.0f}秒)",
             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 ValueError as e:
+        logger.error("qwen_video_analyze config error: %s", e)
+        return _error_result(str(e))
     except APITimeoutError as e:
         logger.warning(
             "qwen_video_analyze model request timed out: video_url=%s error=%s",

+ 210 - 0
tests/supply_agent/test_qwen_video_analysis.py

@@ -0,0 +1,210 @@
+"""Regression tests for the bounded Qwen video-analysis pipeline."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+
+from agents.find_agent.support import qwen_video_analysis as video_analysis
+
+
+@pytest.mark.asyncio
+async def test_download_retries_once_then_abandons(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    attempts = 0
+    destination = tmp_path / "video.mp4"
+
+    async def fail_download(url: str, dest: Path, timeout: float) -> None:
+        nonlocal attempts
+        attempts += 1
+        dest.write_bytes(b"partial")
+        raise httpx.ReadError("connection dropped")
+
+    monkeypatch.setattr(video_analysis, "_download_video_once", fail_download)
+
+    with pytest.raises(video_analysis.VideoDownloadError) as exc_info:
+        await video_analysis._download_video(
+            "https://source.example/video.mp4",
+            destination,
+            1.0,
+        )
+
+    assert attempts == 2
+    assert "已尝试 2 次" in str(exc_info.value)
+    assert not destination.exists()
+
+
+@pytest.mark.asyncio
+async def test_download_timeout_is_a_hard_per_attempt_deadline(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    attempts = 0
+
+    async def stalled_download(url: str, dest: Path, timeout: float) -> None:
+        nonlocal attempts
+        attempts += 1
+        await asyncio.sleep(1)
+
+    monkeypatch.setattr(video_analysis, "_download_video_once", stalled_download)
+
+    with pytest.raises(video_analysis.VideoDownloadError) as exc_info:
+        await asyncio.wait_for(
+            video_analysis._download_video(
+                "https://source.example/video.mp4",
+                tmp_path / "video.mp4",
+                0.01,
+            ),
+            timeout=0.2,
+        )
+
+    assert attempts == 2
+    assert exc_info.value.timed_out is True
+
+
+@pytest.mark.asyncio
+async def test_short_video_is_kept_complete_and_converted_to_oss(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    uploaded: dict[str, object] = {}
+
+    async def download(url: str, dest: Path, timeout: float) -> None:
+        dest.write_bytes(b"complete-short-video")
+
+    def upload(path: Path, source_url: str, max_duration: float) -> str:
+        uploaded["bytes"] = path.read_bytes()
+        uploaded["max_duration"] = max_duration
+        return "https://ours.example/video-clips/short_30s.mp4"
+
+    monkeypatch.setattr(video_analysis, "_require_ffmpeg", lambda: "ffmpeg")
+    monkeypatch.setattr(video_analysis, "_ensure_oss_configured", lambda: None)
+    monkeypatch.setattr(video_analysis, "_download_video", download)
+    monkeypatch.setattr(video_analysis, "_probe_duration", lambda ffmpeg, path: 12.5)
+    monkeypatch.setattr(video_analysis, "_upload_clip", upload)
+    monkeypatch.setattr(
+        video_analysis,
+        "_truncate_video",
+        lambda *args: pytest.fail("short video must not be truncated"),
+    )
+
+    analysis_url, meta = await video_analysis._prepare_analysis_url(
+        "https://source.example/short.mp4",
+        None,
+        1.0,
+    )
+
+    assert analysis_url == "https://ours.example/video-clips/short_30s.mp4"
+    assert uploaded == {"bytes": b"complete-short-video", "max_duration": 30.0}
+    assert meta["truncated"] is False
+    assert meta["converted_to_oss"] is True
+    assert meta["original_duration_seconds"] == 12.5
+    assert meta["max_duration_seconds"] == 30.0
+
+
+@pytest.mark.asyncio
+async def test_long_video_is_clipped_to_30_seconds_and_converted_to_oss(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    truncated_at: list[float] = []
+    uploaded_bytes: list[bytes] = []
+
+    async def download(url: str, dest: Path, timeout: float) -> None:
+        dest.write_bytes(b"long-video")
+
+    def truncate(
+        ffmpeg: str,
+        source: Path,
+        output: Path,
+        max_duration: float,
+    ) -> None:
+        truncated_at.append(max_duration)
+        output.write_bytes(b"first-30-seconds")
+
+    def upload(path: Path, source_url: str, max_duration: float) -> str:
+        uploaded_bytes.append(path.read_bytes())
+        return "https://ours.example/video-clips/long_30s.mp4"
+
+    monkeypatch.setattr(video_analysis, "_require_ffmpeg", lambda: "ffmpeg")
+    monkeypatch.setattr(video_analysis, "_ensure_oss_configured", lambda: None)
+    monkeypatch.setattr(video_analysis, "_download_video", download)
+    monkeypatch.setattr(video_analysis, "_probe_duration", lambda ffmpeg, path: 90.0)
+    monkeypatch.setattr(video_analysis, "_truncate_video", truncate)
+    monkeypatch.setattr(video_analysis, "_upload_clip", upload)
+
+    analysis_url, meta = await video_analysis._prepare_analysis_url(
+        "https://source.example/long.mp4",
+        180.0,
+        1.0,
+    )
+
+    assert analysis_url == "https://ours.example/video-clips/long_30s.mp4"
+    assert truncated_at == [30.0]
+    assert uploaded_bytes == [b"first-30-seconds"]
+    assert meta["truncated"] is True
+    assert meta["converted_to_oss"] is True
+    assert meta["max_duration_seconds"] == 30.0
+
+
+@pytest.mark.asyncio
+async def test_qwen_receives_only_the_converted_url(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    received_urls: list[str] = []
+
+    async def prepare(*args: object, **kwargs: object) -> tuple[str, dict[str, object]]:
+        return "https://ours.example/video.mp4", {
+            "converted_to_oss": True,
+            "original_video_url": "https://source.example/video.mp4",
+        }
+
+    def analyze(
+        video_url: str,
+        prompt: str,
+        fps: float,
+        model: str,
+        timeout: float,
+    ) -> str:
+        received_urls.append(video_url)
+        return "analysis"
+
+    monkeypatch.setattr(video_analysis, "_prepare_analysis_url", prepare)
+    monkeypatch.setattr(video_analysis, "_analyze_video_sync", analyze)
+
+    result = json.loads(
+        await video_analysis.qwen_video_analyze(
+            "https://source.example/video.mp4",
+            tool_timeout=1.0,
+        )
+    )
+
+    assert received_urls == ["https://ours.example/video.mp4"]
+    assert result["video_url"] == "https://ours.example/video.mp4"
+    assert result["original_video_url"] == "https://source.example/video.mp4"
+
+
+@pytest.mark.asyncio
+async def test_whole_tool_has_a_hard_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
+    async def never_finishes(*args: object, **kwargs: object) -> tuple[str, dict[str, object]]:
+        await asyncio.Event().wait()
+        raise AssertionError("unreachable")
+
+    monkeypatch.setattr(video_analysis, "_prepare_analysis_url", never_finishes)
+
+    result = json.loads(
+        await asyncio.wait_for(
+            video_analysis.qwen_video_analyze(
+                "https://source.example/video.mp4",
+                tool_timeout=0.01,
+            ),
+            timeout=0.2,
+        )
+    )
+
+    assert result["error_code"] == "tool_timeout"
+    assert "视频解析工具超时" in result["error"]

+ 1 - 0
web/src/App.vue

@@ -17,6 +17,7 @@ const adminNavItems = [
   { to: '/pipeline-runs', label: '定时任务', icon: '◷', admin: true },
   { to: '/llm-billing', label: 'LLM 费用', icon: '$', admin: true },
   { to: '/demand-process', label: 'Agent 审计', icon: '◎', admin: true },
+  { to: '/find-agent-framework', label: '寻找 Agent 框架', icon: '⌬', admin: false },
   { to: '/find-agent-records', label: '找视频记录', icon: '⌕', admin: false },
   { to: '/admin/users', label: '用户管理', icon: '◇', admin: true },
 ]

+ 7 - 0
web/src/router.ts

@@ -4,6 +4,7 @@ import DemandProcessView from './views/DemandProcessView.vue'
 import GlobalDemandMapView from './views/GlobalDemandMapView.vue'
 import ForbiddenView from './views/ForbiddenView.vue'
 import FindAgentRecordsView from './views/FindAgentRecordsView.vue'
+import FindAgentFrameworkView from './views/FindAgentFrameworkView.vue'
 import LoginView from './views/LoginView.vue'
 import OverviewView from './views/OverviewView.vue'
 import PipelineRunsView from './views/PipelineRunsView.vue'
@@ -51,6 +52,12 @@ export const router = createRouter({
       component: FindAgentRecordsView,
       meta: { title: '找视频记录', userAllowed: true },
     },
+    {
+      path: '/find-agent-framework',
+      name: 'find-agent-framework',
+      component: FindAgentFrameworkView,
+      meta: { title: '寻找 Agent 框架', userAllowed: true },
+    },
     {
       path: '/video-discovery',
       name: 'video-discovery',

+ 617 - 0
web/src/views/FindAgentFrameworkView.vue

@@ -0,0 +1,617 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { RouterLink } from 'vue-router'
+
+type StageTone = 'indigo' | 'blue' | 'cyan' | 'amber' | 'green' | 'violet'
+
+interface FlowStage {
+  id: string
+  index: string
+  title: string
+  subtitle: string
+  summary: string
+  input: string
+  output: string
+  tools: string[]
+  constraints: string[]
+  tone: StageTone
+}
+
+const stages: FlowStage[] = [
+  {
+    id: 'context',
+    index: '01',
+    title: '上下文就绪',
+    subtitle: 'CONTEXT READY',
+    summary: '调度器装配需求、参考视频和拓展点位,并在模型运行前预创建唯一 run_id。',
+    input: 'S / A 级需求、业务日期、参考视频、目的点 / 关键点 / 灵感点',
+    output: 'FindDemandContext、输入快照、status=running 的运行记录',
+    tools: ['调度器预创建(非 Agent Tool)'],
+    constraints: ['没有有效点位的需求不进入运行', '同一业务日与需求只自动执行一次', '后续写库必须复用预创建 run_id'],
+    tone: 'indigo',
+  },
+  {
+    id: 'search',
+    index: '02',
+    title: '搜索召回',
+    subtitle: 'SEARCH & RECALL',
+    summary: '模型规划多个语义不同的搜索词,按关键词、翻页、标签或作者扩展候选。',
+    input: '需求意图、参考标题、拓展点位、既有搜索前沿',
+    output: '搜索页、原始搜索证据、带 candidate_id 的候选记录',
+    tools: ['batch_search_and_record', 'douyin_search', 'douyin_search_tikhub', 'douyin_user_videos'],
+    constraints: ['批量搜索最多 6 个任务、每项最多 2 页', '每页立即落库,不覆盖历史搜索', '同一视频被不同搜索命中时保留独立候选'],
+    tone: 'blue',
+  },
+  {
+    id: 'evidence',
+    index: '03',
+    title: '证据补全',
+    subtitle: 'EVIDENCE',
+    summary: '对高潜候选获取详情、内容点赞画像与作者粉丝画像,并统一标准化年龄证据。',
+    input: 'run_id、候选 candidate_ids、搜索阶段已有的基础信息',
+    output: '详情证据、双侧画像、年龄标准化结果、刷新后的门禁状态',
+    tools: ['douyin_detail', 'batch_fetch_portraits', 'get_content_fans_portrait', 'get_account_fans_portrait', 'normalize_age_portraits'],
+    constraints: ['详情和批量画像单次最多 8 条', '优先使用 run_id + candidate_ids 新模式', '数据缺失是未知,不直接等于负证据'],
+    tone: 'cyan',
+  },
+  {
+    id: 'decision',
+    index: '04',
+    title: '评分与门禁',
+    subtitle: 'DECISION GATE',
+    summary: '模型判断 R / E / S / V,代码用冻结的 P0 规则快照决定候选能否进入主推荐。',
+    input: '相关性、受众倾向、分享信号、时效、时长与互动证据',
+    output: 'primary / rejected / pending_evaluation 与版本化门禁结果',
+    tools: ['batch_update_video_discovery_candidates'],
+    constraints: ['primary 必须通过 P0 门禁', '未补齐证据时自动回到 pending_evaluation', '门禁失败的 primary 自动重分类为 rejected'],
+    tone: 'amber',
+  },
+  {
+    id: 'verify',
+    index: '05',
+    title: '状态核对',
+    subtitle: 'STATE VERIFY',
+    summary: '重新从数据库读取运行、搜索和候选,判断继续搜索是否还能改变结果。',
+    input: '当前 run_id 与已经持久化的全量执行轨迹',
+    output: 'run / searches / candidates 的一致性快照',
+    tools: ['query_video_discovery_state'],
+    constraints: ['最终判断以数据库状态为准', '需要区分 primary_count 与 valid_primary_count', '剩余前沿无信息价值时才停止搜索'],
+    tone: 'green',
+  },
+  {
+    id: 'finish',
+    index: '06',
+    title: '完成与输出',
+    subtitle: 'FINALIZE',
+    summary: '归档未评估候选、计算业务结果,再由模型输出面向用户的执行摘要。',
+    input: '最终候选池、意图摘要、停止原因',
+    output: 'finished / failed 与 goal_met / partial / no_match / failed',
+    tools: ['update_video_discovery_run_status'],
+    constraints: ['finished 会归档全部待评估候选', '有效 primary ≥ 5 才是 goal_met', '最终文本不能替代 outcome_status'],
+    tone: 'violet',
+  },
+]
+
+const activeStageId = ref('search')
+const activeStage = computed(() => stages.find((stage) => stage.id === activeStageId.value) ?? stages[0])
+
+const dataStores = [
+  { name: 'RUN', table: 'video_discovery_run', detail: '运行状态 · 业务结果 · 规则快照' },
+  { name: 'SEARCH', table: 'video_discovery_search', detail: '搜索词 · 父子关系 · 分页状态' },
+  { name: 'CANDIDATE', table: 'video_discovery_candidate', detail: '候选证据 · R/E/S/V · 最终分池' },
+  { name: 'EVIDENCE', table: 'video_discovery_evidence', detail: '搜索 / 详情 / 画像原始响应' },
+  { name: 'GATE', table: 'video_discovery_gate_evaluation', detail: '规则版本 · 证据版本 · 失败原因' },
+]
+</script>
+
+<template>
+  <div class="framework-page">
+    <header class="page-hero">
+      <div class="hero-copy">
+        <span class="eyebrow">FIND AGENT · SYSTEM MAP</span>
+        <h1>寻找 Agent 框架流程</h1>
+        <p>从标准需求输入到可追溯候选输出,展示模型循环、工具编排、证据门禁和持久化状态之间的关系。</p>
+        <div class="hero-actions">
+          <RouterLink to="/find-agent-records" class="primary-link">查看运行记录 <span>→</span></RouterLink>
+          <a href="#stage-flow" class="secondary-link">浏览执行阶段</a>
+        </div>
+      </div>
+
+      <div class="runtime-boundary" aria-label="运行边界">
+        <span class="boundary-label">RUNTIME BOUNDARY</span>
+        <div class="boundary-grid">
+          <div><small>MODEL</small><strong>Gemini 3 Flash</strong></div>
+          <div><small>REACT</small><strong>最多 60 轮</strong></div>
+          <div><small>TIMEOUT</small><strong>默认 600 秒</strong></div>
+          <div><small>TOOLS</small><strong>13 个注册工具</strong></div>
+        </div>
+      </div>
+    </header>
+
+    <section class="architecture-map" aria-labelledby="architecture-title">
+      <div class="section-heading">
+        <div>
+          <span>01 · ORCHESTRATION</span>
+          <h2 id="architecture-title">框架总览</h2>
+        </div>
+        <p>模型负责决定下一步,工具负责读取与写入事实,完成守卫负责阻止没有业务结果的提前结束。</p>
+      </div>
+
+      <div class="kernel-flow">
+        <article class="boundary-node input-node">
+          <div class="node-icon">IN</div>
+          <div>
+            <span>标准任务输入</span>
+            <strong>需求 + 参考视频 + 拓展点位</strong>
+            <small>调度器预创建 run_id</small>
+          </div>
+        </article>
+
+        <span class="flow-connector" aria-hidden="true"><i /><b>→</b></span>
+
+        <article class="agent-kernel">
+          <header>
+            <div>
+              <span>FIND_AGENT KERNEL</span>
+              <strong>LLM ReAct 控制器</strong>
+            </div>
+            <em>异步顺序工具调用</em>
+          </header>
+          <div class="react-loop" aria-label="ReAct 循环">
+            <div><b>01</b><span>Reason</span><small>理解状态</small></div>
+            <i>→</i>
+            <div><b>02</b><span>Act</span><small>选择工具</small></div>
+            <i>→</i>
+            <div><b>03</b><span>Observe</span><small>解析结果</small></div>
+            <i>↺</i>
+          </div>
+          <footer>
+            <span><i class="dot model" />系统 Prompt</span>
+            <span><i class="dot tool" />Tool Registry</span>
+            <span><i class="dot guard" />Completion Guard</span>
+          </footer>
+        </article>
+
+        <span class="flow-connector" aria-hidden="true"><i /><b>→</b></span>
+
+        <article class="boundary-node output-node">
+          <div class="node-icon">OUT</div>
+          <div>
+            <span>结构化业务结果</span>
+            <strong>候选池 + 证据 + 结果状态</strong>
+            <small>goal_met / partial / no_match</small>
+          </div>
+        </article>
+      </div>
+
+      <div class="guard-rail">
+        <span>完成守卫</span>
+        <i />
+        <p><strong>status=finished</strong> 且存在业务结果时,模型的无工具回答才可成为最终输出。</p>
+      </div>
+    </section>
+
+    <section id="stage-flow" class="stage-section" aria-labelledby="stage-title">
+      <div class="section-heading">
+        <div>
+          <span>02 · BUSINESS FLOW</span>
+          <h2 id="stage-title">六阶段执行链</h2>
+        </div>
+        <p>选择阶段查看真实输入、输出、注册工具与代码硬约束。</p>
+      </div>
+
+      <div class="stage-flow" role="tablist" aria-label="寻找 Agent 执行阶段">
+        <template v-for="(stage, index) in stages" :key="stage.id">
+          <button
+            type="button"
+            role="tab"
+            :aria-selected="activeStageId === stage.id"
+            :class="['stage-node', stage.tone, { active: activeStageId === stage.id }]"
+            @click="activeStageId = stage.id"
+          >
+            <span class="stage-index">{{ stage.index }}</span>
+            <small>{{ stage.subtitle }}</small>
+            <strong>{{ stage.title }}</strong>
+            <i>{{ stage.id === 'context' ? '调度入口' : `${stage.tools.length} 工具` }}</i>
+          </button>
+          <span v-if="index < stages.length - 1" class="stage-arrow" aria-hidden="true"><i />→</span>
+        </template>
+      </div>
+
+      <article class="stage-detail" :class="activeStage.tone">
+        <div class="detail-lead">
+          <span>{{ activeStage.index }} / {{ activeStage.subtitle }}</span>
+          <h3>{{ activeStage.title }}</h3>
+          <p>{{ activeStage.summary }}</p>
+        </div>
+
+        <dl class="io-grid">
+          <div>
+            <dt><span>IN</span> 阶段输入</dt>
+            <dd>{{ activeStage.input }}</dd>
+          </div>
+          <div>
+            <dt><span>OUT</span> 阶段输出</dt>
+            <dd>{{ activeStage.output }}</dd>
+          </div>
+        </dl>
+
+        <div class="detail-columns">
+          <section>
+            <span class="detail-label">CALLABLE TOOLS</span>
+            <div class="tool-list">
+              <code v-for="tool in activeStage.tools" :key="tool">{{ tool }}</code>
+            </div>
+          </section>
+          <section>
+            <span class="detail-label">HARD CONSTRAINTS</span>
+            <ul>
+              <li v-for="constraint in activeStage.constraints" :key="constraint">{{ constraint }}</li>
+            </ul>
+          </section>
+        </div>
+      </article>
+    </section>
+
+    <section class="data-plane" aria-labelledby="data-title">
+      <div class="section-heading compact">
+        <div>
+          <span>03 · DATA PLANE</span>
+          <h2 id="data-title">证据与状态底座</h2>
+        </div>
+        <p>Agent Tools 不直接写 SQL,统一经过 Service 与 Repository 操作持久化实体。</p>
+      </div>
+
+      <div class="data-pipeline">
+        <div class="service-gateway">
+          <span>SERVICE GATEWAY</span>
+          <strong>VideoDiscoveryService</strong>
+          <small>事务 · 序列化 · 规则快照</small>
+        </div>
+        <span class="data-arrow">↓</span>
+        <div class="store-grid">
+          <article v-for="store in dataStores" :key="store.name">
+            <span>{{ store.name }}</span>
+            <strong>{{ store.table }}</strong>
+            <small>{{ store.detail }}</small>
+          </article>
+        </div>
+      </div>
+    </section>
+
+    <footer class="page-footnote">
+      <span><i class="legend-dot model" />模型决策</span>
+      <span><i class="legend-dot tool" />工具执行</span>
+      <span><i class="legend-dot guard" />代码门禁</span>
+      <p>当前实现 · 2026-08-04</p>
+    </footer>
+  </div>
+</template>
+
+<style scoped>
+.framework-page {
+  --ink: #182033;
+  --muted: #6d7586;
+  --line: #e3e6ed;
+  display: flex;
+  max-width: 1480px;
+  margin: 0 auto;
+  padding: 0 4px 38px;
+  flex-direction: column;
+  gap: 22px;
+  color: var(--ink);
+}
+
+.page-hero,
+.architecture-map,
+.stage-section,
+.data-plane {
+  border: 1px solid var(--line);
+  border-radius: 20px;
+  background: rgba(255, 255, 255, .94);
+  box-shadow: 0 14px 42px rgba(35, 43, 64, .055);
+}
+
+.page-hero {
+  position: relative;
+  display: grid;
+  min-height: 260px;
+  padding: 38px 42px;
+  overflow: hidden;
+  grid-template-columns: minmax(0, 1.15fr) minmax(420px, .85fr);
+  align-items: center;
+  gap: 38px;
+  background:
+    radial-gradient(circle at 88% 12%, rgba(103, 98, 207, .14), transparent 31%),
+    linear-gradient(135deg, #fff 0 57%, #f8f8fc 57%);
+}
+
+.page-hero::after {
+  position: absolute;
+  inset: 0;
+  background-image:
+    linear-gradient(rgba(89, 98, 127, .045) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(89, 98, 127, .045) 1px, transparent 1px);
+  background-size: 28px 28px;
+  mask-image: linear-gradient(90deg, transparent 46%, #000);
+  content: '';
+  pointer-events: none;
+}
+
+.hero-copy,
+.runtime-boundary { position: relative; z-index: 1; }
+.eyebrow,
+.section-heading div > span,
+.boundary-label,
+.detail-label {
+  color: #6c6bd0;
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: .12em;
+}
+
+.hero-copy h1 {
+  margin: 8px 0 10px;
+  font-size: clamp(30px, 3vw, 45px);
+  line-height: 1.1;
+  letter-spacing: -.035em;
+}
+
+.hero-copy > p {
+  max-width: 650px;
+  margin: 0;
+  color: var(--muted);
+  font-size: 15px;
+  line-height: 1.8;
+}
+
+.hero-actions { display: flex; margin-top: 22px; align-items: center; gap: 12px; }
+.hero-actions a {
+  display: inline-flex;
+  height: 38px;
+  padding: 0 15px;
+  align-items: center;
+  gap: 14px;
+  border-radius: 9px;
+  font-size: 13px;
+  font-weight: 750;
+  text-decoration: none;
+}
+.primary-link { background: #5e5dc8; color: #fff; }
+.primary-link:hover { background: #5352bb; }
+.secondary-link { border: 1px solid #dfe1e8; background: #fff; color: #697184; }
+.secondary-link:hover { border-color: #c9c9eb; color: #5555b7; }
+
+.runtime-boundary {
+  padding: 20px;
+  border: 1px solid rgba(98, 96, 194, .16);
+  border-radius: 16px;
+  background: rgba(255, 255, 255, .78);
+  backdrop-filter: blur(9px);
+}
+.boundary-grid { display: grid; margin-top: 12px; grid-template-columns: 1fr 1fr; gap: 8px; }
+.boundary-grid div { padding: 12px; border-radius: 10px; background: rgba(244, 244, 250, .88); }
+.boundary-grid small,
+.boundary-grid strong { display: block; }
+.boundary-grid small { color: #9a9fac; font-size: 10px; font-weight: 800; letter-spacing: .08em; }
+.boundary-grid strong { margin-top: 4px; font-size: 14px; }
+
+.architecture-map,
+.stage-section,
+.data-plane { padding: 28px 30px; }
+.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
+.section-heading h2 { margin: 3px 0 0; font-size: 22px; letter-spacing: -.02em; }
+.section-heading > p { max-width: 610px; margin: 0; color: var(--muted); font-size: 13px; text-align: right; }
+.section-heading.compact { align-items: center; }
+
+.kernel-flow {
+  display: grid;
+  margin-top: 28px;
+  grid-template-columns: minmax(190px, .8fr) 58px minmax(430px, 1.55fr) 58px minmax(200px, .8fr);
+  align-items: center;
+}
+
+.boundary-node {
+  display: flex;
+  min-height: 126px;
+  padding: 18px;
+  align-items: center;
+  gap: 13px;
+  border: 1px solid #e4e6ed;
+  border-radius: 14px;
+  background: #fafbfc;
+}
+.boundary-node .node-icon {
+  display: grid;
+  width: 38px;
+  height: 38px;
+  flex: 0 0 auto;
+  place-items: center;
+  border-radius: 10px;
+  background: #ececfa;
+  color: #6261c5;
+  font-size: 10px;
+  font-weight: 900;
+}
+.boundary-node span,
+.boundary-node strong,
+.boundary-node small { display: block; }
+.boundary-node span { color: #8e95a3; font-size: 11px; font-weight: 800; }
+.boundary-node strong { margin-top: 5px; font-size: 14px; line-height: 1.45; }
+.boundary-node small { margin-top: 6px; color: #969dab; font-size: 11px; }
+
+.flow-connector { display: flex; align-items: center; color: #8d91b7; }
+.flow-connector i { height: 1px; flex: 1; background: #d9dbe6; }
+.flow-connector b { font-size: 18px; font-weight: 500; }
+
+.agent-kernel {
+  overflow: hidden;
+  border: 1px solid #cbcaee;
+  border-radius: 16px;
+  background: linear-gradient(145deg, #fbfbff, #f3f3fb);
+  box-shadow: 0 10px 30px rgba(78, 76, 165, .08);
+}
+.agent-kernel > header { display: flex; padding: 15px 18px; align-items: center; justify-content: space-between; border-bottom: 1px solid #ddddef; }
+.agent-kernel header span,
+.agent-kernel header strong { display: block; }
+.agent-kernel header span { color: #7170cd; font-size: 10px; font-weight: 850; letter-spacing: .1em; }
+.agent-kernel header strong { margin-top: 3px; font-size: 16px; }
+.agent-kernel header em { padding: 4px 8px; border-radius: 999px; background: #e8e8f8; color: #6665bd; font-size: 10px; font-style: normal; font-weight: 750; }
+.react-loop { display: grid; padding: 18px; grid-template-columns: 1fr 24px 1fr 24px 1fr 28px; align-items: center; }
+.react-loop > div { display: flex; min-height: 70px; padding: 10px 8px; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #dfdfed; border-radius: 11px; background: #fff; }
+.react-loop > div b { color: #8f8ed5; font-size: 10px; }
+.react-loop > div span { margin-top: 2px; font-size: 13px; font-weight: 800; }
+.react-loop > div small { color: #969cab; font-size: 10px; }
+.react-loop > i { color: #9494bc; font-size: 17px; font-style: normal; text-align: center; }
+.agent-kernel > footer { display: flex; padding: 10px 18px; flex-wrap: wrap; gap: 15px; border-top: 1px solid #ddddef; color: #747b8a; font-size: 10px; }
+.agent-kernel footer span { display: inline-flex; align-items: center; gap: 5px; }
+.dot,
+.legend-dot { width: 7px; height: 7px; border-radius: 50%; }
+.model { background: #6665ce; }
+.tool { background: #2795be; }
+.guard { background: #df9c28; }
+
+.guard-rail { display: grid; margin-top: 14px; padding: 11px 15px; grid-template-columns: auto minmax(30px, 1fr) auto; align-items: center; gap: 12px; border-radius: 10px; background: #fff8ea; color: #8c651d; font-size: 12px; }
+.guard-rail > span { font-weight: 850; }
+.guard-rail > i { height: 1px; background: #ead7ad; }
+.guard-rail p { margin: 0; }
+
+.stage-flow { display: grid; margin-top: 28px; grid-template-columns: 1fr 22px 1fr 22px 1fr 22px 1fr 22px 1fr 22px 1fr; align-items: center; }
+.stage-node {
+  position: relative;
+  display: flex;
+  min-width: 0;
+  min-height: 116px;
+  padding: 15px 13px;
+  flex-direction: column;
+  align-items: flex-start;
+  border: 1px solid #e2e4ea;
+  border-radius: 13px;
+  background: #fafbfc;
+  color: var(--ink);
+  text-align: left;
+  cursor: pointer;
+  transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
+}
+.stage-node:hover { transform: translateY(-2px); border-color: #cfd2de; box-shadow: 0 8px 22px rgba(40, 48, 69, .06); }
+.stage-node.active { border-color: var(--tone); background: var(--tone-soft); box-shadow: 0 8px 24px var(--tone-shadow); }
+.stage-node.indigo { --tone: #6665ca; --tone-soft: #f1f1ff; --tone-shadow: rgba(89, 88, 192, .12); }
+.stage-node.blue { --tone: #497dcc; --tone-soft: #eff6ff; --tone-shadow: rgba(67, 117, 193, .12); }
+.stage-node.cyan { --tone: #2997ae; --tone-soft: #effafb; --tone-shadow: rgba(40, 143, 164, .12); }
+.stage-node.amber { --tone: #cf8b25; --tone-soft: #fff8ea; --tone-shadow: rgba(198, 132, 32, .12); }
+.stage-node.green { --tone: #369873; --tone-soft: #effaf6; --tone-shadow: rgba(49, 145, 108, .12); }
+.stage-node.violet { --tone: #845dbd; --tone-soft: #f7f1fd; --tone-shadow: rgba(121, 80, 174, .12); }
+.stage-index { display: grid; width: 25px; height: 21px; place-items: center; border-radius: 6px; background: #e9eaf0; color: #737b8a; font-size: 10px; font-weight: 900; }
+.stage-node.active .stage-index { background: var(--tone); color: #fff; }
+.stage-node small { margin-top: 12px; overflow: hidden; color: #9a9fac; font-size: 9px; font-weight: 800; letter-spacing: .08em; text-overflow: ellipsis; white-space: nowrap; }
+.stage-node strong { margin-top: 2px; font-size: 14px; }
+.stage-node > i { margin-top: auto; color: #9ca2ae; font-size: 10px; font-style: normal; }
+.stage-arrow { display: flex; align-items: center; color: #aaaeb9; font-size: 12px; }
+.stage-arrow i { height: 1px; flex: 1; background: #dadde4; }
+
+.stage-detail {
+  --tone: #497dcc;
+  --tone-soft: #eff6ff;
+  display: grid;
+  margin-top: 18px;
+  padding: 22px;
+  grid-template-columns: minmax(190px, .8fr) minmax(260px, 1.2fr) minmax(0, 1.35fr);
+  gap: 22px;
+  border: 1px solid color-mix(in srgb, var(--tone) 28%, #e2e4ea);
+  border-radius: 15px;
+  background: linear-gradient(135deg, var(--tone-soft), #fff 55%);
+}
+.stage-detail.indigo { --tone: #6665ca; --tone-soft: #f1f1ff; }
+.stage-detail.blue { --tone: #497dcc; --tone-soft: #eff6ff; }
+.stage-detail.cyan { --tone: #2997ae; --tone-soft: #effafb; }
+.stage-detail.amber { --tone: #cf8b25; --tone-soft: #fff8ea; }
+.stage-detail.green { --tone: #369873; --tone-soft: #effaf6; }
+.stage-detail.violet { --tone: #845dbd; --tone-soft: #f7f1fd; }
+.detail-lead > span { color: var(--tone); font-size: 10px; font-weight: 850; letter-spacing: .1em; }
+.detail-lead h3 { margin: 5px 0 6px; font-size: 20px; }
+.detail-lead p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
+.io-grid { display: grid; margin: 0; gap: 9px; }
+.io-grid div { padding: 11px 12px; border-left: 2px solid var(--tone); background: rgba(255, 255, 255, .72); }
+.io-grid dt { color: #7f8796; font-size: 10px; font-weight: 800; }
+.io-grid dt span { margin-right: 5px; color: var(--tone); }
+.io-grid dd { margin: 4px 0 0; font-size: 12px; line-height: 1.55; }
+.detail-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
+.detail-columns section { min-width: 0; }
+.tool-list { display: flex; margin-top: 9px; flex-wrap: wrap; gap: 6px; }
+.tool-list code { padding: 5px 7px; border: 1px solid #e0e2e9; border-radius: 6px; background: #fff; color: #4d5566; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; overflow-wrap: anywhere; }
+.detail-columns ul { display: grid; margin: 9px 0 0; padding: 0; gap: 7px; list-style: none; }
+.detail-columns li { position: relative; padding-left: 13px; color: #626b7b; font-size: 11px; line-height: 1.45; }
+.detail-columns li::before { position: absolute; top: 6px; left: 0; width: 5px; height: 5px; border-radius: 50%; background: var(--tone); content: ''; }
+
+.data-pipeline { display: flex; margin-top: 24px; flex-direction: column; align-items: center; }
+.service-gateway { width: min(420px, 100%); padding: 13px 18px; border: 1px solid #d9dbea; border-radius: 12px; background: #f7f7fc; text-align: center; }
+.service-gateway span,
+.service-gateway strong,
+.service-gateway small { display: block; }
+.service-gateway span { color: #7675cc; font-size: 9px; font-weight: 850; letter-spacing: .12em; }
+.service-gateway strong { margin-top: 3px; font-size: 14px; }
+.service-gateway small { margin-top: 2px; color: #9298a5; font-size: 10px; }
+.data-arrow { color: #a4a8b4; line-height: 28px; }
+.store-grid { display: grid; width: 100%; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
+.store-grid article { min-width: 0; padding: 13px; border-top: 2px solid #6d6ccb; background: #f8f9fb; }
+.store-grid span,
+.store-grid strong,
+.store-grid small { display: block; }
+.store-grid span { color: #7372ca; font-size: 9px; font-weight: 900; letter-spacing: .09em; }
+.store-grid strong { margin-top: 5px; overflow: hidden; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.store-grid small { margin-top: 5px; color: #8b92a0; font-size: 10px; line-height: 1.4; }
+
+.page-footnote { display: flex; padding: 0 5px; align-items: center; gap: 17px; color: #8a91a0; font-size: 10px; }
+.page-footnote > span { display: inline-flex; align-items: center; gap: 6px; }
+.page-footnote p { margin: 0 0 0 auto; }
+
+@media (max-width: 1180px) {
+  .kernel-flow { grid-template-columns: 1fr 35px minmax(390px, 1.4fr) 35px 1fr; }
+  .stage-flow { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
+  .stage-arrow { display: none; }
+  .stage-detail { grid-template-columns: 1fr 1.4fr; }
+  .detail-columns { grid-column: 1 / -1; }
+  .store-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+
+@media (max-width: 900px) {
+  .page-hero { padding: 30px; grid-template-columns: 1fr; }
+  .runtime-boundary { max-width: 620px; }
+  .section-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
+  .section-heading > p { max-width: none; text-align: left; }
+  .kernel-flow { grid-template-columns: 1fr; gap: 10px; }
+  .flow-connector { width: 1px; height: 24px; margin: 0 auto; flex-direction: column; }
+  .flow-connector i { width: 1px; }
+  .flow-connector b { transform: rotate(90deg); }
+  .guard-rail { grid-template-columns: auto 1fr; }
+  .guard-rail p { grid-column: 1 / -1; }
+}
+
+@media (max-width: 700px) {
+  .framework-page { gap: 14px; }
+  .page-hero,
+  .architecture-map,
+  .stage-section,
+  .data-plane { border-radius: 15px; }
+  .page-hero,
+  .architecture-map,
+  .stage-section,
+  .data-plane { padding: 22px 18px; }
+  .hero-actions { align-items: stretch; flex-direction: column; }
+  .hero-actions a { justify-content: center; }
+  .boundary-grid { grid-template-columns: 1fr; }
+  .react-loop { grid-template-columns: 1fr; gap: 6px; }
+  .react-loop > i { transform: rotate(90deg); }
+  .stage-flow { grid-template-columns: 1fr 1fr; }
+  .stage-detail { padding: 17px; grid-template-columns: 1fr; }
+  .detail-columns { grid-column: auto; grid-template-columns: 1fr; }
+  .store-grid { grid-template-columns: 1fr 1fr; }
+  .page-footnote { flex-wrap: wrap; }
+  .page-footnote p { width: 100%; margin-left: 0; }
+}
+
+@media (max-width: 430px) {
+  .stage-flow,
+  .store-grid { grid-template-columns: 1fr; }
+}
+</style>