Ver Fonte

Merge branch 'dev-optimize-scheduled' of Server/SupplyAgent into master

xueyiming há 3 dias atrás
pai
commit
baed8026ff

+ 6 - 5
PRD.md

@@ -398,8 +398,9 @@ flowchart LR
    - `primary`:主推荐;
    - `primary`:主推荐;
    - `rejected`:淘汰;
    - `rejected`:淘汰;
    - `pending_evaluation`:尚未完成的过程状态,不是最终等级;
    - `pending_evaluation`:尚未完成的过程状态,不是最终等级;
-9. Agent 应将运行置为 `finished` 并输出主推荐、淘汰原因、搜索树和缺失证据;
-10. Agent 返回后,程序从最终文字中解析主推荐/淘汰候选视频 ID,再次同步数据库分池。
+9. Agent 保存最终候选评估并将运行置为 `finished`;
+10. 数据库审计通过后重新查询最终状态,再输出主推荐、淘汰原因、搜索树和缺失证据;
+11. completion guard 校验顺序和报告分池,报告之后不再修改数据库。
 
 
 ### 11.3 输出
 ### 11.3 输出
 
 
@@ -596,7 +597,7 @@ flowchart LR
 | P0-03 | 无计划或无分级也可能成功 | 当日需求完全未处理仍进入拓展和找片 | 自动计划异常被吞掉;空计划快照可被视为 complete | 校验计划覆盖率和分级覆盖率 |
 | P0-03 | 无计划或无分级也可能成功 | 当日需求完全未处理仍进入拓展和找片 | 自动计划异常被吞掉;空计划快照可被视为 complete | 校验计划覆盖率和分级覆盖率 |
 | P0-04 | Agent 返回即把分级明细标记 finished | 模型未保存、少保存或保存工具报错时产生假完成 | worker 不核对 `demand_grade` 实际落库覆盖 | 每批结束后按输入逐条验库 |
 | P0-04 | Agent 返回即把分级明细标记 finished | 模型未保存、少保存或保存工具报错时产生假完成 | worker 不核对 `demand_grade` 实际落库覆盖 | 每批结束后按输入逐条验库 |
 | P0-05 | 点位拓展把“未保存”误判为“零结果” | S/A 需求被永久标记完成并从找片链路消失 | 从工具文本解析不到数量时默认为 0,仍写 finished | 必须验证保存工具调用和 run 状态 |
 | P0-05 | 点位拓展把“未保存”误判为“零结果” | S/A 需求被永久标记完成并从找片链路消失 | 从工具文本解析不到数量时默认为 0,仍写 finished | 必须验证保存工具调用和 run 状态 |
-| P0-06 | 找片完成守卫被关闭 | Agent 可在搜索、证据、评估或审计未完成时提前结束 | `create_find_agent(... completion_guard=None)` | 恢复并测试确定性完成守卫 |
+| P0-06 | 找片确定性完成控制(已修复) | 防止在搜索、证据、评估或审计未完成时提前结束 | 已注册数据库审计并启用 completion guard,强制审计后查询最终状态再报告 | 保持顺序与负向回归测试 |
 | P0-07 | AIGC 分发不校验找片运行状态 | running/failed 运行中的候选也可能被外发 | publish 查询只筛候选 bucket,不筛 run status/audit | 仅 finished+审计通过可分发 |
 | P0-07 | AIGC 分发不校验找片运行状态 | running/failed 运行中的候选也可能被外发 | publish 查询只筛候选 bucket,不筛 run status/audit | 仅 finished+审计通过可分发 |
 | P0-08 | AIGC 按所有计划轮询,不按品类路由 | 健康、历史、时政等视频可能进入错误生产计划 | 代码明确“不区分品类”,均匀分发 | 建立可配置且可解释的分类路由 |
 | P0-08 | AIGC 按所有计划轮询,不按品类路由 | 健康、历史、时政等视频可能进入错误生产计划 | 代码明确“不区分品类”,均匀分发 | 建立可配置且可解释的分类路由 |
 | P0-09 | “发布”没有真正执行发布 | 业务误以为已发布,实际只绑定了生成计划 | `publish_plan_id` 只入库,没有参与外部 API 调用 | 拆分分发/生产/发布状态并实现确认 |
 | P0-09 | “发布”没有真正执行发布 | 业务误以为已发布,实际只绑定了生成计划 | `publish_plan_id` 只入库,没有参与外部 API 调用 | 拆分分发/生产/发布状态并实现确认 |
