소스 검색

增加寻找视频可视化

xueyiming 20 시간 전
부모
커밋
ab6c43d20a

+ 1 - 0
.gitignore

@@ -23,6 +23,7 @@ tests/*
 tests/api/*
 !tests/api/__init__.py
 !tests/api/test_demand_feedback.py
+!tests/api/test_video_discovery_records.py
 !tests/supply_agent/
 !tests/supply_infra/
 tests/supply_infra/*

+ 32 - 32
PRD.md

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

+ 1 - 1
agents/find_agent/demand_run.py

@@ -64,7 +64,7 @@ class FindDemandContext:
 
 @dataclass
 class FindDemandExecutionResult:
-    """单条需求找执行结果。"""
+    """单条需求找视频执行结果。"""
 
     run_id: str | None = None
     skipped: bool = False

+ 74 - 1
api/app.py

@@ -46,6 +46,12 @@ from api.services.video_discovery import (
     get_video_discovery_demand,
     list_video_discovery_demands,
 )
+from api.services.video_discovery_records import (
+    get_video_discovery_run,
+    list_video_discovery_candidates,
+    list_video_discovery_runs,
+    list_video_discovery_searches,
+)
 from supply_infra.config import get_infra_settings
 from supply_infra.db import dispose_engine, get_session, init_db
 
@@ -162,7 +168,7 @@ def run_pipeline(
     """
     异步一键执行供给数据全流程,立即返回 run_id,后台串行执行:
 
-    全局树同步 → 需求池同步 → 需求分级 → 视频点位拓展 → find_agent 找 → AIGC 发布。
+    全局树同步 → 需求池同步 → 需求分级 → 视频点位拓展 → find_agent 找视频 → AIGC 发布。
     """
     resolved_biz_dt = body.biz_dt if body and body.biz_dt is not None else biz_dt
     return run_supply_pipeline(biz_dt=resolved_biz_dt)
@@ -261,6 +267,73 @@ def video_discovery_demands(
     return list_video_discovery_demands(biz_dt=biz_dt)
 
 
+@app.get("/api/video-discovery/runs")
+def video_discovery_runs(
+    biz_dt: str | None = Query(default=None, pattern=r"^\d{8}$"),
+    status: Literal["running", "finished", "failed"] | None = None,
+    keyword: str | None = Query(default=None, max_length=256),
+    limit: int = Query(default=20, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return paged find-agent runs for the records workspace."""
+    return list_video_discovery_runs(
+        biz_dt=biz_dt,
+        status=status,
+        keyword=keyword,
+        limit=limit,
+        offset=offset,
+    )
+
+
+@app.get("/api/video-discovery/runs/{run_id}")
+def video_discovery_run(run_id: str) -> dict:
+    """Return one find-agent run and its aggregate counts."""
+    result = get_video_discovery_run(run_id)
+    if result is None:
+        raise HTTPException(status_code=404, detail="video discovery run not found")
+    return result
+
+
+@app.get("/api/video-discovery/runs/{run_id}/searches")
+def video_discovery_searches(
+    run_id: str,
+    keyword: str | None = Query(default=None, max_length=256),
+    limit: int = Query(default=20, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return paged search-page records for one find-agent run."""
+    result = list_video_discovery_searches(
+        run_id,
+        keyword=keyword,
+        limit=limit,
+        offset=offset,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail="video discovery run not found")
+    return result
+
+
+@app.get("/api/video-discovery/runs/{run_id}/candidates")
+def video_discovery_candidates(
+    run_id: str,
+    bucket: str | None = Query(default=None, max_length=24),
+    keyword: str | None = Query(default=None, max_length=256),
+    limit: int = Query(default=20, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return paged candidate records for one find-agent run."""
+    result = list_video_discovery_candidates(
+        run_id,
+        bucket=bucket,
+        keyword=keyword,
+        limit=limit,
+        offset=offset,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail="video discovery run not found")
+    return result
+
+
 @app.get("/api/video-discovery/demands/{demand_grade_id}")
 def video_discovery_demand(demand_grade_id: int) -> dict:
     """Return one demand and its videos/points resolved from the source tables."""

+ 4 - 0
api/auth_middleware.py

@@ -21,11 +21,15 @@ _NORMAL_USER_PATHS = {
     ("GET", "/api/category-tree"),
     ("GET", "/api/demand-grade"),
     ("GET", "/api/video-discovery/demands"),
+    ("GET", "/api/video-discovery/runs"),
     ("GET", "/api/video-discovery/feedback"),
     ("POST", "/api/video-discovery/feedback"),
 }
 _NORMAL_USER_PATTERNS = (
     re.compile(r"^/api/video-discovery/demands/\d+$"),
+    re.compile(r"^/api/video-discovery/runs/[^/]+$"),
+    re.compile(r"^/api/video-discovery/runs/[^/]+/searches$"),
+    re.compile(r"^/api/video-discovery/runs/[^/]+/candidates$"),
     re.compile(r"^/api/demand-grade/\d+/videos$"),
 )
 

+ 336 - 0
api/services/video_discovery_records.py

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

+ 1 - 1
deploy/README.md

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

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

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

+ 2 - 0
prd/README.md

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

+ 1 - 1
sql/video_discovery_tables.sql

@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS `video_discovery_run` (
   KEY `idx_video_discovery_run_biz_dt` (`biz_dt`),
   KEY `idx_video_discovery_run_status` (`status`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
-  COMMENT='一次需求找运行';
+  COMMENT='一次需求找视频运行';
 
 CREATE TABLE IF NOT EXISTS `video_discovery_search` (
   `id` BIGINT NOT NULL AUTO_INCREMENT,

+ 1 - 1
supply_infra/db/models/video_discovery.py

@@ -20,7 +20,7 @@ from supply_infra.db.base import Base
 
 
 class VideoDiscoveryRun(Base):
-    """一次需求找运行,保存输入、意图解释和完成状态。"""
+    """一次需求找视频运行,保存输入、意图解释和完成状态。"""
 
     __tablename__ = "video_discovery_run"
     __table_args__ = (

+ 1 - 1
supply_infra/db/repositories/video_discovery_repo.py

@@ -41,7 +41,7 @@ _PUBLISHABLE_BUCKETS = ("primary",)
 
 
 class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
-    """需求找运行、搜索轨迹和候选快照的统一 Repository。"""
+    """需求找视频运行、搜索轨迹和候选快照的统一 Repository。"""
 
     model = VideoDiscoveryRun
 

+ 1 - 1
supply_infra/scheduler/constants.py

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

+ 204 - 0
tests/api/test_video_discovery_records.py

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

+ 1 - 0
web/src/App.vue

@@ -16,6 +16,7 @@ const adminNavItems = [
   { to: '/video-discovery', label: '需求汇总', icon: '▷', admin: false },
   { to: '/pipeline-runs', label: '定时任务', icon: '◷', admin: true },
   { to: '/demand-process', label: 'Agent 审计', icon: '◎', admin: true },
+  { to: '/find-agent-records', label: '找视频记录', icon: '⌕', admin: false },
   { to: '/admin/users', label: '用户管理', icon: '◇', admin: true },
 ]
 const navItems = computed(() =>

+ 1 - 1
web/src/api/videoDiscovery.ts

@@ -18,7 +18,7 @@ export async function fetchVideoDiscoveryDemands(
   const query = bizDt ? `?biz_dt=${encodeURIComponent(bizDt)}` : ''
   const res = await fetch(`/api/video-discovery/demands${query}`)
   if (!res.ok) {
-    throw new Error(`加载需求找数据失败: ${res.status} ${res.statusText}`)
+    throw new Error(`加载需求找视频数据失败: ${res.status} ${res.statusText}`)
   }
   return res.json()
 }

+ 62 - 0
web/src/api/videoDiscoveryRecords.ts

@@ -0,0 +1,62 @@
+import type {
+  PagedResponse,
+  VideoDiscoveryCandidateRecord,
+  VideoDiscoveryRunRecord,
+  VideoDiscoverySearchRecord,
+} from '../types/videoDiscoveryRecords'
+
+async function readJson<T>(response: Response, message: string): Promise<T> {
+  if (!response.ok) {
+    const body = await response.json().catch(() => null) as { detail?: string } | null
+    throw new Error(body?.detail || `${message}(${response.status})`)
+  }
+  return response.json() as Promise<T>
+}
+
+function queryString(values: Record<string, string | number | null | undefined>): string {
+  const query = new URLSearchParams()
+  Object.entries(values).forEach(([key, value]) => {
+    if (value !== null && value !== undefined && value !== '') query.set(key, String(value))
+  })
+  const serialized = query.toString()
+  return serialized ? `?${serialized}` : ''
+}
+
+export function fetchVideoDiscoveryRuns(params: {
+  bizDt?: string
+  status?: string
+  keyword?: string
+  limit: number
+  offset: number
+}): Promise<PagedResponse<VideoDiscoveryRunRecord>> {
+  return fetch(`/api/video-discovery/runs${queryString({
+    biz_dt: params.bizDt,
+    status: params.status,
+    keyword: params.keyword,
+    limit: params.limit,
+    offset: params.offset,
+  })}`).then((response) => readJson(response, '加载找视频运行失败'))
+}
+
+export function fetchVideoDiscoveryRun(runId: string): Promise<VideoDiscoveryRunRecord> {
+  return fetch(`/api/video-discovery/runs/${encodeURIComponent(runId)}`)
+    .then((response) => readJson(response, '加载运行详情失败'))
+}
+
+export function fetchVideoDiscoverySearches(
+  runId: string,
+  params: { keyword?: string; limit: number; offset: number },
+): Promise<PagedResponse<VideoDiscoverySearchRecord>> {
+  return fetch(
+    `/api/video-discovery/runs/${encodeURIComponent(runId)}/searches${queryString(params)}`,
+  ).then((response) => readJson(response, '加载搜索轨迹失败'))
+}
+
+export function fetchVideoDiscoveryCandidates(
+  runId: string,
+  params: { bucket?: string; keyword?: string; limit: number; offset: number },
+): Promise<PagedResponse<VideoDiscoveryCandidateRecord>> {
+  return fetch(
+    `/api/video-discovery/runs/${encodeURIComponent(runId)}/candidates${queryString(params)}`,
+  ).then((response) => readJson(response, '加载候选视频失败'))
+}

+ 7 - 0
web/src/router.ts

@@ -3,6 +3,7 @@ import CategoryTreeView from './views/CategoryTreeView.vue'
 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 LoginView from './views/LoginView.vue'
 import OverviewView from './views/OverviewView.vue'
 import PipelineRunsView from './views/PipelineRunsView.vue'
@@ -43,6 +44,12 @@ export const router = createRouter({
       component: DemandProcessView,
       meta: { title: '需求归类过程', admin: true },
     },
+    {
+      path: '/find-agent-records',
+      name: 'find-agent-records',
+      component: FindAgentRecordsView,
+      meta: { title: '找视频记录', userAllowed: true },
+    },
     {
       path: '/video-discovery',
       name: 'video-discovery',

+ 90 - 0
web/src/types/videoDiscoveryRecords.ts

@@ -0,0 +1,90 @@
+export type JsonValue = unknown
+
+export interface VideoDiscoveryRunRecord {
+  id: number
+  run_id: string
+  biz_dt: string | null
+  demand_grade_id: number | null
+  demand_word: string
+  seed_video_id: string | null
+  seed_video_title: string | null
+  relevant_points: JsonValue
+  intent_summary: string | null
+  status: string
+  search_count: number
+  candidate_count: number
+  primary_count: number
+  rejected_count: number
+  pending_count: number
+  stop_reason: string | null
+  create_time: string | null
+  update_time: string | null
+}
+
+export interface VideoDiscoverySearchRecord {
+  id: number
+  run_id: string
+  search_key: string
+  keyword: string
+  query_reason: string
+  source_type: string
+  source_value: string | null
+  parent_search_id: number | null
+  provider: string
+  provider_state: JsonValue
+  content_type: string
+  sort_type: string
+  publish_time: string
+  cursor: string
+  page_no: number
+  results_count: number
+  new_candidate_count: number
+  has_more: boolean
+  next_cursor: string | null
+  result_ids: JsonValue
+  status: string
+  error_message: string | null
+  create_time: string | null
+  update_time: string | null
+}
+
+export interface VideoDiscoveryCandidateRecord {
+  id: number
+  run_id: string
+  search_id: number | null
+  aweme_id: string
+  title: string | null
+  content_link: string | null
+  author_name: string | null
+  author_sec_uid: string | null
+  source_keywords: JsonValue
+  source_search_ids: JsonValue
+  tags: JsonValue
+  play_count: number | null
+  like_count: number | null
+  comment_count: number | null
+  collect_count: number | null
+  share_count: number | null
+  content_age_evidence: JsonValue
+  account_age_evidence: JsonValue
+  age_normalization: JsonValue
+  relevance_score: number | null
+  elder_score: number | null
+  share_score: number | null
+  value_score: number | null
+  decision_reason: string | null
+  decision_bucket: string
+  aigc_crawler_plan_id: string | null
+  aigc_produce_plan_id: string | null
+  aigc_publish_plan_id: string | null
+  aigc_plan_label: string | null
+  create_time: string | null
+  update_time: string | null
+}
+
+export interface PagedResponse<T> {
+  items: T[]
+  total: number
+  limit: number
+  offset: number
+}

+ 1249 - 0
web/src/views/FindAgentRecordsView.vue

@@ -0,0 +1,1249 @@
+<script setup lang="ts">
+import { computed, onMounted, ref, watch } from 'vue'
+import {
+  fetchVideoDiscoveryCandidates,
+  fetchVideoDiscoveryRun,
+  fetchVideoDiscoveryRuns,
+  fetchVideoDiscoverySearches,
+} from '../api/videoDiscoveryRecords'
+import type {
+  JsonValue,
+  VideoDiscoveryCandidateRecord,
+  VideoDiscoveryRunRecord,
+  VideoDiscoverySearchRecord,
+} from '../types/videoDiscoveryRecords'
+
+type DetailTab = 'searches' | 'candidates'
+
+const PAGE_SIZE = 20
+const runs = ref<VideoDiscoveryRunRecord[]>([])
+const selected = ref<VideoDiscoveryRunRecord | null>(null)
+const searches = ref<VideoDiscoverySearchRecord[]>([])
+const candidates = ref<VideoDiscoveryCandidateRecord[]>([])
+const runTotal = ref(0)
+const searchTotal = ref(0)
+const candidateTotal = ref(0)
+const runPage = ref(1)
+const searchPage = ref(1)
+const candidatePage = ref(1)
+const tab = ref<DetailTab>('searches')
+const bizDt = ref('')
+const status = ref('')
+const runKeyword = ref('')
+const detailKeyword = ref('')
+const candidateBucket = ref('')
+const loadingRuns = ref(true)
+const loadingDetail = ref(false)
+const error = ref('')
+const expandedSearchId = ref<number | null>(null)
+const expandedCandidateId = ref<number | null>(null)
+let runRequestId = 0
+let detailRequestId = 0
+
+const runPages = computed(() => Math.max(1, Math.ceil(runTotal.value / PAGE_SIZE)))
+const detailTotal = computed(() => tab.value === 'searches' ? searchTotal.value : candidateTotal.value)
+const detailPage = computed(() => tab.value === 'searches' ? searchPage.value : candidatePage.value)
+const detailPages = computed(() => Math.max(1, Math.ceil(detailTotal.value / PAGE_SIZE)))
+
+onMounted(() => loadRuns())
+
+watch(tab, () => {
+  detailKeyword.value = ''
+  expandedSearchId.value = null
+  expandedCandidateId.value = null
+  if (!selected.value) return
+  if (tab.value === 'searches' && !searches.value.length) void loadSearches()
+  if (tab.value === 'candidates' && !candidates.value.length) void loadCandidates()
+})
+
+watch(candidateBucket, () => {
+  candidatePage.value = 1
+  if (selected.value && tab.value === 'candidates') void loadCandidates()
+})
+
+async function loadRuns(preferredRunId?: string) {
+  const requestId = ++runRequestId
+  loadingRuns.value = true
+  error.value = ''
+  try {
+    const response = await fetchVideoDiscoveryRuns({
+      bizDt: bizDt.value.trim(),
+      status: status.value,
+      keyword: runKeyword.value.trim(),
+      limit: PAGE_SIZE,
+      offset: (runPage.value - 1) * PAGE_SIZE,
+    })
+    if (requestId !== runRequestId) return
+    runs.value = response.items
+    runTotal.value = response.total
+    const targetId = preferredRunId || selected.value?.run_id
+    const target = response.items.find((item) => item.run_id === targetId) || response.items[0]
+    if (target) await selectRun(target)
+    else clearSelection()
+  } catch (cause) {
+    if (requestId === runRequestId) {
+      error.value = cause instanceof Error ? cause.message : String(cause)
+    }
+  } finally {
+    if (requestId === runRequestId) loadingRuns.value = false
+  }
+}
+
+async function selectRun(run: VideoDiscoveryRunRecord) {
+  selected.value = run
+  searchPage.value = 1
+  candidatePage.value = 1
+  detailKeyword.value = ''
+  candidateBucket.value = ''
+  searches.value = []
+  candidates.value = []
+  expandedSearchId.value = null
+  expandedCandidateId.value = null
+  await loadActiveDetail()
+}
+
+function clearSelection() {
+  selected.value = null
+  searches.value = []
+  candidates.value = []
+  searchTotal.value = 0
+  candidateTotal.value = 0
+}
+
+async function loadActiveDetail() {
+  if (tab.value === 'searches') await loadSearches()
+  else await loadCandidates()
+}
+
+async function loadSearches() {
+  if (!selected.value) return
+  const requestId = ++detailRequestId
+  loadingDetail.value = true
+  error.value = ''
+  try {
+    const response = await fetchVideoDiscoverySearches(selected.value.run_id, {
+      keyword: detailKeyword.value.trim(),
+      limit: PAGE_SIZE,
+      offset: (searchPage.value - 1) * PAGE_SIZE,
+    })
+    if (requestId !== detailRequestId) return
+    searches.value = response.items
+    searchTotal.value = response.total
+  } catch (cause) {
+    if (requestId === detailRequestId) {
+      error.value = cause instanceof Error ? cause.message : String(cause)
+    }
+  } finally {
+    if (requestId === detailRequestId) loadingDetail.value = false
+  }
+}
+
+async function loadCandidates() {
+  if (!selected.value) return
+  const requestId = ++detailRequestId
+  loadingDetail.value = true
+  error.value = ''
+  try {
+    const response = await fetchVideoDiscoveryCandidates(selected.value.run_id, {
+      bucket: candidateBucket.value,
+      keyword: detailKeyword.value.trim(),
+      limit: PAGE_SIZE,
+      offset: (candidatePage.value - 1) * PAGE_SIZE,
+    })
+    if (requestId !== detailRequestId) return
+    candidates.value = response.items
+    candidateTotal.value = response.total
+  } catch (cause) {
+    if (requestId === detailRequestId) {
+      error.value = cause instanceof Error ? cause.message : String(cause)
+    }
+  } finally {
+    if (requestId === detailRequestId) loadingDetail.value = false
+  }
+}
+
+function applyRunFilters() {
+  runPage.value = 1
+  void loadRuns()
+}
+
+function resetRunFilters() {
+  bizDt.value = ''
+  status.value = ''
+  runKeyword.value = ''
+  runPage.value = 1
+  void loadRuns()
+}
+
+function applyDetailFilter() {
+  if (tab.value === 'searches') searchPage.value = 1
+  else candidatePage.value = 1
+  void loadActiveDetail()
+}
+
+function changeRunPage(delta: number) {
+  const next = runPage.value + delta
+  if (next < 1 || next > runPages.value) return
+  runPage.value = next
+  void loadRuns()
+}
+
+function changeDetailPage(delta: number) {
+  if (tab.value === 'searches') searchPage.value += delta
+  else candidatePage.value += delta
+  void loadActiveDetail()
+}
+
+async function refresh() {
+  const runId = selected.value?.run_id
+  if (runId) {
+    try {
+      selected.value = await fetchVideoDiscoveryRun(runId)
+    } catch {
+      // The list refresh below owns the visible error state.
+    }
+  }
+  await loadRuns(runId)
+}
+
+function statusLabel(value: string): string {
+  return { running: '运行中', finished: '已完成', failed: '失败' }[value] || value
+}
+
+function bucketLabel(value: string): string {
+  return {
+    primary: '主推荐',
+    rejected: '已淘汰',
+    pending_evaluation: '待评估',
+  }[value] || value
+}
+
+function sourceLabel(value: string): string {
+  return {
+    demand: '需求词',
+    seed: '种子视频',
+    point: '内容点',
+    tag: '标签',
+    author: '作者',
+    pagination: '翻页',
+    mixed: '混合',
+  }[value] || value
+}
+
+function formatTime(value: string | null): string {
+  if (!value) return '—'
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) return value
+  return new Intl.DateTimeFormat('zh-CN', {
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hour12: false,
+  }).format(date)
+}
+
+function formatDate(value: string | null): string {
+  if (!value) return '未标记'
+  if (/^\d{8}$/.test(value)) return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}`
+  return value
+}
+
+function formatCount(value: number | null): string {
+  if (value === null) return '—'
+  if (value >= 100_000_000) return `${(value / 100_000_000).toFixed(1)}亿`
+  if (value >= 10_000) return `${(value / 10_000).toFixed(1)}万`
+  return new Intl.NumberFormat('zh-CN').format(value)
+}
+
+function formatScore(value: number | null): string {
+  if (value === null) return '—'
+  return value.toFixed(value > 1 ? 2 : 3)
+}
+
+function formatJson(value: JsonValue): string {
+  if (value === null || value === undefined || value === '') return '暂无数据'
+  if (typeof value === 'string') return value
+  return JSON.stringify(value, null, 2)
+}
+
+function openCandidate(candidate: VideoDiscoveryCandidateRecord) {
+  const url = candidate.content_link?.trim()
+  if (url) window.open(url, '_blank', 'noopener,noreferrer')
+}
+</script>
+
+<template>
+  <div class="records-page">
+    <header class="page-header">
+      <div>
+        <div class="eyebrow"><span /> FIND AGENT · DATA TRACE</div>
+        <h1>找视频记录</h1>
+        <p>沿运行、搜索轨迹和候选视频还原 Find Agent 的完整决策过程。</p>
+      </div>
+      <button class="refresh-button" type="button" :disabled="loadingRuns" @click="refresh">
+        <span :class="{ spinning: loadingRuns }">↻</span>
+        刷新数据
+      </button>
+    </header>
+
+    <form class="filters" @submit.prevent="applyRunFilters">
+      <label>
+        <span>业务日</span>
+        <input v-model="bizDt" inputmode="numeric" maxlength="8" placeholder="YYYYMMDD" />
+      </label>
+      <label>
+        <span>运行状态</span>
+        <select v-model="status">
+          <option value="">全部状态</option>
+          <option value="running">运行中</option>
+          <option value="finished">已完成</option>
+          <option value="failed">失败</option>
+        </select>
+      </label>
+      <label class="keyword-filter">
+        <span>需求 / Run ID</span>
+        <input v-model="runKeyword" type="search" placeholder="搜索需求词、种子标题或 Run ID" />
+      </label>
+      <button class="primary-button" type="submit">查询</button>
+      <button class="ghost-button" type="button" @click="resetRunFilters">重置</button>
+    </form>
+
+    <p v-if="error" class="error-banner">
+      <strong>数据加载失败</strong>
+      {{ error }}
+    </p>
+
+    <section class="workspace">
+      <aside class="run-panel">
+        <div class="panel-heading">
+          <div>
+            <span>video_discovery_run</span>
+            <strong>运行记录</strong>
+          </div>
+          <b>{{ runTotal }}</b>
+        </div>
+
+        <div v-if="loadingRuns && !runs.length" class="state"><i class="loader" />加载运行记录…</div>
+        <div v-else-if="!runs.length" class="state empty">
+          <strong>暂无运行记录</strong>
+          <span>尝试调整业务日或状态筛选</span>
+        </div>
+        <div v-else class="run-list">
+          <button
+            v-for="run in runs"
+            :key="run.run_id"
+            type="button"
+            class="run-card"
+            :class="{ active: selected?.run_id === run.run_id }"
+            @click="selectRun(run)"
+          >
+            <span class="run-card-top">
+              <span class="status-pill" :class="`status-${run.status}`">
+                <i />{{ statusLabel(run.status) }}
+              </span>
+              <time>{{ formatTime(run.create_time) }}</time>
+            </span>
+            <strong>{{ run.demand_word }}</strong>
+            <small :title="run.run_id">{{ run.run_id }}</small>
+            <span class="run-card-meta">
+              <span>{{ formatDate(run.biz_dt) }}</span>
+              <span>{{ run.search_count }} 次搜索</span>
+              <span>{{ run.candidate_count }} 个候选</span>
+            </span>
+          </button>
+        </div>
+
+        <div class="pagination compact">
+          <button type="button" :disabled="runPage <= 1" @click="changeRunPage(-1)">‹</button>
+          <span>{{ runPage }} / {{ runPages }}</span>
+          <button type="button" :disabled="runPage >= runPages" @click="changeRunPage(1)">›</button>
+        </div>
+      </aside>
+
+      <main v-if="selected" class="detail-panel">
+        <section class="run-summary">
+          <div class="summary-main">
+            <div class="summary-title">
+              <div>
+                <span>当前运行 · {{ formatDate(selected.biz_dt) }}</span>
+                <h2>{{ selected.demand_word }}</h2>
+              </div>
+              <span class="status-pill large" :class="`status-${selected.status}`">
+                <i />{{ statusLabel(selected.status) }}
+              </span>
+            </div>
+            <p v-if="selected.intent_summary" class="intent">{{ selected.intent_summary }}</p>
+            <p v-else class="intent muted">Agent 尚未写入意图总结。</p>
+            <div class="identity-row">
+              <code>{{ selected.run_id }}</code>
+              <span v-if="selected.demand_grade_id">需求分级 #{{ selected.demand_grade_id }}</span>
+              <span v-if="selected.seed_video_id">种子视频 {{ selected.seed_video_id }}</span>
+            </div>
+            <details v-if="selected.relevant_points || selected.stop_reason" class="run-context">
+              <summary>查看输入点位与停止原因</summary>
+              <div v-if="selected.stop_reason">
+                <strong>停止原因</strong>
+                <p>{{ selected.stop_reason }}</p>
+              </div>
+              <div v-if="selected.relevant_points">
+                <strong>相关点位</strong>
+                <pre>{{ formatJson(selected.relevant_points) }}</pre>
+              </div>
+            </details>
+          </div>
+
+          <div class="metric-grid">
+            <article><span>搜索页</span><strong>{{ selected.search_count }}</strong></article>
+            <article><span>全部候选</span><strong>{{ selected.candidate_count }}</strong></article>
+            <article class="metric-primary"><span>主推荐</span><strong>{{ selected.primary_count }}</strong></article>
+            <article class="metric-rejected"><span>淘汰 / 待评估</span><strong>{{ selected.rejected_count }}<small> / {{ selected.pending_count }}</small></strong></article>
+          </div>
+        </section>
+
+        <section class="records-card">
+          <div class="tabs">
+            <button type="button" :class="{ active: tab === 'searches' }" @click="tab = 'searches'">
+              搜索轨迹 <b>{{ selected.search_count }}</b>
+            </button>
+            <button type="button" :class="{ active: tab === 'candidates' }" @click="tab = 'candidates'">
+              候选视频 <b>{{ selected.candidate_count }}</b>
+            </button>
+          </div>
+
+          <form class="detail-filters" @submit.prevent="applyDetailFilter">
+            <label class="detail-search">
+              <span class="search-icon" />
+              <input
+                v-model="detailKeyword"
+                type="search"
+                :placeholder="tab === 'searches' ? '搜索关键词、原因或来源' : '搜索视频 ID、标题或作者'"
+              />
+            </label>
+            <select v-if="tab === 'candidates'" v-model="candidateBucket" aria-label="候选分池">
+              <option value="">全部分池</option>
+              <option value="primary">主推荐</option>
+              <option value="rejected">已淘汰</option>
+              <option value="pending_evaluation">待评估</option>
+            </select>
+            <button type="submit">筛选</button>
+            <span class="result-count">共 {{ detailTotal }} 条</span>
+          </form>
+
+          <div v-if="loadingDetail" class="state"><i class="loader" />加载记录…</div>
+          <div
+            v-else-if="tab === 'searches' && !searches.length"
+            class="state empty"
+          >
+            <strong>暂无搜索轨迹</strong>
+            <span>Agent 保存搜索页后会显示在这里</span>
+          </div>
+          <div
+            v-else-if="tab === 'candidates' && !candidates.length"
+            class="state empty"
+          >
+            <strong>暂无候选视频</strong>
+            <span>当前运行或筛选条件下没有候选</span>
+          </div>
+
+          <div v-else-if="tab === 'searches'" class="table-wrap">
+            <table class="search-table">
+              <thead>
+                <tr>
+                  <th>ID / 状态</th>
+                  <th>搜索词与原因</th>
+                  <th>来源</th>
+                  <th>供应方 / 分页</th>
+                  <th>结果</th>
+                  <th>创建时间</th>
+                  <th />
+                </tr>
+              </thead>
+              <tbody>
+                <template v-for="item in searches" :key="item.id">
+                  <tr>
+                    <td>
+                      <span class="record-id">#{{ item.id }}</span>
+                      <span class="mini-status" :class="item.status === 'success' ? 'ok' : 'bad'">
+                        {{ item.status === 'success' ? '成功' : '失败' }}
+                      </span>
+                    </td>
+                    <td class="wide-cell">
+                      <strong>{{ item.keyword }}</strong>
+                      <small>{{ item.query_reason }}</small>
+                    </td>
+                    <td>
+                      <span class="source-pill">{{ sourceLabel(item.source_type) }}</span>
+                      <small>{{ item.source_value || '—' }}</small>
+                    </td>
+                    <td>
+                      <strong>{{ item.provider }}</strong>
+                      <small>第 {{ item.page_no }} 页 · 游标 {{ item.cursor }}</small>
+                    </td>
+                    <td>
+                      <strong>{{ item.results_count }}</strong>
+                      <small>新增 {{ item.new_candidate_count }} · {{ item.has_more ? '有下一页' : '已到底' }}</small>
+                    </td>
+                    <td class="nowrap">{{ formatTime(item.create_time) }}</td>
+                    <td>
+                      <button
+                        class="expand-button"
+                        type="button"
+                        :aria-expanded="expandedSearchId === item.id"
+                        @click="expandedSearchId = expandedSearchId === item.id ? null : item.id"
+                      >
+                        {{ expandedSearchId === item.id ? '收起' : '详情' }}
+                      </button>
+                    </td>
+                  </tr>
+                  <tr v-if="expandedSearchId === item.id" class="expanded-row">
+                    <td colspan="7">
+                      <div class="expanded-grid">
+                        <div>
+                          <strong>检索参数</strong>
+                          <dl>
+                            <dt>搜索键</dt><dd>{{ item.search_key }}</dd>
+                            <dt>内容 / 排序</dt><dd>{{ item.content_type }} · {{ item.sort_type }}</dd>
+                            <dt>发布时间</dt><dd>{{ item.publish_time }}</dd>
+                            <dt>父搜索</dt><dd>{{ item.parent_search_id ? `#${item.parent_search_id}` : '根搜索' }}</dd>
+                            <dt>下一游标</dt><dd>{{ item.next_cursor || '—' }}</dd>
+                          </dl>
+                          <p v-if="item.error_message" class="error-text">{{ item.error_message }}</p>
+                        </div>
+                        <div>
+                          <strong>供应方状态</strong>
+                          <pre>{{ formatJson(item.provider_state) }}</pre>
+                        </div>
+                        <div>
+                          <strong>结果视频 ID</strong>
+                          <pre>{{ formatJson(item.result_ids) }}</pre>
+                        </div>
+                      </div>
+                    </td>
+                  </tr>
+                </template>
+              </tbody>
+            </table>
+          </div>
+
+          <div v-else class="table-wrap">
+            <table class="candidate-table">
+              <thead>
+                <tr>
+                  <th>ID / 分池</th>
+                  <th>视频与作者</th>
+                  <th>互动数据</th>
+                  <th>R / E / S / V</th>
+                  <th>AIGC 计划</th>
+                  <th>创建时间</th>
+                  <th />
+                </tr>
+              </thead>
+              <tbody>
+                <template v-for="item in candidates" :key="item.id">
+                  <tr>
+                    <td>
+                      <span class="record-id">#{{ item.id }}</span>
+                      <span class="bucket-pill" :class="`bucket-${item.decision_bucket}`">
+                        {{ bucketLabel(item.decision_bucket) }}
+                      </span>
+                    </td>
+                    <td class="wide-cell video-cell">
+                      <strong>{{ item.title || '未命名视频' }}</strong>
+                      <small>{{ item.author_name || '未知作者' }} · {{ item.aweme_id }}</small>
+                    </td>
+                    <td>
+                      <strong>{{ formatCount(item.play_count) }} 播放</strong>
+                      <small>
+                        赞 {{ formatCount(item.like_count) }} · 评 {{ formatCount(item.comment_count) }}
+                        · 藏 {{ formatCount(item.collect_count) }} · 转 {{ formatCount(item.share_count) }}
+                      </small>
+                    </td>
+                    <td>
+                      <span class="score-line">
+                        <b>R {{ formatScore(item.relevance_score) }}</b>
+                        <b>E {{ formatScore(item.elder_score) }}</b>
+                        <b>S {{ formatScore(item.share_score) }}</b>
+                        <b class="value-score">V {{ formatScore(item.value_score) }}</b>
+                      </span>
+                    </td>
+                    <td>
+                      <strong>{{ item.aigc_plan_label || '未分发' }}</strong>
+                      <small>{{ item.aigc_crawler_plan_id || '—' }}</small>
+                    </td>
+                    <td class="nowrap">{{ formatTime(item.create_time) }}</td>
+                    <td>
+                      <button
+                        class="expand-button"
+                        type="button"
+                        :aria-expanded="expandedCandidateId === item.id"
+                        @click="expandedCandidateId = expandedCandidateId === item.id ? null : item.id"
+                      >
+                        {{ expandedCandidateId === item.id ? '收起' : '详情' }}
+                      </button>
+                    </td>
+                  </tr>
+                  <tr v-if="expandedCandidateId === item.id" class="expanded-row">
+                    <td colspan="7">
+                      <div class="candidate-detail">
+                        <div class="decision-block">
+                          <strong>决策依据</strong>
+                          <p>{{ item.decision_reason || '暂无决策说明' }}</p>
+                          <div class="tag-row">
+                            <span v-for="tag in Array.isArray(item.tags) ? item.tags : []" :key="String(tag)">
+                              {{ tag }}
+                            </span>
+                          </div>
+                          <button
+                            v-if="item.content_link"
+                            class="link-button"
+                            type="button"
+                            @click="openCandidate(item)"
+                          >
+                            打开原视频 ↗
+                          </button>
+                        </div>
+                        <div class="evidence-grid">
+                          <details>
+                            <summary>内容受众年龄证据</summary>
+                            <pre>{{ formatJson(item.content_age_evidence) }}</pre>
+                          </details>
+                          <details>
+                            <summary>账号粉丝年龄证据</summary>
+                            <pre>{{ formatJson(item.account_age_evidence) }}</pre>
+                          </details>
+                          <details>
+                            <summary>年龄归一化结果</summary>
+                            <pre>{{ formatJson(item.age_normalization) }}</pre>
+                          </details>
+                          <details>
+                            <summary>来源与 AIGC 计划</summary>
+                            <dl>
+                              <dt>Search ID</dt><dd>{{ item.search_id || '历史记录未关联' }}</dd>
+                              <dt>来源关键词</dt><dd>{{ formatJson(item.source_keywords) }}</dd>
+                              <dt>来源搜索</dt><dd>{{ formatJson(item.source_search_ids) }}</dd>
+                              <dt>爬取计划</dt><dd>{{ item.aigc_crawler_plan_id || '—' }}</dd>
+                              <dt>生成计划</dt><dd>{{ item.aigc_produce_plan_id || '—' }}</dd>
+                              <dt>发布计划</dt><dd>{{ item.aigc_publish_plan_id || '—' }}</dd>
+                            </dl>
+                          </details>
+                        </div>
+                      </div>
+                    </td>
+                  </tr>
+                </template>
+              </tbody>
+            </table>
+          </div>
+
+          <div v-if="detailTotal" class="pagination">
+            <span>第 {{ detailPage }} / {{ detailPages }} 页</span>
+            <div>
+              <button type="button" :disabled="detailPage <= 1" @click="changeDetailPage(-1)">上一页</button>
+              <button type="button" :disabled="detailPage >= detailPages" @click="changeDetailPage(1)">下一页</button>
+            </div>
+          </div>
+        </section>
+      </main>
+
+      <main v-else class="detail-panel no-selection">
+        <span>⌕</span>
+        <strong>选择一次找视频运行</strong>
+        <p>运行的搜索轨迹和候选评估将在这里展开。</p>
+      </main>
+    </section>
+  </div>
+</template>
+
+<style scoped>
+.records-page {
+  display: flex;
+  min-width: 0;
+  max-width: 1680px;
+  min-height: 100%;
+  margin: 0 auto;
+  flex-direction: column;
+  gap: 16px;
+  color: #242b3a;
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  padding: 8px 4px 2px;
+}
+
+.eyebrow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #737b8c;
+  font-size: 9px;
+  font-weight: 800;
+  letter-spacing: .17em;
+}
+
+.eyebrow span {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: #6666d4;
+  box-shadow: 0 0 0 4px rgba(102, 102, 212, .11);
+}
+
+.page-header h1 {
+  margin: 8px 0 3px;
+  font-size: 27px;
+  letter-spacing: -.04em;
+}
+
+.page-header p {
+  margin: 0;
+  color: #8a91a0;
+  font-size: 11px;
+}
+
+button, input, select { font: inherit; }
+
+.refresh-button,
+.primary-button,
+.ghost-button {
+  height: 38px;
+  border-radius: 9px;
+  cursor: pointer;
+}
+
+.refresh-button {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  padding: 0 13px;
+  border: 1px solid #dfe2e8;
+  background: #fff;
+  color: #5d6575;
+  font-size: 10px;
+  font-weight: 700;
+}
+
+.spinning { display: inline-block; animation: spin .7s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.filters {
+  display: grid;
+  grid-template-columns: 150px 160px minmax(260px, 1fr) auto auto;
+  align-items: end;
+  gap: 10px;
+  padding: 13px 15px;
+  border: 1px solid #e3e6eb;
+  border-radius: 14px;
+  background: rgba(255, 255, 255, .88);
+}
+
+.filters label {
+  display: grid;
+  gap: 5px;
+}
+
+.filters label > span {
+  color: #858c9b;
+  font-size: 9px;
+  font-weight: 700;
+}
+
+.filters input,
+.filters select,
+.detail-filters input,
+.detail-filters select {
+  height: 38px;
+  border: 1px solid #dfe2e8;
+  border-radius: 8px;
+  outline: none;
+  background: #fff;
+  color: #424a5b;
+  font-size: 10px;
+}
+
+.filters input { width: 100%; padding: 0 11px; }
+.filters select, .detail-filters select { padding: 0 30px 0 10px; }
+.filters input:focus, .filters select:focus, .detail-filters input:focus, .detail-filters select:focus {
+  border-color: #7474d8;
+  box-shadow: 0 0 0 3px rgba(102, 102, 212, .08);
+}
+
+.primary-button {
+  padding: 0 18px;
+  border: 1px solid #5d5dcd;
+  background: #6262d2;
+  color: #fff;
+  font-size: 10px;
+  font-weight: 700;
+}
+
+.ghost-button {
+  padding: 0 13px;
+  border: 1px solid #dfe2e8;
+  background: #fff;
+  color: #7a8190;
+  font-size: 10px;
+}
+
+.error-banner {
+  margin: 0;
+  padding: 10px 14px;
+  border: 1px solid #f0cdd2;
+  border-radius: 10px;
+  background: #fff5f6;
+  color: #a43d4b;
+  font-size: 10px;
+}
+
+.error-banner strong { margin-right: 8px; }
+
+.workspace {
+  display: grid;
+  min-height: 650px;
+  flex: 1;
+  grid-template-columns: 260px minmax(0, 1fr);
+  gap: 14px;
+}
+
+.run-panel,
+.detail-panel {
+  min-width: 0;
+  border: 1px solid #e2e5ea;
+  border-radius: 15px;
+  background: rgba(255, 255, 255, .91);
+  box-shadow: 0 8px 26px rgba(34, 42, 61, .035);
+}
+
+.run-panel {
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+}
+
+.panel-heading {
+  display: flex;
+  min-height: 62px;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 15px;
+  border-bottom: 1px solid #ebedf1;
+}
+
+.panel-heading div > span,
+.panel-heading div > strong { display: block; }
+.panel-heading div > span { color: #a0a6b1; font-size: 8px; font-weight: 700; }
+.panel-heading div > strong { margin-top: 3px; font-size: 12px; }
+.panel-heading b {
+  display: grid;
+  min-width: 27px;
+  height: 22px;
+  place-items: center;
+  padding: 0 7px;
+  border-radius: 7px;
+  background: #f0f0ff;
+  color: #5f5fcc;
+  font-size: 9px;
+}
+
+.run-list {
+  min-height: 0;
+  padding: 7px;
+  flex: 1;
+  overflow: auto;
+}
+
+.run-card {
+  display: flex;
+  width: 100%;
+  padding: 12px 11px;
+  flex-direction: column;
+  gap: 6px;
+  border: 1px solid transparent;
+  border-radius: 10px;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.run-card:hover { background: #f7f7fa; }
+.run-card.active {
+  border-color: #dcdcff;
+  background: #f1f1ff;
+}
+
+.run-card-top,
+.run-card-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 6px;
+}
+
+.run-card-top time { color: #9aa0ac; font-size: 8px; }
+.run-card > strong {
+  overflow: hidden;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.run-card > small {
+  overflow: hidden;
+  color: #9ca2ae;
+  font-family: ui-monospace, monospace;
+  font-size: 8px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.run-card-meta {
+  justify-content: flex-start;
+  color: #7e8594;
+  font-size: 8px;
+}
+.run-card-meta span + span::before { margin-right: 6px; color: #c3c7cf; content: '·'; }
+
+.status-pill,
+.bucket-pill {
+  display: inline-flex;
+  width: fit-content;
+  align-items: center;
+  gap: 5px;
+  padding: 3px 7px;
+  border-radius: 999px;
+  font-size: 8px;
+  font-weight: 800;
+}
+.status-pill i { width: 5px; height: 5px; border-radius: 50%; }
+.status-running { background: #fff7df; color: #9a6b08; }
+.status-running i { background: #e8a91d; }
+.status-finished { background: #eaf8f2; color: #278365; }
+.status-finished i { background: #31aa80; }
+.status-failed { background: #fff0f1; color: #af4652; }
+.status-failed i { background: #d25563; }
+.status-pill.large { padding: 5px 9px; font-size: 9px; }
+
+.detail-panel { overflow: hidden; }
+.run-summary {
+  display: grid;
+  padding: 19px;
+  grid-template-columns: minmax(0, 1fr) 390px;
+  gap: 18px;
+  border-bottom: 1px solid #e8eaee;
+  background: linear-gradient(145deg, #fff 20%, #f8f8ff);
+}
+.summary-title {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 15px;
+}
+.summary-title > div > span { color: #9298a5; font-size: 9px; font-weight: 700; }
+.summary-title h2 { margin: 4px 0 0; font-size: 20px; letter-spacing: -.03em; }
+.intent { max-width: 800px; margin: 11px 0 10px; color: #586071; font-size: 10px; line-height: 1.7; }
+.intent.muted { color: #9ca2ad; }
+.identity-row { display: flex; flex-wrap: wrap; gap: 8px; }
+.identity-row code, .identity-row span {
+  padding: 4px 7px;
+  border-radius: 6px;
+  background: #f0f2f5;
+  color: #707787;
+  font-size: 8px;
+}
+.run-context { margin-top: 10px; font-size: 9px; }
+.run-context summary { color: #6262cc; cursor: pointer; }
+.run-context strong { display: block; margin-top: 8px; color: #606878; }
+.run-context p { margin: 4px 0; color: #737b89; }
+pre {
+  max-height: 210px;
+  margin: 6px 0 0;
+  padding: 10px;
+  overflow: auto;
+  border: 1px solid #e5e7ec;
+  border-radius: 8px;
+  background: #f8f9fb;
+  color: #596173;
+  font: 8px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-self: start; }
+.metric-grid article {
+  min-height: 68px;
+  padding: 11px 12px;
+  border: 1px solid #e5e7ed;
+  border-radius: 10px;
+  background: rgba(255,255,255,.8);
+}
+.metric-grid span { display: block; color: #9298a5; font-size: 8px; font-weight: 700; }
+.metric-grid strong { display: block; margin-top: 5px; font-size: 19px; letter-spacing: -.04em; }
+.metric-grid strong small { color: #999fac; font-size: 11px; }
+.metric-grid .metric-primary { border-color: #cceadf; background: #f4fbf8; }
+.metric-primary strong { color: #248062; }
+.metric-grid .metric-rejected { border-color: #f0dadd; background: #fff9f9; }
+.metric-rejected strong { color: #a94c58; }
+
+.records-card { min-width: 0; }
+.tabs {
+  display: flex;
+  height: 49px;
+  align-items: flex-end;
+  gap: 21px;
+  padding: 0 18px;
+  border-bottom: 1px solid #e8eaee;
+}
+.tabs button {
+  height: 49px;
+  padding: 0 2px;
+  border: 0;
+  border-bottom: 2px solid transparent;
+  background: transparent;
+  color: #858c9a;
+  font-size: 10px;
+  font-weight: 700;
+  cursor: pointer;
+}
+.tabs button.active { border-bottom-color: #6262d2; color: #4f50bd; }
+.tabs b { margin-left: 4px; color: #a1a6b1; font-size: 8px; }
+
+.detail-filters {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 13px;
+  border-bottom: 1px solid #eceef2;
+  background: #fafbfc;
+}
+.detail-search { position: relative; width: min(360px, 45%); }
+.detail-search input { width: 100%; padding: 0 11px 0 34px; }
+.search-icon {
+  position: absolute;
+  z-index: 1;
+  top: 12px;
+  left: 12px;
+  width: 10px;
+  height: 10px;
+  border: 1.5px solid #969daa;
+  border-radius: 50%;
+}
+.search-icon::after {
+  position: absolute;
+  right: -4px;
+  bottom: -3px;
+  width: 5px;
+  height: 1.5px;
+  background: #969daa;
+  content: '';
+  transform: rotate(45deg);
+}
+.detail-filters > button {
+  height: 34px;
+  padding: 0 13px;
+  border: 1px solid #d7dae1;
+  border-radius: 8px;
+  background: #fff;
+  color: #646c7b;
+  font-size: 9px;
+  font-weight: 700;
+  cursor: pointer;
+}
+.result-count { margin-left: auto; color: #939aa7; font-size: 9px; }
+
+.table-wrap { width: 100%; overflow-x: auto; }
+table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }
+th {
+  height: 36px;
+  padding: 0 10px;
+  border-bottom: 1px solid #e8eaee;
+  background: #fff;
+  color: #9298a5;
+  font-size: 8px;
+  font-weight: 700;
+  text-align: left;
+}
+td {
+  height: 64px;
+  padding: 9px 10px;
+  border-bottom: 1px solid #eff0f3;
+  color: #5b6373;
+  font-size: 9px;
+  vertical-align: middle;
+}
+tbody > tr:not(.expanded-row):hover { background: #fafafe; }
+td strong, td small { display: block; }
+td strong {
+  overflow: hidden;
+  color: #41495a;
+  font-size: 9px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+td small {
+  max-width: 100%;
+  margin-top: 4px;
+  overflow: hidden;
+  color: #9096a3;
+  font-size: 8px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.search-table th:nth-child(1), .search-table td:nth-child(1) { width: 86px; }
+.search-table th:nth-child(2), .search-table td:nth-child(2) { width: 25%; }
+.search-table th:nth-child(3), .search-table td:nth-child(3) { width: 15%; }
+.search-table th:nth-child(4), .search-table td:nth-child(4) { width: 16%; }
+.search-table th:nth-child(5), .search-table td:nth-child(5) { width: 14%; }
+.search-table th:nth-child(6), .search-table td:nth-child(6) { width: 112px; }
+.search-table th:nth-child(7), .search-table td:nth-child(7) { width: 53px; }
+.candidate-table th:nth-child(1), .candidate-table td:nth-child(1) { width: 89px; }
+.candidate-table th:nth-child(2), .candidate-table td:nth-child(2) { width: 24%; }
+.candidate-table th:nth-child(3), .candidate-table td:nth-child(3) { width: 19%; }
+.candidate-table th:nth-child(4), .candidate-table td:nth-child(4) { width: 20%; }
+.candidate-table th:nth-child(5), .candidate-table td:nth-child(5) { width: 13%; }
+.candidate-table th:nth-child(6), .candidate-table td:nth-child(6) { width: 112px; }
+.candidate-table th:nth-child(7), .candidate-table td:nth-child(7) { width: 53px; }
+.wide-cell strong { font-size: 10px; }
+.record-id {
+  display: block;
+  margin-bottom: 5px;
+  color: #858c9a;
+  font-family: ui-monospace, monospace;
+  font-size: 8px;
+}
+.mini-status, .source-pill {
+  display: inline-block;
+  padding: 2px 6px;
+  border-radius: 5px;
+  font-size: 8px;
+  font-weight: 700;
+}
+.mini-status.ok { background: #eaf8f2; color: #278365; }
+.mini-status.bad { background: #fff0f1; color: #ad4350; }
+.source-pill { background: #eef0ff; color: #5f61bc; }
+.bucket-primary { background: #e8f8f1; color: #247e61; }
+.bucket-rejected { background: #fff0f1; color: #aa4551; }
+.bucket-pending_evaluation { background: #fff7df; color: #946708; }
+.nowrap { white-space: nowrap; }
+.score-line { display: flex; flex-wrap: wrap; gap: 4px; }
+.score-line b {
+  padding: 3px 5px;
+  border-radius: 5px;
+  background: #f1f2f5;
+  color: #687080;
+  font-size: 7px;
+}
+.score-line .value-score { background: #ececff; color: #5758c2; }
+.expand-button {
+  padding: 4px 0;
+  border: 0;
+  background: transparent;
+  color: #5e60c8;
+  font-size: 8px;
+  font-weight: 700;
+  cursor: pointer;
+}
+.expanded-row > td { height: auto; padding: 13px; background: #f8f9fc; }
+.expanded-grid { display: grid; grid-template-columns: .8fr 1fr 1fr; gap: 12px; }
+.expanded-grid > div, .decision-block, .evidence-grid details {
+  min-width: 0;
+  padding: 11px;
+  border: 1px solid #e3e6ec;
+  border-radius: 9px;
+  background: #fff;
+}
+.expanded-grid > div > strong, .decision-block > strong {
+  color: #4b5363;
+  font-size: 9px;
+}
+dl {
+  display: grid;
+  grid-template-columns: 78px minmax(0, 1fr);
+  gap: 6px;
+  margin: 8px 0 0;
+  font-size: 8px;
+}
+dt { color: #9ba1ad; }
+dd { margin: 0; overflow-wrap: anywhere; color: #606878; }
+.error-text { color: #ad4350; }
+.candidate-detail { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 12px; }
+.decision-block p { color: #697181; font-size: 9px; line-height: 1.7; }
+.tag-row { display: flex; flex-wrap: wrap; gap: 5px; }
+.tag-row span { padding: 3px 6px; border-radius: 5px; background: #f0f1f5; color: #717887; font-size: 8px; }
+.link-button {
+  margin-top: 10px;
+  padding: 5px 8px;
+  border: 1px solid #d9daf4;
+  border-radius: 6px;
+  background: #f5f5ff;
+  color: #5a5cc5;
+  font-size: 8px;
+  cursor: pointer;
+}
+.evidence-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.evidence-grid details { padding: 9px; }
+.evidence-grid summary { color: #5e6676; font-size: 8px; font-weight: 700; cursor: pointer; }
+
+.state {
+  display: flex;
+  min-height: 190px;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  color: #8d94a2;
+  font-size: 10px;
+}
+.state.empty { flex-direction: column; }
+.state.empty strong { color: #666e7d; font-size: 11px; }
+.state.empty span { color: #9ba1ad; font-size: 9px; }
+.loader {
+  width: 13px;
+  height: 13px;
+  border: 2px solid #dddff1;
+  border-top-color: #6262d2;
+  border-radius: 50%;
+  animation: spin .7s linear infinite;
+}
+.pagination {
+  display: flex;
+  min-height: 51px;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 13px;
+  border-top: 1px solid #eaecf0;
+  color: #8b92a0;
+  font-size: 9px;
+}
+.pagination div { display: flex; gap: 6px; }
+.pagination button {
+  min-width: 54px;
+  height: 29px;
+  border: 1px solid #dfe2e7;
+  border-radius: 7px;
+  background: #fff;
+  color: #656d7d;
+  font-size: 8px;
+  cursor: pointer;
+}
+.pagination button:disabled { opacity: .4; cursor: default; }
+.pagination.compact { justify-content: center; gap: 10px; margin-top: auto; }
+.pagination.compact button { min-width: 29px; }
+.no-selection {
+  display: flex;
+  min-height: 650px;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  color: #9299a6;
+}
+.no-selection > span { font-size: 32px; color: #7778d4; }
+.no-selection strong { margin-top: 9px; color: #596171; font-size: 13px; }
+.no-selection p { margin: 5px 0; font-size: 9px; }
+
+@media (max-width: 1180px) {
+  .run-summary { grid-template-columns: 1fr; }
+  .metric-grid { grid-template-columns: repeat(4, 1fr); }
+  .metric-grid article { min-height: 58px; }
+}
+</style>

+ 3 - 3
zhangbo.md

@@ -303,9 +303,9 @@ source_dim
 - `get_account_fans_portrait`:获取作者粉丝画像;
 - `batch_fetch_portraits`:批量获取视频画像,并可同时获取作者画像;
 - `normalize_age_portraits`:标准化 `50-` 等年龄桶及双侧证据;
-- `create_video_discovery_run`:创建可追踪的找运行;
+- `create_video_discovery_run`:创建可追踪的找视频运行;
 - `batch_update_video_discovery_candidates`:按 candidate_id 更新证据、评分和最终分池;
-- `update_video_discovery_run_status`:独立更新找运行状态;
+- `update_video_discovery_run_status`:独立更新找视频运行状态;
 - `query_video_discovery_state`:查询搜索树和候选分池。
 
 搜索和详情接口有约 10 秒的请求间隔限制。
@@ -502,7 +502,7 @@ multi_demand_pool_di.id
 `supply_infra/scheduler/app.py` 当前只注册一条任务:
 
 1. 每天 `15:00`(`SCHEDULER_TIMEZONE`):串行执行全链路
-   `run_supply_pipeline`(全局树 → 需求池 → 分级 → 拓展 → 找 → AIGC 发布)。
+   `run_supply_pipeline`(全局树 → 需求池 → 分级 → 拓展 → 找视频 → AIGC 发布)。
 
 ### 9.2 全局树同步