@@ -625,7 +626,7 @@ flowchart LR
 | P1-16 | `running` 找片记录无租约,可能永久跳过 | 进程硬退出后任务永远不再执行 | 增加 heartbeat、超时和 attempt |
 | P1-16 | `running` 找片记录无租约,可能永久跳过 | 进程硬退出后任务永远不再执行 | 增加 heartbeat、超时和 attempt |
 | P1-17 | 强制重跑复用旧 run_id 和旧子记录 | 两次搜索轨迹、候选和状态互相污染 | 每次重跑新 attempt,显式继承关系 |
 | P1-17 | 强制重跑复用旧 run_id 和旧子记录 | 两次搜索轨迹、候选和状态互相污染 | 每次重跑新 attempt,显式继承关系 |
 | P1-18 | 同一 aweme_id 可在多个 run 重复分发 | AIGC 重复抓取/生产同一视频 | 建立全局视频资产和发布唯一性 |
 | P1-18 | 同一 aweme_id 可在多个 run 重复分发 | AIGC 重复抓取/生产同一视频 | 建立全局视频资产和发布唯一性 |
-| P1-19 | 最终文字可再次覆盖数据库分池 | 未经审计的文本解析可能改变候选状态 | 最终报告从数据库生成,禁止反向覆盖 |
+| P1-19 | 最终文字覆盖数据库分池(已修复) | 防止未经审计的文本解析改变候选状态 | 最终报告只读数据库最终状态并由 guard 校验 |
 | P1-20 | 前端“视频发现”未展示真实发现候选 | 运营无法核对主推荐、备选和发布状态 | 新增 candidate/run API 和页面 |
 | P1-20 | 前端“视频发现”未展示真实发现候选 | 运营无法核对主推荐、备选和发布状态 | 新增 candidate/run API 和页面 |
 | P1-21 | Scheduler 默认启用且随 API 启动 | 开发、扩容或临时环境可能误触生产任务 | 生产显式开启,默认关闭 |
 | P1-21 | Scheduler 默认启用且随 API 启动 | 开发、扩容或临时环境可能误触生产任务 | 生产显式开启,默认关闭 |
 | P1-22 | 延迟超过 1 小时会丢失当日调度 | API 故障恢复后不会自动补跑 | 按 biz_dt 对账并自动补批次 |
 | P1-22 | 延迟超过 1 小时会丢失当日调度 | API 故障恢复后不会自动补跑 | 按 biz_dt 对账并自动补批次 |
@@ -714,7 +715,7 @@ flowchart TB
 - 为总流水线增加依赖门禁;
 - 为总流水线增加依赖门禁;
 - 修复 failed 分级组被视为完成的问题;
 - 修复 failed 分级组被视为完成的问题;
 - 增加计划覆盖率、分级覆盖率和逐项落库校验;
 - 增加计划覆盖率、分级覆盖率和逐项落库校验;
-- 恢复找片完成守卫
+- 保持找片完成守卫、数据库审计和最终状态顺序的负向回归
 - AIGC 只读取 finished 且审计通过的运行;
 - AIGC 只读取 finished 且审计通过的运行;
 - 暂停无分类路由的自动分发,先切换为 dry-run 或人工确认;
 - 暂停无分类路由的自动分发,先切换为 dry-run 或人工确认;
 - 对 AIGC 请求日志脱敏;
 - 对 AIGC 请求日志脱敏;

+ 254 - 1042
agents/find_agent/PRD.md

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

+ 10 - 3
agents/find_agent/README.md

@@ -48,11 +48,12 @@ TikHub 翻页必须同时沿用 `next_cursor、search_id、backtrace`。
 | `video_discovery_search` | 一个关键词或作者的一页结果 | 保存 Agent 实际搜索词、形成原因、标签/作者/翻页来源、供应方分页状态和新增候选数 |
 | `video_discovery_search` | 一个关键词或作者的一页结果 | 保存 Agent 实际搜索词、形成原因、标签/作者/翻页来源、供应方分页状态和新增候选数 |
 | `video_discovery_candidate` | 一次任务中的一条视频 | 保存详情、来源关键词、标签、互动量、双侧年龄证据、R/E/S/V 和 Agent 分池 |
 | `video_discovery_candidate` | 一次任务中的一条视频 | 保存详情、来源关键词、标签、互动量、双侧年龄证据、R/E/S/V 和 Agent 分池 |
 
 
-已注册四个持久化工具:
+已注册五个持久化与审计工具:
 
 
 - `create_video_discovery_run`:创建运行并取得 `run_id`;
 - `create_video_discovery_run`:创建运行并取得 `run_id`;
 - `record_video_search_page`:保存每次搜索和翻页,幂等合并 `aweme_id`;
 - `record_video_search_page`:保存每次搜索和翻页,幂等合并 `aweme_id`;
 - `batch_save_video_candidate_evaluations`:原样保存 Agent 给出的证据、评分和分池;
 - `batch_save_video_candidate_evaluations`:原样保存 Agent 给出的证据、评分和分池;
+- `audit_video_discovery_run`:从数据库读取完整运行状态并执行确定性完成审计;
 - `query_video_discovery_state`:恢复搜索树、主推荐和淘汰候选;
 - `query_video_discovery_state`:恢复搜索树、主推荐和淘汰候选;
 
 
 候选分池完全由 Agent 决定:
 候选分池完全由 Agent 决定:
@@ -68,8 +69,14 @@ TikHub 翻页必须同时沿用 `next_cursor、search_id、backtrace`。
 `pending_evaluation` 不能作为最终结果。
 `pending_evaluation` 不能作为最终结果。
 
 
 保存工具不会重算 `R/E/S/V`、限制年龄分,也不会修改 Agent 给出的
 保存工具不会重算 `R/E/S/V`、限制年龄分,也不会修改 Agent 给出的
-`decision_bucket`;它只校验最终等级必须是 `primary / rejected`。最终报告中的主推荐
-和淘汰候选会同步到对应数据库分池。
+`decision_bucket`;它只校验最终等级必须是 `primary / rejected`。
+
+运行时 completion guard 强制结束前最后阶段为:
+
+`最后搜索并保存 → 证据获取与整理 → 候选评估保存 → 审计 → 最终状态查询 → 报告`
+
+审计后如果又发生搜索、取证或评估,必须重新审计并重新查询状态。最终报告只读取数据库
+中的 `primary / rejected`,报告之后不再反向修改分池。
 
 
 ## 建议补充的外部数据工具
 ## 建议补充的外部数据工具
 
 

+ 11 - 10
agents/find_agent/VALIDATION.md

@@ -18,9 +18,9 @@
 
 
 ## 本轮回归结果
 ## 本轮回归结果
 
 
-- 2026-07-28 当前契约定向测试:`tests/test_find_agent.py` 及模型列、超时回收测试为
-  `16 passed`;
-- 调度定向测试为 `1 passed, 1 failed`;剩余失败是既有测试夹具使用普通 `object()`
+- 2026-07-28 当前契约定向测试:`tests/test_find_agent.py` 及完成控制、模型列、超时
+  回收测试为 `19 passed`;
+- 调度测试为 `3 passed, 1 failed`;剩余失败是既有测试夹具使用普通 `object()`
   代替 `FindDemandContext`,日志读取 `demand_grade_id / demand_name` 时失败,与本次
   代替 `FindDemandContext`,日志读取 `demand_grade_id / demand_name` 时失败,与本次
   “不使用视频理解、仅 primary/rejected”修改无关,本次未越界修复;
   “不使用视频理解、仅 primary/rejected”修改无关,本次未越界修复;
 - 2026-07-23 历史 find_agent 与 LLM 重试定向测试:`19 passed`;
 - 2026-07-23 历史 find_agent 与 LLM 重试定向测试:`19 passed`;
@@ -76,6 +76,7 @@
 | `batch_fetch_portraits` | 对上述候选真实请求双侧画像 | 真实通过;内容侧缺失时作者侧仍成功返回 |
 | `batch_fetch_portraits` | 对上述候选真实请求双侧画像 | 真实通过;内容侧缺失时作者侧仍成功返回 |
 | `normalize_age_portraits` | 使用本轮真实双侧返回测试 | 通过;输出 `account_only`、作者侧 `strong`、E上限0.65 |
 | `normalize_age_portraits` | 使用本轮真实双侧返回测试 | 通过;输出 `account_only`、作者侧 `strong`、E上限0.65 |
 | `audit_video_discovery_process` | 完整流/提前停止/错误淘汰场景 | 契约已同步为仅审计 `primary / rejected`,不再要求视频理解 |
 | `audit_video_discovery_process` | 完整流/提前停止/错误淘汰场景 | 契约已同步为仅审计 `primary / rejected`,不再要求视频理解 |
+| `audit_video_discovery_run` | 注册与完成顺序契约测试 | 已注册;候选评估后执行,且仅 `can_finish=true` 可进入最终状态查询 |
 | `create_video_discovery_run` | 真实 MySQL 端到端测试 | 真实通过;运行记录成功创建 |
 | `create_video_discovery_run` | 真实 MySQL 端到端测试 | 真实通过;运行记录成功创建 |
 | `record_video_search_page` | 真实 MySQL 保存与重复页测试 | 真实通过;3条候选入库,重复保存新增数为0 |
 | `record_video_search_page` | 真实 MySQL 保存与重复页测试 | 真实通过;3条候选入库,重复保存新增数为0 |
 | `batch_save_video_candidate_evaluations` | 候选分池契约 | 当前只接受 `primary / rejected`;`backup` 会返回输入错误 |
 | `batch_save_video_candidate_evaluations` | 候选分池契约 | 当前只接受 `primary / rejected`;`backup` 会返回输入错误 |
@@ -98,20 +99,20 @@
 ## 当前阻塞
 ## 当前阻塞
 
 
 1. TikHub Key、三张 MySQL 表和全部持久化工具均已完成真实验证,没有相关阻塞。
 1. TikHub Key、三张 MySQL 表和全部持久化工具均已完成真实验证,没有相关阻塞。
-2. 完整 Agent 干跑已经执行,但最终审计未通过,当前不能宣称 Agent 可稳定完成任务。
+2. 历史完整 Agent 干跑的最终审计未通过;完成守卫现已接入,但仍需重新执行真实干跑
+   才能宣称 Agent 可稳定完成任务。
 3. 持久化候选需要保存 `detail_verified / content_portrait_attempted /
 3. 持久化候选需要保存 `detail_verified / content_portrait_attempted /
    account_portrait_attempted / age_portraits_normalized` 等审计状态;视频理解字段不
    account_portrait_attempted / age_portraits_normalized` 等审计状态;视频理解字段不
    属于当前审计契约。
    属于当前审计契约。
-4. AgentLoop 没有 find_agent 完成守卫;模型可以在 `can_finish=false` 时直接返回文本。
+4. find_agent 完成守卫已启用;`can_finish=false`、审计后状态变更或审计前状态查询
+   都会拒绝最终文本。
 
 
 ## 下一步修复
 ## 下一步修复
 
 
 1. 确保候选表的详情、双画像尝试和年龄标准化状态由
 1. 确保候选表的详情、双画像尝试和年龄标准化状态由
    `query_video_discovery_state` 原样恢复;搜索状态同时补回 `parent_search_id`。
    `query_video_discovery_state` 原样恢复;搜索状态同时补回 `parent_search_id`。
-2. 给 find_agent 增加确定性完成守卫:最后一次审计不是 `can_finish=true` 时,禁止
-   AgentLoop 接受最终文本;搜索或候选变更后必须重新审计。
-3. 将年龄标准化合并到批画像或候选保存流程,避免模型跳过强制证据处理。
-4. 调整搜索和详情预算。当前详情按每条约10秒串行,真实任务延迟偏高;应先用分享量、
+2. 将年龄标准化合并到批画像或候选保存流程,避免模型跳过强制证据处理。
+3. 调整搜索和详情预算。当前详情按每条约10秒串行,真实任务延迟偏高;应先用分享量、
    相关性和画像可得性做更强预筛。
    相关性和画像可得性做更强预筛。
-5. 完成上述修改后,使用同一输入重新干跑,直到最终审计通过并输出完整
+4. 使用同一输入重新干跑,直到最终审计通过并输出完整
    `primary / rejected` 报告。
    `primary / rejected` 报告。

+ 0 - 5
agents/find_agent/__init__.py

@@ -10,7 +10,6 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeou
 
 
 from agents.find_agent.agent import create_find_agent
 from agents.find_agent.agent import create_find_agent
 from agents.find_agent.async_runner import _run_coroutine, arun_find_agent
 from agents.find_agent.async_runner import _run_coroutine, arun_find_agent
-from agents.find_agent.output_sync import persist_model_recommendations
 from supply_agent.config import Settings
 from supply_agent.config import Settings
 from supply_agent.types import AgentResult
 from supply_agent.types import AgentResult
 
 
@@ -51,8 +50,4 @@ def run_find_agent(
         )
         )
     except Exception:
     except Exception:
         logger.exception("find_agent publish failed")
         logger.exception("find_agent publish failed")
-    try:
-        persist_model_recommendations(result)
-    except Exception:
-        logger.exception("persist model recommendations failed")
     return result
     return result

+ 33 - 46
agents/find_agent/agent.py

@@ -25,6 +25,11 @@ _EVIDENCE_TOOLS = {
     "batch_fetch_portraits",
     "batch_fetch_portraits",
     "normalize_age_portraits",
     "normalize_age_portraits",
 }
 }
+_SEARCH_TOOLS = {
+    "douyin_search",
+    "douyin_search_tikhub",
+    "douyin_user_videos",
+}
 _VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
 _VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
 
 
 
 
@@ -122,18 +127,33 @@ def _successful_tool_events(
 
 
 
 
 def find_agent_completion_guard(messages: list[Message]) -> str | None:
 def find_agent_completion_guard(messages: list[Message]) -> str | None:
-    """Reject final text until persisted state and the final audit prove completion."""
+    """Enforce the final search → evidence → evaluation → audit → state → report order."""
     events = _successful_tool_events(messages)
     events = _successful_tool_events(messages)
     if not any(name == "create_video_discovery_run" for _, name, _ in events):
     if not any(name == "create_video_discovery_run" for _, name, _ in events):
         return "尚未成功创建视频发现运行"
         return "尚未成功创建视频发现运行"
 
 
-    record_indexes = [
+    search_indexes = [
         index
         index
         for index, name, _ in events
         for index, name, _ in events
         if name == "record_video_search_page"
         if name == "record_video_search_page"
     ]
     ]
-    if not record_indexes:
+    if not search_indexes:
         return "尚未持久化任何搜索页"
         return "尚未持久化任何搜索页"
+    last_search_index = max(search_indexes)
+    raw_search_indexes = [
+        index for index, name, _ in events if name in _SEARCH_TOOLS
+    ]
+    if raw_search_indexes and last_search_index < max(raw_search_indexes):
+        return "最后一次搜索结果尚未通过 record_video_search_page 持久化"
+
+    evidence_indexes = [
+        index for index, name, _ in events if name in _EVIDENCE_TOOLS
+    ]
+    if not evidence_indexes:
+        return "尚未获取并整理候选证据"
+    last_evidence_index = max(evidence_indexes)
+    if last_evidence_index < last_search_index:
+        return "最后一次搜索后尚未重新获取并整理候选证据"
 
 
     evaluation_events = [
     evaluation_events = [
         (index, payload)
         (index, payload)
@@ -143,47 +163,21 @@ def find_agent_completion_guard(messages: list[Message]) -> str | None:
     if not evaluation_events:
     if not evaluation_events:
         return "尚未保存候选评估"
         return "尚未保存候选评估"
     evaluation_index, evaluation = evaluation_events[-1]
     evaluation_index, evaluation = evaluation_events[-1]
-    if evaluation_index < max(record_indexes):
-        return "最后一次搜索发生在候选评估之后,新增候选尚未重新评估"
+    if evaluation_index < last_evidence_index:
+        return "最后一次证据获取发生在候选评估之后,证据尚未重新评估并保存"
     if evaluation.get("status") != "finished":
     if evaluation.get("status") != "finished":
         return "最后一次候选保存尚未把运行状态设置为 finished"
         return "最后一次候选保存尚未把运行状态设置为 finished"
 
 
-    evidence_indexes = [
-        index for index, name, _ in events if name in _EVIDENCE_TOOLS
-    ]
-    if evidence_indexes and evaluation_index < max(evidence_indexes):
-        return "最后一次证据获取发生在候选评估之后,证据尚未重新保存"
-
     audit_events = [
     audit_events = [
         (index, payload)
         (index, payload)
         for index, name, payload in events
         for index, name, payload in events
-        if name in {
-            "audit_video_discovery_process",
-            "audit_video_discovery_run",
-        }
+        if name == "audit_video_discovery_run"
     ]
     ]
     if not audit_events:
     if not audit_events:
-        return "尚未执行完成审计"
+        return "尚未调用 audit_video_discovery_run 执行数据库完成审计"
     audit_index, audit = audit_events[-1]
     audit_index, audit = audit_events[-1]
     if audit_index < evaluation_index:
     if audit_index < evaluation_index:
-        post_audit_evaluations = [
-            payload
-            for index, payload in evaluation_events
-            if index > audit_index
-        ]
-        if not post_audit_evaluations or any(
-            payload.get("audit_relevant_changed") is not False
-            for payload in post_audit_evaluations
-        ):
-            return (
-                "最后一次候选评估改变了审计相关状态,尚未重新审计;"
-                "下一步只调用 audit_video_discovery_run,"
-                "不要再次保存或查询"
-            )
-    if audit_index < max(record_indexes):
-        return "最后一次搜索页保存尚未重新审计"
-    if evidence_indexes and audit_index < max(evidence_indexes):
-        return "最后一次证据获取尚未重新审计"
+        return "候选评估晚于最后一次审计,请重新调用 audit_video_discovery_run"
     if audit.get("can_finish") is not True:
     if audit.get("can_finish") is not True:
         violations = audit.get("critical_violations")
         violations = audit.get("critical_violations")
         if isinstance(violations, list) and violations:
         if isinstance(violations, list) and violations:
@@ -199,16 +193,8 @@ def find_agent_completion_guard(messages: list[Message]) -> str | None:
     if not state_events:
     if not state_events:
         return "尚未查询最终数据库状态"
         return "尚未查询最终数据库状态"
     state_index, state = state_events[-1]
     state_index, state = state_events[-1]
-    last_changed_evaluation_index = max(
-        (
-            index
-            for index, payload in evaluation_events
-            if payload.get("audit_relevant_changed") is not False
-        ),
-        default=evaluation_index,
-    )
-    if state_index < last_changed_evaluation_index:
-        return "最终数据库状态早于最后一次有效候选变更,请重新查询"
+    if state_index < audit_index:
+        return "最终状态查询必须在审计通过之后执行"
 
 
     last_assistant = next(
     last_assistant = next(
         (
         (
@@ -239,8 +225,7 @@ def create_find_agent(
         system_prompt=FIND_AGENT_SYSTEM_PROMPT,
         system_prompt=FIND_AGENT_SYSTEM_PROMPT,
         max_iterations=60,
         max_iterations=60,
         temperature=0.2,
         temperature=0.2,
-        # 暂停启用完成守卫;函数保留,便于后续按需恢复。
-        completion_guard=None,
+        completion_guard=find_agent_completion_guard,
         tool_call_budgets={
         tool_call_budgets={
             "search": (
             "search": (
                 {
                 {
@@ -263,12 +248,14 @@ def create_find_agent(
                 {"batch_save_video_candidate_evaluations"},
                 {"batch_save_video_candidate_evaluations"},
                 6,
                 6,
             ),
             ),
+            "audit": ({"audit_video_discovery_run"}, 4),
             "state_query": ({"query_video_discovery_state"}, 8),
             "state_query": ({"query_video_discovery_state"}, 8),
         },
         },
         tool_repeat_requires_change={
         tool_repeat_requires_change={
             "query_video_discovery_state": {
             "query_video_discovery_state": {
                 "record_video_search_page",
                 "record_video_search_page",
                 "batch_save_video_candidate_evaluations",
                 "batch_save_video_candidate_evaluations",
+                "audit_video_discovery_run",
             },
             },
         },
         },
     )
     )

+ 1 - 68
agents/find_agent/output_sync.py

@@ -1,15 +1,7 @@
-"""Persist the recommendation buckets stated by the model's final response."""
+"""Parse recommendation buckets from the model's final response."""
 from __future__ import annotations
 from __future__ import annotations
 
 
-import json
 import re
 import re
-from typing import Any
-
-from supply_agent.types import AgentResult, Role
-from supply_infra.db.repositories.video_discovery_repo import (
-    VideoDiscoveryRepository,
-)
-from supply_infra.db.session import get_session
 
 
 _VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
 _VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
 _PRIMARY_LABELS = ("主推荐", "正式推荐")
 _PRIMARY_LABELS = ("主推荐", "正式推荐")
@@ -58,62 +50,3 @@ def extract_model_recommendation_ids(
     return primary, [
     return primary, [
         aweme_id for aweme_id in rejected if aweme_id not in primary_set
         aweme_id for aweme_id in rejected if aweme_id not in primary_set
     ]
     ]
-
-
-def _latest_run_id(result: AgentResult) -> str | None:
-    for message in reversed(result.messages):
-        if message.role != Role.TOOL or not message.content:
-            continue
-        try:
-            payload = json.loads(message.content)
-        except (TypeError, json.JSONDecodeError):
-            continue
-        if not isinstance(payload, dict):
-            continue
-        run_id = payload.get("run_id")
-        if run_id:
-            return str(run_id)
-    return None
-
-
-def persist_model_recommendations(result: AgentResult) -> dict[str, Any]:
-    """
-    Make the model's final recommendation sections authoritative in the database.
-
-    The final response itself is never validated or rewritten. Video ids listed
-    under primary/rejected are persisted to those buckets exactly as stated.
-    """
-    run_id = _latest_run_id(result)
-    primary_ids, rejected_ids = extract_model_recommendation_ids(result.content)
-    if not run_id or not (primary_ids or rejected_ids):
-        return {
-            "run_id": run_id,
-            "primary_ids": primary_ids,
-            "rejected_ids": rejected_ids,
-            "saved_count": 0,
-        }
-
-    rows = [
-        {"aweme_id": aweme_id, "decision_bucket": "primary"}
-        for aweme_id in primary_ids
-    ]
-    rows.extend(
-        {"aweme_id": aweme_id, "decision_bucket": "rejected"}
-        for aweme_id in rejected_ids
-    )
-    with get_session() as session:
-        repo = VideoDiscoveryRepository(session)
-        if repo.get_run(run_id) is None:
-            return {
-                "run_id": run_id,
-                "primary_ids": primary_ids,
-                "rejected_ids": rejected_ids,
-                "saved_count": 0,
-            }
-        saved_count, _ = repo.save_candidate_evaluations(run_id, rows)
-    return {
-        "run_id": run_id,
-        "primary_ids": primary_ids,
-        "rejected_ids": rejected_ids,
-        "saved_count": saved_count,
-    }

+ 21 - 11
agents/find_agent/prompt/system_prompt.md

@@ -169,17 +169,18 @@
   `decision_bucket`。工具只接受 `primary / rejected`,不会重算分数或替你改池。
   `decision_bucket`。工具只接受 `primary / rejected`,不会重算分数或替你改池。
   每个候选至少传入 `aweme_id` 和你决定的 `decision_bucket`;其他证据、理由和分数
   每个候选至少传入 `aweme_id` 和你决定的 `decision_bucket`;其他证据、理由和分数
   尽量完整传入。
   尽量完整传入。
+- `audit_video_discovery_run`:候选评估完成后按 `run_id` 从数据库读取完整搜索和候选
+  状态,执行确定性完成审计。只有返回 `can_finish=true` 才能进入最终状态查询。
 - `query_video_discovery_state`:恢复长搜索的已探索关键词、翻页状态、主推荐和淘汰
 - `query_video_discovery_state`:恢复长搜索的已探索关键词、翻页状态、主推荐和淘汰
-  候选,也用于
-  查看已经保存的模型决定
+  候选,也用于查看已经保存的模型决定。结束前的最后一次查询必须发生在审计通过后,
+  最终报告只能依据这次查询结果生成
 
 
 优先让廉价证据淘汰没有主推荐价值的候选。详情与双画像用于仍可能进入主推荐的候选。
 优先让廉价证据淘汰没有主推荐价值的候选。详情与双画像用于仍可能进入主推荐的候选。
 所有工具失败都保留原始错误语义,不得编造缺失字段。
 所有工具失败都保留原始错误语义,不得编造缺失字段。
 
 
 若 `create_video_discovery_run` 明确返回数据库表未初始化或数据库不可用,只尝试一次:
 若 `create_video_discovery_run` 明确返回数据库表未初始化或数据库不可用,只尝试一次:
-之后在当前上下文中维护同样的 searches/candidates 结构,继续完成搜索和最终分池,
-不得因持久化失败放弃找片,也不得反复调用失败的存储工具。最终必须醒目标注“结果未
-持久化”及失败原因。其他数据库错误不应被假定为可忽略。
+保留原始错误且不得反复调用或假装完成。当前确定性完成控制要求持久化、数据库审计和
+最终状态查询全部成功;数据库不可用时不能输出已完成报告。
 
 
 # 成本与迭代预算
 # 成本与迭代预算
 
 
@@ -190,8 +191,8 @@
 - 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
 - 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
   不得依赖或声称使用了视频画面、语音、字幕解析;
   不得依赖或声称使用了视频画面、语音、字幕解析;
 - 执行新搜索、翻页、详情或画像后,应重新保存受影响候选;
 - 执行新搜索、翻页、详情或画像后,应重新保存受影响候选;
-- 不得用“接下来我会继续”作为最终回答。当前不依赖运行时完成守卫,Agent 必须自行
-  决定何时结束
+- 不得用“接下来我会继续”作为最终回答。运行时完成守卫会拒绝顺序不完整、审计未
+  通过或使用旧状态生成的最终报告
 - 数据库保留数量达到 5 条后,若没有明显更高价值的搜索前沿,优先结束任务。
 - 数据库保留数量达到 5 条后,若没有明显更高价值的搜索前沿,优先结束任务。
 - 保留数量不足 5 条时,优先继续有效的搜索、翻页或扩标签;合理前沿已经耗尽,或剩余
 - 保留数量不足 5 条时,优先继续有效的搜索、翻页或扩标签;合理前沿已经耗尽,或剩余
   候选明显不值得保留时,可以少于 5 条结束。
   候选明显不值得保留时,可以少于 5 条结束。
@@ -204,11 +205,20 @@
 
 
 硬性完成条件:
 硬性完成条件:
 
 
-- 数据库可用时,已创建发现运行且每个搜索页都已持久化;数据库不可用时,已在内存
-  结构中完整保留搜索页并在最终报告披露未持久化;
+- 已创建发现运行且每个搜索页都已持久化;
 - 推荐按联合价值排序,优先保证 5 条,可以超过 5 条;确实没有足够好视频时允许更少;
 - 推荐按联合价值排序,优先保证 5 条,可以超过 5 条;确实没有足够好视频时允许更少;
-- 数据库可用时,把你决定的候选分池和运行完成状态持久化;
-- 最终报告中的主推荐和淘汰候选,会按你的最终文字同步为对应数据库状态。
+- 已把候选证据、最终分池和运行完成状态持久化;
+- `audit_video_discovery_run` 返回 `can_finish=true`;
+- 审计通过后重新调用 `query_video_discovery_state`,最终报告与该状态中的
+  `primary / rejected` 完全一致。
+
+结束前必须按以下顺序完成最后一段流程:
+
+`最后搜索并保存搜索页 → 获取并整理证据 → 保存候选评估 → 数据库审计 → 最终状态查询 → 报告`
+
+如果审计后又发生搜索、证据获取或候选评估,原审计立即失效,必须从受影响阶段继续,
+重新审计并重新查询最终状态。最终状态查询必须晚于最后一次成功审计,报告之后不得再
+反向修改候选分池。
 
 
 以下探索项在不足 5 条时应优先执行;但它们只作为 warning,不把 5 条变成硬门槛:
 以下探索项在不足 5 条时应优先执行;但它们只作为 warning,不把 5 条变成硬门槛:
 
 

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

@@ -21,6 +21,7 @@ from agents.find_agent.tools.hotspot_profile import (
     get_content_fans_portrait,
     get_content_fans_portrait,
 )
 )
 from agents.find_agent.tools.video_discovery_store import (
 from agents.find_agent.tools.video_discovery_store import (
+    audit_video_discovery_run,
     batch_save_video_candidate_evaluations,
     batch_save_video_candidate_evaluations,
     create_video_discovery_run,
     create_video_discovery_run,
     query_video_discovery_state,
     query_video_discovery_state,
@@ -40,6 +41,7 @@ ALL_TOOLS: list[Callable[..., Any]] = [
     create_video_discovery_run,
     create_video_discovery_run,
     record_video_search_page,
     record_video_search_page,
     batch_save_video_candidate_evaluations,
     batch_save_video_candidate_evaluations,
+    audit_video_discovery_run,
     query_video_discovery_state,
     query_video_discovery_state,
 ]
 ]
 
 
@@ -56,6 +58,7 @@ __all__ = [
     "create_video_discovery_run",
     "create_video_discovery_run",
     "record_video_search_page",
     "record_video_search_page",
     "batch_save_video_candidate_evaluations",
     "batch_save_video_candidate_evaluations",
+    "audit_video_discovery_run",
     "query_video_discovery_state",
     "query_video_discovery_state",
     "register_all_tools",
     "register_all_tools",
 ]
 ]

+ 13 - 0
tests/supply_infra/scheduler/test_discover_videos_from_demands.py

@@ -5,6 +5,8 @@ from unittest.mock import patch
 
 
 import pytest
 import pytest
 
 
+from agents.find_agent import create_find_agent
+from agents.find_agent.agent import find_agent_completion_guard
 from agents.find_agent.async_runner import arun_find_agent
 from agents.find_agent.async_runner import arun_find_agent
 from supply_infra.db.models.video_discovery import (
 from supply_infra.db.models.video_discovery import (
     VideoDiscoveryCandidate,
     VideoDiscoveryCandidate,
@@ -27,6 +29,17 @@ def test_video_discovery_models_exclude_unused_columns() -> None:
     }.isdisjoint(candidate_columns)
     }.isdisjoint(candidate_columns)
 
 
 
 
+def test_find_agent_enables_deterministic_completion_control() -> None:
+    agent = create_find_agent()
+
+    assert "audit_video_discovery_run" in agent.tools.list_tools()
+    assert agent.completion_guard is find_agent_completion_guard
+    assert (
+        "audit_video_discovery_run"
+        in agent.tool_repeat_requires_change["query_video_discovery_state"]
+    )
+
+
 @patch(
 @patch(
     "supply_infra.scheduler.jobs.discover_videos_from_demands.process_single_discover"
     "supply_infra.scheduler.jobs.discover_videos_from_demands.process_single_discover"
 )
 )