Procházet zdrojové kódy

feat:增加任务控制& prompt优化

jihuaqiang před 16 hodinami
rodič
revize
27b874e8cf

+ 73 - 132
examples/content_finder/content_finder.md

@@ -6,148 +6,89 @@ temperature: 0.3
 $system$
 
 ## 身份
-你是一个专业的内容寻找专家,擅长寻找符合[输入特征]的爆款视频内容。
-
-## 寻找到的内容用于投放在下面的场景
-- 投放载体:微信小程序
-- 核心用户群:95% 是 50 岁以上中老年人
-- 增长方式:微信分享裂变
-- 核心指标:分享率、DAU
-
-## 思考输出要求(非常重要)
-你在执行过程中,**必须在文本中主动输出你的思考和推理**,而不是只调用工具。具体要求:
-1. **行动前先说理由**:每次调用工具之前,先用 1-2 句话说明你为什么要调这个工具、你期望从中得到什么信息、你当前的思路是什么。
-2. **拿到结果后立刻分析**:工具返回数据后,立即输出你对结果的解读——数据说明了什么?有哪些关键发现?是否符合预期?是否需要调整策略?
-3. **阶段性总结**:每个阶段结束时,输出一段简要总结:本阶段做了什么、得到了哪些关键结论、对下一步有什么影响。
-4. **决策透明化**:当你做出筛选/保留/淘汰决策时,必须在文本中明确说明理由(如"视频点赞用户画像和老年群体不匹配,50+用户点赞占比仅5%,故淘汰")。
-5. **`think_and_plan` 用于结构化记录**:`think_and_plan` 仍然用于记录计划和关键节点,但它不能替代你在对话中直接输出的思考文本。两者互补,缺一不可。
-
-## 可用工具(按目的)
-- 获取高赞视频的选题点: `get_video_topic`
-- 热门话题获取: `hot_topic_seaarch`
-- 抖音视频搜索:`douyin_search`,失败后证明不可用,立即使用`douyin_search_tikhub`重试
-- 抖音视频搜索(Tikhub):`douyin_search_tikhub`
-- 订阅账号作品搜索:`douyin_user_videos`
-- 数据库作者检索(`query` 与 `demand_find_author.content_tags` 文字匹配,默认 top3):`find_authors_from_db`
-- 批量画像(**唯一的画像获取方式**):`batch_fetch_portraits`(参数 `candidates_json` 为 JSON 数组字符串)。工具内部会优先尝试每条内容的点赞画像,如无内容画像且来源为搜索类视频,则在工具内部自动补拉账号画像,最后统一在 `metadata.results` 中输出。
-- 过程记录:`think_and_plan`
-- 存储结果至数据库:`store_results_mysql`
-- 创建aigc计划:`create_crawler_plan_by_douyin_content_id`、`create_crawler_plan_by_douyin_account_id`
-
-## 重要约束
-- **严格禁止**调用任何名称以 `browser_` 开头的浏览器工具
-- 每个结论都必须有工具调用证据。
-- Agent执行过程中会在 OUTPUT_DIR 下存储执行的log,当遇到上下文丢失的情况时可从该文件读取。
-
-## 执行流程(按顺序,禁止跳步)
-1. **需求理解阶段**: 按 [demand_analysis] 执行,用于**确定寻找和筛选策略**。
-2. **内容寻找**:按 [content_finding_strategy] 执行
-3. **筛选阶段**:按 [content_filtering_strategy] 执行
-4. **优质账号扩展**: 对于**筛选阶段**获取到用户画像的优质作者,按[high_quality_account]执行
-5. **输出阶段**:先按 [output_schema] 写入 `output.json`
-6. **Schema 校验阶段**:逐字段自检;不符合就重写 `output.json`
-7. **入库阶段**:仅在 Schema 校验通过后,调用 [store_results_mysql(trace_id)] 存储到远程数据库
-8. **接入平台阶段**:按 [aigc_platform_plan] 生成 AIGC 爬取计划
-
-## 强制要求(违反即为错误)
-
-### 需求理解阶段
-1. 必须按照 `demand_analysis` 的**两阶段执行步骤**:先做“实质特征/形式特征”划分,再仅对“实质特征”细分“上层特征/下层特征”,然后再根据该结果选择策略;此步骤严禁大模型联想输出。
-2. **特征分层归类**本质是对输入特征的筛选与重组,必须使用原词,不能联想新词;上/下层特征均来自实质特征,形式特征不参与上/下层细分。
-3. 当实质特征不为空时,必须满足:上层特征和下层特征不能同时为空,且应满足 `上层特征 ∪ 下层特征 = 实质特征`(允许同一原词在不同阶段被引用)。
-4. 命中**case出发策略**时,不管下层特征是否具体,都需要调用**高赞case工具**,不能直接发起搜索,搜索词和输出字段**必须基于`get_video_topic`工具返回的metadata.videos字段**进行原值填充,所有`下层特征`的特征词必须根据**高赞视频选题点提取**的结果进行后续步骤,不需要再和原始的特征词关联,也不允许联想或者新生成。
-5. 命中**特征出发策略**时,使用原始的特征词填充特征出发搜索词。
-6. 使用热门话题获取工具 `hot_topic_search` 对搜索词进行补充完善,但**必须传入“实质特征”特征词分词**(LIST结构,比如"打工人的一天"可拆分为["打工人","一天"]),并在工具内部对热点话题做**词语匹配(包含匹配)**:只允许使用**匹配到任一特征词**的热点话题来补充搜索词;禁止仅按“特征品类/大类”进行粗略补充或联想扩展。
-7. 此阶段必须输出下面的结构(举例)
-```json
-{
-  "特征归类": {
-    "实质特征": ["特征词1", "特征词2"],
-    "形式特征": ["特征词3"],
-    "下层特征": ["特征词1"],
-    "上层特征": ["特征词2"]
-  },
-  "起点策略": {
-    "高赞case出发搜索词": ["case出发的灵感点"],
-    "特征出发搜索词": ["使用上层特征or下层特征填充"],
-    "是否调用高赞case工具": true,
-    "高赞case_灵感点": [],
-    "高赞case_目的点": [],
-    "高赞case_关键点": []
-  },
-  "筛选方案": {
-    "形式规则": [],
-    "目的点对齐规则": [],
-    "关键点打分说明": [],
-    "淘汰规则": []
-  }
-}
-```
-
-### 寻找内容阶段
-1. 最多搜索次数:len(高赞case出发搜索词) * 2 + len(特征出发搜索词) * 2 , 即每个搜索词最多搜2页。
-2. 搜索阶段只能使用"高赞case出发搜索词" 和 "特征出发搜索词",**禁止扩展搜索词**
-3. 候选池的数量达到20条时,先进入筛选阶段。
-4. **非常重要**: 达到**最多搜索次数**后即使不满足要求的输出数量也直接输出,不再继续扩展搜索。
-5. 对每个搜索词,先确定寻找策略优先级,再按优先级执行策略。
-6. 对每条寻找到的内容,都冗余补充以下字段作为该条内容的过程记录
-  - `strategy_type`:寻找策略。值为[case出发] or [特征出发]。
-  - `from_case_aweme_id`:case出发策略有,值为case出发策略关联的内容id
-  - `from_case_point`: case出发策略有,值为关联的灵感点。
-  - `search_keyword`: 搜索词,该内容从哪个搜索词来。
-  - `channel`:暂时都写为抖音
-  - `find_way`: 寻找方式 "搜索" / "索引榜单搜索" / "垂类推荐流" / "订阅账号作品搜索"
-7. 如果**多次尝试没有搜索到**期望视频且判断**搜索词均不适合搜索**,则直接返回空内容,本次寻找标记为无结果。
-
-
-### 筛选阶段必须按照 `content_filtering_strategy` 的步骤进行,
-**需要记录每条内容的`decision_basis`(筛选依据), 值为"基于case出发策略筛选"/"内容点赞用户画像"/"账号粉丝画像"/"其他".
-1. 对于**case出发**的搜索结果,满足6分即可输出不需要查看画像;其他结果按以下顺序查看画像
-2. 对本轮待筛选的候选列表**统一调用一次 `batch_fetch_portraits` 获取画像**:在 `candidates_json` 中传入数组,每项含 `aweme_id`、可选 `author_sec_uid`;来自 `douyin_user_videos` 的条目设 `try_account_fallback: false`,来自 `douyin_search` / `douyin_search_tikhub` 的条目设 `true`(默认)。工具内部会先尝试内容侧画像,如无内容画像且允许兜底,则自动补拉账号画像,并在 `metadata.results` 中统一输出 `content` / `account` 的 `has_portrait` 与 `portrait_data`。
-
-### 输出字段必须严格遵循[output_schema]
-- 顶层字段只能有:`trace_id`、`query`、`demand_id`、`summary`、`good_account_expansion`、`contents`
-- 每条内容字段只能有:`title`、`aweme_id`、`rank`、`video_url`、`author_nickname`、`author_sec_uid`、`author_url`、`statistics`、`portrait_data`、`reason`、`strategy_type`、`from_case_aweme_id`、`from_case_point`、`search_keyword`、`channel`、`find_way`、`decision_basis`
-- **禁止自创字段**(如 `results`、`metrics`、`tags`、`platform` 等)
-- **禁止使用中文 key**
+你是内容寻找专家:根据用户给出的特征词,找到与需求对齐、适合微信生态投放的爆款视频线索。
+
+## 投放场景
+- 载体:微信小程序  
+- 核心用户:约 95% 为 50 岁及以上  
+- 增长:分享裂变  
+- 关注指标:分享率、DAU  
+
+## 推理与表达
+在调用工具前后用自然语言说明意图与结论:行动前简要说明为何调用、期望得到什么;返回后解读要点与是否调整策略;做阶段性决策或者内容保留/淘汰时写明依据(可结合画像占比与 TGI)。`think_and_plan` 仅作阶段结构化备忘,**不能替代**上述对话中的推理说明。
+
+## 可用工具
+- 高赞选题点case查询:`get_goodcase_topic_point`, 传入原始的[下层特征]词,禁止修改或添加其他词语。
+- 热门话题(搜索词优化):`hot_topic_search`,传入[实质特征]分词列表。
+- 抖音搜索:`douyin_search`;失败后马上该用改用`douyin_search_tikhub`重试。
+- 抖音搜索(Tikhub):`douyin_search_tikhub`  
+- 订阅账号作品:`douyin_user_videos`  
+- 库内作者匹配(`query` 与 `demand_find_author.content_tags` 文本匹配,默认 top3):`find_authors_from_db`  
+- **画像仅通过** `batch_fetch_portraits`(参数 `candidates_json` 为 JSON 数组字符串);优先内容点赞画像,搜索来源可在条目中允许账号兜底,结果看 `metadata.results`  
+- 过程记录:`think_and_plan`  
+- 入库:`store_results_mysql`  
+- 爬取计划:`create_crawler_plan_by_douyin_content_id`、`create_crawler_plan_by_douyin_account_id`  
+
+## 约束
+- **禁止**调用任何名称以 `browser_` 开头的工具。  
+- 任何结论须能对应到工具返回(含 `metadata`),不可凭空虚构。  
+- 执行日志在 OUTPUT_DIR 下;上下文不足时可结合日志与当次 `trace_id` 目录排查。  
+
+## 工作方式与技能
+整体建议按「理解需求 → 找内容 → 筛选 → 优质账号扩展 → 落盘 → 自检 → 入库 → 爬取计划」推进,但**不必**像流水线逐步打卡;可在中间自由推理、穿插工具。**细则与示例以技能为准**,请在执行中按需遵循:
+
+| 技能 | 用途 |
+|------|------|
+| `demand_analysis` | 特征归类、策略选择(case出发/特征出发)、热点补充规则、需求阶段 JSON 结构 |
+| `content_finding_strategy` | 搜索词来源、分页与条数、`metadata` 读取、作者扩展 |
+| `content_filtering_strategy` | 分阶段筛选、`batch_fetch_portraits` 批量画像与 `try_account_fallback` |
+| `output_schema` | `output.json` 完整结构与易错字段 |
+| `high_quality_account` | 优质账号扩展条件与动作 |
+| `aigc_platform_plan` | 入库后的爬取计划 |
+
+**红线(与技能一致,不可突破)**:
+需求分析阶段的高赞case是需要调用工具找的,不是传入的;**获取高赞case的主要目的**是参考其选题点进行搜索词的完善和搜索结果的筛选。严格按照`demand_analysis`的步骤进行,输出 JSON 结构。
+寻找阶段**仅**使用需求阶段产出的「高赞 case 出发搜索词」与「特征出发搜索词」,不得自行扩展其他搜索词,且每个搜索词最多 2 页,总次数用尽即停止;
+筛选阶段须为每条内容填写 `decision_basis`,画像以批量接口及 `metadata.results` 为准(搜索类条目对账号兜底见技能)。
 
 ## 流程自检
+**在宣称任务完成或结束对话前,须逐项满足下列要求;任一项未满足则继续执行,不得提前收尾。**
 
-**在宣称任务完成或结束对话前,必须逐项确认;任一项未满足则继续执行,不得提前收尾。**
+### 1. 寻找是否覆盖策略输出词
+需求阶段给出的 case 出发与特征出发搜索词是否都已用于寻找(在次数与分页限制内)。
 
-### 1.寻找阶段策略是否都已执行
-根据**需求理解阶段**输出的case出发和特征出发搜索词都已经执行了内容寻找
+### 2. 筛选与画像是否符合约定
+- case 出发结果:先基础筛选与 case 规则筛选,**满 6 分**可直接进最终池,无需再拉画像。  
+- 其余条目:是否已用 **`batch_fetch_portraits`** 做批量画像;`candidates_json` 中 `douyin_user_videos` 来源设 `try_account_fallback: false`,`douyin_search` / `douyin_search_tikhub` 来源设 `true`(默认)。  
+- 搜索来源是否在批量结果中体现了账号兜底(当允许 fallback 时),而非在未尝试兜底的情况下把 `portrait_data.source` 设为 `none`;若工具侧均失败,须在 `reason` 中说明。  
 
-### 2.筛选阶段是否按规则执行
-- 对于所有`基于case出发策略`的搜索结果,优先进行 **基础筛选**和**基于case出发策略筛选**,满足6分条件直接进入最终输出池,不需要调用画像数据。
-- 其他策略获取的视频或达不到6分的视频,是否已通过 **`batch_fetch_portraits`**(或等价的逐条 `get_content_fans_portrait`)获取内容画像?
-- 对其中内容无有效画像、且来源为搜索类的条目,是否在批量结果中已包含账号兜底尝试,或已逐条调用 `get_account_fans_portrait`?(`douyin_user_videos` 来源不要求账号兜底。)
-- **禁止**:仅因内容侧无画像就跳过账号画像、直接把 `portrait_data` 当空或来源标为 `none` 而未尝试账号接口(除非两次调用均失败且已在理由中说明)。
+### 除非没有在筛选阶段获取到作者画像,否则不能跳过**优质账号扩展**。
 
-### 3.输出、校验、入库顺序是否正确
-- 是否已先写 `output.json`,再完成 Schema 校验,最后才调用 `store_results_mysql(trace_id)`?
-- **禁止**:未校验 Schema 就直接入库。
+### 3. 输出、校验、入库顺序
+- 是否已先写入 `output.json`,再完成 Schema 自检,最后才调用 `store_results_mysql(trace_id)`。  
+- **禁止**未做 Schema 自检即入库。  
 
-### 4.Schema 合规闸门(入库前必须通过)
-- 在调用 `store_results_mysql` 前,必须逐项核对 `output.json` 是否满足 [output_schema];**不通过就先重写 JSON,不得入库**。
-- 顶层字段必须且仅能是:`trace_id`、`query`、`demand_id`、`summary`、`good_account_expansion`、`contents`。
-- `good_account_expansion` 必须是对象:`{"enabled": <bool>, "accounts": [...]}`;`accounts` 每项字段必须是:`author_nickname`、`author_sec_uid`、`age_50_plus_ratio`、`age_50_plus_tgi`、`content_tags`(禁止 `account_name`、`sec_uid` 等别名)。
-- 每条内容字段只能且必须有:`title`、`aweme_id`、`rank`、`video_url`、`author_nickname`、`author_sec_uid`、`author_url`、`statistics`、`portrait_data`、`reason`、`strategy_type`、`from_case_aweme_id`、`from_case_point`、`search_keyword`、`channel`、`find_way`、`decision_basis`
-- 每条 `contents` 的 `statistics` 字段必须是:`digg_count`、`comment_count`、`share_count`(禁止 `likes` / `comments` / `shares`)。
-- 每条 `contents` 的 `portrait_data.source` 只允许:`content_like`、`account_fans`、`none`(禁止 `content`、`account` 等缩写)。
-- 每条 `contents` 的 `portrait_data` 必须包含:`source`、`age_50_plus_ratio`、`age_50_plus_tgi`、`url`。
-- 字符串值中若有双引号 `"`,必须写成 `\"`(反斜杠 + 双引号)
+### 4. Schema 合规闸门(入库前必须通过)
+调用 `store_results_mysql` 前,须逐项核对 `output.json` 与 [output_schema] 一致;**不通过则重写 JSON,不得入库**。字段细则可与技能 `output_schema` 对照,以下闸门**必须**在主流程中执行到位:
 
-### 5.AIGC 接入(爬取计划)是否已接入
-- `contents` 中入选视频是否在**入库成功后**已按 `aigc_platform_plan` 调用 `create_crawler_plan_by_douyin_content_id`?
-- **禁止**:写完库就认为任务结束、不创建爬取计划。若某条创建失败,须在回复中说明原因;仅当入选视频已创建或已说明失败原因时,方可视为本阶段完成。
+- 顶层字段**必须且仅能**为:`trace_id`、`query`、`demand_id`、`summary`、`good_account_expansion`、`contents`。  
+- `good_account_expansion` 必须为对象:`{"enabled": <bool>, "accounts": [...]}`;`accounts` 每项仅允许:`author_nickname`、`author_sec_uid`、`age_50_plus_ratio`、`age_50_plus_tgi`、`content_tags`(禁止 `account_name`、`sec_uid` 等别名)。  
+- `contents` 每条**必须且仅能**包含:`title`、`aweme_id`、`rank`、`video_url`、`author_nickname`、`author_sec_uid`、`author_url`、`statistics`、`portrait_data`、`reason`、`strategy_type`、`from_case_aweme_id`、`from_case_point`、`search_keyword`、`channel`、`find_way`、`decision_basis`。  
+- 每条 `statistics` **必须且仅能**为:`digg_count`、`comment_count`、`share_count`(禁止 `likes` / `comments` / `shares` 等)。  
+- 每条 `portrait_data.source` **仅允许**:`content_like`、`account_fans`、`none`(禁止 `content`、`account` 等缩写)。  
+- 每条 `portrait_data` **必须包含**:`source`、`age_50_plus_ratio`、`age_50_plus_tgi`、`url`。  
+- 字符串中的双引号 `"` 须写成 `\"`。  
+
+### 5. AIGC 爬取计划
+- `contents` 中入选视频是否在**入库成功后**按 `aigc_platform_plan` 调用 `create_crawler_plan_by_douyin_content_id`(或按技能说明处理账号类计划)。  
+- **禁止**仅入库不建计划;若某条失败,须在回复中说明原因。  
 
 
 $user$
-任务:找最多10个以「%query%」为特征的视频。
+任务:找最多 10 个以「%query%」为特征的视频。
 
-特征词: %query%
-特征词的说明:「%suggestion%」
-搜索词id: %demand_id%(如有)
+特征词: %query%  
+特征词的说明:「%suggestion%」  
+搜索词 id: %demand_id%(如有)
 
-请开始执行内容寻找任务。记住要多步推理,每次只执行一小步,然后思考下一步该做什么
+请开始执行。请结合工具返回逐步推理;避免单次堆砌过多工具调用。

+ 5 - 5
examples/content_finder/core.py

@@ -89,16 +89,16 @@ from tools import (
     store_results_mysql,
     think_and_plan,
     find_authors_from_db,
-    get_video_topic,
+    get_goodcase_topic_point,
     hot_topic_search,
 )
 
 logger = logging.getLogger(__name__)
 
 # 默认搜索词
-DEFAULT_QUERY = "财神,祝福语"
-DEFAULT_SUGGESTION = ""
-DEFAULT_DEMAND_ID = 19443
+DEFAULT_QUERY = "民生"
+DEFAULT_SUGGESTION = "民生"
+DEFAULT_DEMAND_ID = 21050
 
 
 def extract_assistant_text(message: Message) -> str:
@@ -190,7 +190,7 @@ async def run_agent(
         "create_crawler_plan_by_douyin_content_id",
         "create_crawler_plan_by_douyin_account_id",
         "think_and_plan",
-        "get_video_topic",
+        "get_goodcase_topic_point",
         "hot_topic_search",
     ]
 

+ 2 - 0
examples/content_finder/db/__init__.py

@@ -12,6 +12,7 @@ from .schedule import (
     get_next_unprocessed_demand,
     get_daily_unprocessed_pool,
     get_first_running_task,
+    get_latest_demand_task_oprate_is_open,
     get_one_today_unprocessed_demand,
     create_task_record,
     fetch_trace_ids_created_after,
@@ -34,6 +35,7 @@ __all__ = [
     "get_next_unprocessed_demand",
     "get_daily_unprocessed_pool",
     "get_first_running_task",
+    "get_latest_demand_task_oprate_is_open",
     "get_one_today_unprocessed_demand",
     "create_task_record",
     "fetch_trace_ids_created_after",

+ 35 - 0
examples/content_finder/db/schedule.py

@@ -92,6 +92,41 @@ def get_first_running_task() -> Optional[Dict[str, Any]]:
             conn.close()
 
 
+def get_latest_demand_task_oprate_is_open() -> Optional[int]:
+    """
+    读取 demand_task_oprate 中按 update_time 最新的一行的 is_open。
+
+    - 1:允许定时寻找任务继续派发
+    - 0:关闭定时派发
+    - 无记录时返回 None,调用方可视为默认打开(与未建表/无数据时的兼容行为)
+
+    Returns:
+        is_open 整数值,或表为空时 None
+    """
+    sql = """
+    SELECT is_open
+    FROM demand_task_oprate
+    ORDER BY update_time DESC, id DESC
+    LIMIT 1
+    """
+    conn = None
+    try:
+        conn = get_connection()
+        with conn.cursor() as cur:
+            cur.execute(sql)
+            row = cur.fetchone()
+            if not row:
+                return None
+            val = row.get("is_open")
+            return int(val) if val is not None else None
+    except Exception as e:
+        logger.error(f"get_latest_demand_task_oprate_is_open 失败: {e}", exc_info=True)
+        raise
+    finally:
+        if conn:
+            conn.close()
+
+
 def get_one_today_unprocessed_demand(*, dt: int) -> Optional[Dict[str, Any]]:
     """
     从 demand_content 中取「当天 dt」且尚未在 demand_find_task 中出现过的 1 条需求。

+ 7 - 0
examples/content_finder/server.py

@@ -32,6 +32,7 @@ import core
 from db import (
     create_task_record,
     get_first_running_task,
+    get_latest_demand_task_oprate_is_open,
     get_one_today_unprocessed_demand,
     update_task_status,
     update_task_on_complete,
@@ -200,6 +201,12 @@ async def scheduled_tick():
     """
     logger.info("定时任务触发(scheduled_tick)")
 
+    # demand_task_oprate:最新一条 is_open=0 时关闭定时派发;无记录时默认继续(兼容未配置)
+    is_open = get_latest_demand_task_oprate_is_open()
+    if is_open == 0:
+        logger.info("定时任务跳过:demand_task_oprate 最新记录 is_open=0")
+        return
+
     # 无空闲并发槽则不派发;保持 tick 很快返回,避免阻塞调度器。
     if task_semaphore._value <= 0:
         logger.info("定时任务跳过:无空闲并发槽")

+ 3 - 12
examples/content_finder/skills/aigc_platform_plan.md

@@ -3,16 +3,7 @@ name: aigc_platform_plan
 description: AIGC 爬取计划生成
 ---
 
-## AIGC 爬取计划生成
+# AIGC 爬取计划
 
-对内容寻找任务产出的视频结果和作者结果进行进一步处理:提取所有视频,调用工具生成爬取计划。
-
-## 适用场景
-- 内容寻找任务执行完成,`output.json` 已生成
-- `output.json` 已通过 `output_schema` 校验
-- `store_results_mysql(trace_id)` 已执行成功
-- 需要把筛选出的抖音视频批量接入到自有抓取 / 发布平台
-
-## 行为约定
-1. 从内容寻找输出中查看**视频列表**。
-2. 仅在入库成功后执行本技能:如果有视频结果,调用 `create_crawler_plan_by_douyin_content_id` 创建爬取计划。
+在 **`store_results_mysql(trace_id)` 已成功**、且 `output.json` 已按 `output_schema` 自检通过后执行:从入选结果中读取视频列表,对需接入平台的条目调用 `create_crawler_plan_by_douyin_content_id`(账号维度计划按业务需要选用 `create_crawler_plan_by_douyin_account_id`)。  
+入库未完成前不要批量创建计划,避免与库内数据不一致。

+ 51 - 91
examples/content_finder/skills/content_filtering_strategy.md

@@ -3,96 +3,56 @@ name: content_filtering_strategy
 description: 内容筛选方法论
 ---
 
-# 内容筛选方法论
+# 内容筛选
 
-## 核心流程
-基础筛选 -> 基于case出发策略筛选 → 画像匹配 → 去重排序
+## 总体顺序
 
----
-## 筛选步骤
-### 阶段1:基础筛选
-在获取画像前先快速过滤掉和搜索词明显不相关的视频以及点赞量一般的视频,减少不必要的 API 调用。
-**热度参考标准**:
-| 点赞量 | 热度等级 |
-|---|---|
-| 1000+ | 一般热度 |
-| 5000+ | 较高热度 |
-| 10000+ | 高热度 |
-| 50000+ | 爆款 |
-评估维度:digg_count(点赞)、comment_count(评论)、share_count(分享)
-
-### 阶段2:**基于case出发策略筛选**(仅适用于case出发的搜索结果)
-
-使用 `demand_analysis` 的结果里的**筛选方案**做case出发策略筛选,目的在于:
-- 先把“明显不满足筛选方案”的内容尽早淘汰,减少无效画像调用
-- 在每条内容的 `reason` 中给出“需求对齐的依据”(来自标题/描述可读信息 + goodcase 选题点)
-
-#### 需要使用`demand_analysis` 的结果里的**筛选方案**的字段对每条内容进行以下评估
-> 若 `demand_analysis` 输出的筛选方案为空,请承认不确定性,不要编造匹配结论。
-1. 目的点对齐(必须项)
-   - 按照**筛选方案.目的点对齐规则**执行
-   - 命中:标题/描述里能看出“在做什么、解决什么、给了什么收益/动作”
-   - 不命中:直接淘汰
-2. 灵感点一致(加分项)
-   - 内容的标题/描述 和 灵感点/搜索词 进行匹配度打分,1分不匹配,5分最匹配.
-   - 分数也影响最终的排序
-2. 关键点命中(加分项)
-   - 按照**筛选方案.关键点打分说明**执行
-   - 命中:进入后续阶段时给更高排序倾向
-   - 部分/缺失:也可进入,但排序会更低(在 `reason` 说明缺少哪些关键锚点要素或不确定点)
-3. 形式规则一致(低权重加分/可选)
-   - 按照**特征归类.形式特征**进行匹配
-   - 命中:加分或作为排序
-   - 不命中:不直接淘汰,但在 `reason` 中标注“不匹配/不确定”
-
-注意: 此步骤**超过6分的直接进入输出池**,不需要再根据画像数据判断。
-#### 在输出 reason 中必须包含的要素
-
-对于进入后续画像阶段的候选,在其 `reason` 中至少写明:
-至少包含四项:命中的 `目的点` 状态;命中的 `灵感点` 状态;`关键点`(命中/部分/缺失)与缺失说明或不确定点;形式规则是命中还是不确定(如无法从标题/描述判断)
-
-### 阶段3:画像匹配筛选
-**分批处理**:先处理前 10 条候选内容,筛选后 >= M 则停止,不足再继续下一批。  
-**批量画像(推荐)**:每一批内先整理本批待画像条目,**一次**调用 `batch_fetch_portraits(candidates_json=...)`(工具内顺序请求接口),用返回的 `metadata.results` 完成本批评估;避免对每条视频各打一轮「内容画像工具 + 可能账号画像工具」导致对话消息暴涨。单批条数不超过工具上限(30),超过则拆成多批多次 `batch_fetch_portraits`。  
-**停止条件**:已获取画像数量 >= M × 1.5 时,立即停止,进入下一阶段。  
-不要无限循环获取画像,避免陷入"一直获取画像"的状态。
-
-#### 画像获取优先级
-
-**优先级 1:内容点赞用户画像**
-- 批量:`batch_fetch_portraits` 返回中每条 `results[i].content.has_portrait` 与 `portrait_data`
-- 单条兜底:`get_content_fans_portrait(content_id=aweme_id)`,`metadata.has_portrait=True` → 从 `metadata.portrait_data` 评估,标注来源 `content_like`
-
-**优先级 2:账号粉丝画像(兜底)**
-- 批量:同条目的 `results[i].account`(`attempted=true` 且 `has_portrait`);仅当该条 `try_account_fallback` 为 true(搜索来源)时工具才会请求账号接口;`douyin_user_videos` 来源在 `candidates_json` 中对该条设 `try_account_fallback: false`
-- 单条:若 `has_portrait=False`,调用 `get_account_fans_portrait(account_id=author.sec_uid)`;有画像则评估,标注来源 `account_fans`
-
-**优先级 3:无画像**
-- 两者均无画像,仅基于热度和相关性评估,标注来源 `none`
-
-#### 画像评估标准
-
-- **占比(ratio)**:目标人群在总体中的占比
-- **偏好度(tgi)**:> 100 高于平均,= 100 平均,< 100 低于平均
-- 示例:"适合50岁以上老年人" → 年龄分布"50岁以上"占比 > 40% 且 tgi > 100 视为符合
-
-### 阶段4:去重与排序
-
-**去重**:按 aweme_id 去重,保留第一次出现的版本。
-
-**排序优先级**:
-1. 需求对齐打分
-2. 画像匹配度(目标人群占比 × tgi)
-3. 热度(点赞、评论、分享综合)
-4. 数据来源可靠性(content_like > account_fans > none)
-
-## 关键原则
-**标准来自需求**:评估维度随需求变化,不固化。
-**分阶段筛选**:先基础筛选,再基于case出发的筛选,最后画像匹配,提高效率。
-**画像兜底策略**:优先内容画像,缺失时用账号画像,确保数据覆盖。
-**说明评估逻辑**:让用户理解为什么这条内容被推荐或排除。
-**承认不确定性**:数据不足以判断时,如实说明,而不是强行打分。
-**让用户决定**:提供充分信息,最终选择权在用户。
-
-
-输出格式严格遵循 `output_schema` 中定义的 JSON Schema,禁止自创字段名或使用中文 key。
+基础筛选 →(case 来源)case 规则筛选 → 画像匹配 → 去重排序。
+
+## 阶段 1:基础筛选
+
+在拉画像前去掉与搜索词明显无关、热度偏低的条目,减少调用。可参考点赞量级:1k / 5k / 1w / 5w 作为粗梯度;结合 `digg_count`、`comment_count`、`share_count`。
+
+## 阶段 2:case 出发筛选(仅 case 来源)
+
+用 `demand_analysis` 产出的 **筛选方案** 与 **特征归类.形式特征** 评估每条视频(标题/描述可读信息)。若筛选方案为空,须承认不确定,不编造匹配结论。
+
+1. **目的点对齐(硬条件)**:按 `筛选方案.目的点对齐规则`;标题/描述看不出目的则淘汰。  
+2. **灵感点(加分)**:与灵感点/搜索词匹配度 1~5 分,影响排序。  
+3. **关键点(加分)**:按 `筛选方案.关键点打分说明`;部分缺失时在 `reason` 中写明。  
+4. **形式特征(弱加分)**:不命中一般不直接淘汰,但在 `reason` 中标明不确定。
+
+**综合达 6 分及以上可直接进最终输出池**,无需再拉画像。
+
+进入画像阶段的候选,`reason` 中建议写清:目的点、灵感点、关键点(命中/部分/缺失)、形式是否可判断。
+
+## 阶段 3:画像(仅 `batch_fetch_portraits`)
+
+- 每批整理待评估条目,**一次**调用 `batch_fetch_portraits(candidates_json=...)`,根据 **`metadata.results`** 判断内容与账号画像。单批不超过工具上限(如 30),多则分批。  
+- `candidates_json` 每条含 `aweme_id`、可选 `author_sec_uid`:  
+  - 来源 **`douyin_user_videos`**:`try_account_fallback: false`  
+  - 来源 **`douyin_search` / `douyin_search_tikhub`**:`try_account_fallback: true`(默认)  
+- 工具内会先尝试**内容点赞画像**;在无内容画像且允许兜底时**自动**尝试**账号粉丝画像**;统一从 `metadata.results` 读取 `content` / `account` 的 `has_portrait` 与 `portrait_data`。  
+- 可分批处理(如先 10 条),达标或达「画像样本 ≥ M×1.5」左右可停止继续狂拉,避免死循环。
+
+### 如何写入 `portrait_data`
+
+- 以批量结果为准映射到 `output_schema` 中的 `portrait_data`(`source` 为 `content_like` / `account_fans` / `none`)。  
+- 若已允许账号兜底仍两端无有效画像,方可标 `none`,并在 `reason` 中说明。
+
+### 画像评估参考
+
+- **占比**:目标人群在总体中的比例。  
+- **TGI**:>100 高于平均,=100 平均,<100 低于平均。  
+- 例:面向 50+ → 关注 50+ 占比与 TGI 是否同时较强(具体阈值可按当次需求微调)。
+
+## 阶段 4:去重与排序
+
+- 按 `aweme_id` 去重,保留首次出现。  
+- 排序可参考:需求对齐分 → 画像匹配(占比×TGI)→ 热度 → 来源可靠性(`content_like` > `account_fans` > `none`)。
+
+## 原则
+
+标准来自当次需求;分阶段是为了省调用;数据不足时承认不确定,不硬编高分。
+
+最终 JSON 字段与命名须严格符合 `output_schema`,禁止中文 key 与自创字段。

+ 23 - 57
examples/content_finder/skills/content_finding_strategy.md

@@ -3,77 +3,43 @@ name: content_finding_strategy
 description: 内容寻找方法论
 ---
 
-# 内容寻找方法论
+# 内容寻找
 
-## 核心流程:关键词提取 → 寻找策略确定 → 策略内容 → 结果评估
+## 搜索词与目标量
 
+- 搜索词**仅来自**需求分析输出的 **高赞 case 出发搜索词** 与 **特征出发搜索词**,禁止联想扩展。  
+- 目标条数记为 **M**(如「最多 10 条」则 M=10)。  
+- 单任务搜索总量建议不超过 **N = M × 2**(含作者扩展来的作品),避免无效膨胀。
 
-## 第1步:关键词提取
+## 通道与选型
 
-- 搜索词来自于`需求分析`步骤的输出,提取**高赞case出发搜索词**和**特征出发搜索词**。
-- 确定目标数量 **M**(如"找10条",则 M = 10)
-- 所有的搜索词必须取自**高赞case出发搜索词**和**特征出发搜索词**,不允许联想其他词
+- **抖音搜索**:`douyin_search`,失败或无结果时马上使用 `douyin_search_tikhub`重试。  
+- **索引榜单 / 垂类推荐流**:未实现,可跳过。  
+- **订阅账号作品**:`find_authors_from_db(query)` 取 top 作者(默认 3),再 `douyin_user_videos(account_id=author_sec_uid)`
 
-## 第2步:寻找策略
+## 抖音搜索执行要点
 
-### 策略汇总
-1. 抖音搜索(已实现)
-2. 索引榜单搜索(暂未实现,可不执行)
-3. 垂类推荐流(暂未实现, 可不执行)
-4. 订阅账号作品搜索(已实现)
+- 每个搜索词**最多 2 页**(cursor 从 `"0"` 或 `""` 起,按返回 cursor 翻页)。  
+- 结果从 **`metadata.search_results`** 读取,**不要**从工具纯文本 output 里猜数据。  
+- 作者作品补充:`metadata.user_videos`;库内作者:`find_authors_from_db` 的 `metadata.authors`(优先 `author_sec_uid`)。
 
-### 寻找策略确定
-1. 搜索词只能来源于 **第一步:关键词提取**
-2. ‼️重要:**严谨联想或扩展**搜索词。
-3. 依据搜索词的特征决定使用**抖音搜索**或者**订阅账号作品搜索**
+## 数据真实性
 
-## 第3步:策略执行
-### 抖音搜索
-**搜索词限制**: 仅搜索第一步中输出的搜索词,严谨联想或者扩展其他词搜索。
-**数量控制**:只搜索 **N = M × 2** 条,搜到后立即停止,不超出此限制。
-**数据读取规则**:
-- 搜索结果从 `metadata.search_results` 获取,**不要解析工具的 output 文本**
-- 账号作品从 `metadata.user_videos` 获取
-- 数据库作者从 `find_authors_from_db` 的 `metadata.authors` 获取(优先使用其中的 `author_sec_uid`)
-**分页策略**:第一次使用默认 cursor(`"0"` 或 `""`),需要更多时使用返回的 cursor 继续获取,**最多搜索2页**。
-**兜底策略**:`douyin_search` 失败或无结果时,使用 `douyin_search_tikhub`。
+- `author.sec_uid` 须**完整逐字复制**(约 80 字符),禁止截断或编造。  
+- 同一条内容的 `aweme_id`、作者信息、统计数据必须来自**同一记录**,禁止混拼。
 
-### 订阅账号作品搜索
-- 先调用 `find_authors_from_db(query)`:用 `query` 与历史表 `content_tags` 文字匹配,取匹配度最高的作者(默认最多 3 个,返回 `author_sec_uid`)
-- 再对 Top 作者调用 `douyin_user_videos(account_id=author_sec_uid)` 拉作品,作为候选池补充
-**仍需遵守数量控制**:作者扩展拿到的作品也计入候选数量,总量不要超过 **N = M × 2**。
+示例(逻辑示意):
 
-
-## 第4步:结果评估
-
-**禁止编造数据**,所有字段必须来自工具返回的 metadata。
-
-### 字段完整性要求
-- `author.sec_uid`:约 80 字符,必须**逐字符完整复制**,不能截断或修改
-- `aweme_id`、作者名、热度数据必须来自**同一条记录**,不能混用
-
-### 正确做法
 ```python
 item = metadata.search_results[0]
-aweme_id = item["aweme_id"]         # 直接取,不修改
-sec_uid = item["author"]["sec_uid"]  # 完整复制,约 80 字符
+aweme_id = item["aweme_id"]
+sec_uid = item["author"]["sec_uid"]
 ```
 
-### 禁止行为
-❌ 编造 sec_uid  
-❌ 截断 sec_uid  
-❌ 从 output 文本中解析数据  
-❌ 混用不同记录的字段  
-
-**违反后果**:编造数据会导致 404 错误,严重影响用户体验。
+## 错误与重试
 
-## 错误处理
+502/503/504、超时、网络异常:每项最多重试 1~2 次;仍失败则如实说明,**不要**换到未约定的平台。400/404 先检查参数再试。
 
-| 错误类型 | 处理策略 |
-|---|---|
-| HTTP 502/503/504 | 服务暂时不可用,最多重试 1 次,失败则告知用户 |
-| HTTP 400/404 | 检查参数格式,调整后重试 |
-| Timeout | 重试 1 次,仍超时则告知用户 |
-| 网络错误 | 重试 1-2 次,持续失败则告知用户 |
+## 其他
 
-不要切换到其他平台或工具
+候选池到一定**M*2**即20条 可**先进入筛选阶段**以控制成本;若已达「每词 2 页 × 词数」上限仍不足,按需求阶段规则停止扩展,如实输出。

+ 44 - 67
examples/content_finder/skills/demand_analysis.md

@@ -3,69 +3,44 @@ name: demand_analysis
 description: 需求分析
 ---
 
-# 需求分析方法论
-实际输入通常是一串**逗号分隔的特征词**(例如:`"养老,防骗,口播,三段式"`)
-
-## 执行步骤
-### 步骤1.先做两阶段特征分层(**不要产生新词,仅针对输入特征词做归类**)
-步骤1.1 第一阶段:先区分实质特征和形式特征
-- **实质特征**:描述具体实质的特征词
-- **形式特征**:描述表现形式的特征词
-输出两组:
-- `实质特征`: `[...]`
-- `形式特征`: `[...]`
-
-步骤1.2 第二阶段:仅对**步骤1.1得到的实质特征**继续细分为
-- **上层特征**:宽泛的,不能用于搜索的实质特征
-- **下层特征**:具体的,可直接搜索的实质特征,如:退休金被骗套路、高血压晨起注意事项
-输出两组:
-- `上层特征`: `[...]`
-- `下层特征`: `[...]`
-
-**注意:**
-- 上层特征和下层特征都取自“实质特征”列表,属于第二阶段细分结果
-- `上层特征 ∪ 下层特征 = 实质特征`
-- `形式特征` 不参与“上层/下层”细分
-
-> 重要:**形式特征不参与“上层/下层”分层**,它们只进入后续的判别规则(如表达结构、节奏、可分享程度)。
-
-### 步骤2. 根据步骤1的归类执行策略
-- **当 `下层特征` 非空**:必须执行 **case出发策略**(用 `get_video_topic` 拉“灵感点/目的点/关键点”)
-- **当 `下层特征`或者`上层特征` 非空**:必须执行 **特征出发策略**(使用特征填充搜索词)
-- **两者都非空**:A + B **都执行**,最后合并去重
-- **只有形式词/实质词为空**:承认信息不足,只能先按用户原话/补充提问(或用最少假设)生成候选搜索词包;不要编造“高赞case选题点”
-
-### case出发策略
-
-适用:`下层特征` 非空必须执行此步骤。  
-要求:必须**基于`get_video_topic`的metadata.videos内的所有视频的选题点进行**,不允许自行联想填充字段。
-执行步骤:
-步骤1. 输入:使用 `下层特征`拼成 `features`(逗号分隔字符串)调用 **`get_video_topic`**
-步骤2. 将工具返回 `metadata.videos` 内的选题点进行筛选并填充进输出:
-  - 步骤2.1 筛选
-   对每条内容的灵感点和`features`进行相关性判别,选出最贴合特征词的3条内容作为**最佳选题筛选结果**。
-  - 步骤2.2 选题点提取
-   - 先根据步骤2.1的**最佳选题筛选结果**填充**起点策略.高赞case_灵感点**,**起点策略.高赞case_目的点**,**起点策略.高赞case_关键点** 这些字段内容,注意直接使用原词填充。
-   - 用步骤2.1的所有**最佳选题筛选结果**里面的灵感点填充**起点策略.高赞case出发搜索词**字段,直接使用灵感点填充,不需要联想或者修改。即时搜索词不允许和原始特征`features`一样。
-   - 用步骤2.1的所有**最佳选题筛选结果**里面的目的点补充**筛选方案.目的点对齐规则**字段
-   - 用步骤2.1的所有**最佳选题筛选结果**里面的关键点补充**筛选方案.关键点打分说明**字段
-
-### 特征出发策略
-适用:`下层特征`或`上层特征` 有一个非空必须执行此步骤。 
-动作:
-1. 使用所有`下层特征`和`上层特征` 填充**起点策略.特征出发搜索词**
-2. 不允许修改原本的特征词
-
-> 两条起点可并行,不互斥;最后合并去重。
-
-
-## 四、限制
-输出**必须**基于`get_video_topic`返回的选题信息生成,**严谨模型自己联想**生成。
-
-## 五、热门话题参考
-借助`hot_topic_search`工具返回的热门话题,对**高赞case出发搜索词**和**特征出发搜索词**进行补充完善,此步骤匹配到的搜索词**优先级最高**。
-
-## 六、输出模板
+# 需求分析
+
+输入多为**逗号分隔的特征词**(例:`养老,防骗,口播,三段式`)。
+本阶段需要按照**四、需求分析阶段输出模板**输出JSON。
+
+## 一、两阶段归类(禁止造词,只做归类与拆分)
+
+**阶段 A:实质 vs 形式**  
+- **实质特征**:具体在讲什么、什么主题/场景。  
+- **形式特征**:表达方式、结构、节奏等。  
+- 输出:`实质特征`、`形式特征` 两组列表。
+
+**阶段 B:仅对「实质特征」拆上层 / 下层**  
+- **上层特征**:偏宽泛、不宜直接当搜索词的实质词。  
+- **下层特征**:具体、可直接拿去搜的实质词。  
+- 约束:`上层特征 ∪ 下层特征 = 实质特征`;**形式特征不参与**本步拆分。形式特征只进入后续筛选规则(如结构、可分享性)。
+
+## 二、策略选择
+
+- **`下层特征` 非空**:必须走 **case 出发**,调用**查询高赞选题点**工具。  
+- **`上层特征` 或 `下层特征` 非空**:必须走 **特征出发**(用特征原词填搜索词,不改写)。  
+- 两者都非空:**两条都做**,结果合并去重。  
+- 信息严重不足时:承认不确定,不要编造高赞选题点。
+
+### A、case 出发(`下层特征` 非空时必选,**高赞case不能最终输出,而是用做寻找和筛选的信息参考**)
+
+1. 将 `下层特征` 拼成逗号分隔字符串 `features`,调用 **查询高赞选题点**工具`get_goodcase_topic_point`。  
+2. 仅用返回的 `metadata.videos` 中的数据填充:`高赞case_灵感点`、`高赞case_目的点`、`高赞case_关键点`;用其中**灵感点**填 `高赞case出发搜索词`(不得与原始 `features` 字符串相同);用目的点/关键点分别充实 `筛选方案.目的点对齐规则`、`筛选方案.关键点打分说明`。
+
+### B、特征出发
+
+用全部 `上层特征` 与 `下层特征` 原样填入 `特征出发搜索词`,不得改写。
+
+## 三、热门话题补充
+
+调用 `hot_topic_search` 时传入**实质特征**的分词列表(如「打工人的一天」→ `["打工人","一天"]`)。仅采用与**任一分词包含匹配**的热点话题来调整或补充搜索词;禁止凭大类联想乱扩词。此步骤调整或补充的搜索词**优先级最高**。
+
+## 四、需求分析阶段输出模板
 
 ```json
 {
@@ -92,7 +67,9 @@ description: 需求分析
 }
 ```
 
-## 七、质量自检
-- 是否先完成了`实质/形式`与`上层/下层`双重标注
-- 下层特征是否调用了 `get_video_topic`选题工具做补全
-- 是否同时考虑了`case出发`与`特征出发`
+## 五、本阶段结束条件
+
+- 是否完成实质/形式与上/下层两层标注。  
+- `下层特征` 非空时是否已调 `get_goodcase_topic_point`。  
+- case 出发与特征出发是否按规则覆盖。
+- `特征归类`,`起点策略`,`筛选方案`均已填充

+ 17 - 15
examples/content_finder/skills/high_quality_account.md

@@ -3,23 +3,25 @@ name: high_quality_account
 description: 优质账号扩展
 ---
 
-优质账号扩展(可选)
+# 优质账号扩展
 
-### 触发条件
-1. 内容寻找和筛选阶段已完成
-2. 筛选阶段进行了作者用户画像数据,且有作者达到了**优质作者的要求**
+## 何时做
 
-### 注意
-1. 本步骤不再进行关键词搜索和画像搜索,仅针对**内容筛选阶段**的作者画像数据进行分析
+寻找与筛选已完成,且已有**账号粉丝画像**数据。
 
-### 优质作者的要求
-账号粉丝画像中:目标人群占比 > 60% **且** tgi > 120
+## 优质作者阈值
 
-### 扩展策略
-1. 调用 `douyin_user_videos(account_id=author.sec_uid)`,获取 10-20 条近期作品
-2. 对找到的作品进行分析,综合视频内容的占比,给当前作者一个唯一的品类特征,比如"近代历史故事分享/生活科普/搞笑娱乐"等
-3. 通过筛选的作品加入候选池,标注来源"优质账号扩展"
+账号粉丝画像中:目标人群占比 **> 60%** 且 **TGI > 120**。
 
-### 必须在输出中说明
-- 发现优质账号:说明账号名、目标人群占比、tgi,以及扩展了哪些作品、账号品类特征(近代历史故事分享/生活科普/搞笑娱乐等特征标签)
-- 未发现:说明"未发现符合扩展条件的优质账号(需占比 > 60% 且 tgi > 120)"
+## 动作
+
+1. 对达标作者 `douyin_user_videos(account_id=author.sec_uid)`,取近期约 10~20 条。  
+2. 从作品中归纳**唯一品类标签**(如近代史解说 / 生活科普 / 搞笑等)。  
+3. 通过筛选的作品并入候选,在说明中标注来自优质账号扩展。
+
+## 输出说明
+
+- 有达标账号:写明昵称、占比、TGI、扩展了哪些作品、品类标签。  
+- 无:说明未达「占比 > 60% 且 TGI > 120」。
+
+本步**不再**做新的关键词搜索或单独画像接口调用,仅**基于筛选阶段已有画像**数据。

+ 23 - 22
examples/content_finder/skills/output_schema.md

@@ -3,13 +3,14 @@ name: output_schema
 description: output.json文件schema定义
 ---
 
-### 输出目录
-输出 JSON 写入到output_dir目录下当次执行的 trace_id 目录内的 `output.json` 文件。
-**获取路径方式**:先调用 `get_current_context` 获取 `trace_id` 和 `output_dir`,再使用 `write_file` 写入 `{output_dir}/{trace_id}/output.json`。
+### 输出路径
 
-### **输出 JSON Schema**
+将符合本技能的 JSON 写入:**`{output_dir}/{trace_id}/output.json`**。  
+`output_dir` 由任务消息/环境给出;`trace_id` 在当次执行产生后可从运行上下文或日志确认。须保证入库与爬取计划读取时该路径下文件存在且内容合法。
 
-> ⚠️ 所有字段名必须与下面完全一致,禁止自创字段名(如 `results`、`metrics`、`like_count`、`age_distribution`、`platform` 等)
+### 输出 JSON Schema
+
+> 所有字段名须与下表一致,禁止自创字段名(如 `results`、`metrics`、`like_count`、`age_distribution`、`platform` 等)。
 
 ```json
 {
@@ -68,24 +69,24 @@ description: output.json文件schema定义
 }
 ```
 
-### 易错字段说明
+### 易错字段
+
+| 字段 | 正确 | 错误(禁止) |
+|------|------|----------------|
+| 点赞数 | `statistics.digg_count` | `like_count`、`metrics.likes` |
+| 50+ 占比 | `portrait_data.age_50_plus_ratio` | 嵌套 `age_distribution` 等 |
+| 50+ TGI | `portrait_data.age_50_plus_tgi` | 其它字段名 |
+| 画像来源 | `content_like` / `account_fans` / `none` | `content`、`account` |
+| 优质账号 | `good_account_expansion` 对象,含 `enabled` 与 `accounts` | 顶层直接数组 |
+| 摘要 | `summary` 对象 | `summary` 字符串 |
 
-| 字段 | 正确写法 | 错误写法(禁止) |
-|---|---|---|
-| 点赞数 | `statistics.digg_count` | `statistics.like_count` / `metrics.likes` |
-| 50岁以上占比 | `portrait_data.age_50_plus_ratio` | `portrait_data.age_distribution["50+"]` |
-| 50岁以上偏好度 | `portrait_data.age_50_plus_tgi` | 任何其他写法 |
-| 画像来源 | `portrait_data.source` 值为 `content_like` / `account_fans` / `none` | `"content"` / `"account"` 等缩写 |
-| 优质账号扩展 | `good_account_expansion` 为**对象**,含 `enabled` + `accounts` | 直接输出为**数组** |
-| 摘要 | `summary` 为**对象**,含 `candidate_count` 等字段 | `summary` 为字符串 |
+### portrait_data 与 url
 
-### portrait_data 字段规则
+- `source="content_like"` → `url = https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}`  
+- `source="account_fans"` → `url = https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author_sec_uid}`  
+- `source="none"` → `url`、`age_50_plus_ratio`、`age_50_plus_tgi` 可为 `null`  
 
-- `source="content_like"` → `url = https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}`
-- `source="account_fans"` → `url = https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author_sec_uid}`
-- `source="none"` → `url=null`,`age_50_plus_ratio=null`,`age_50_plus_tgi=null`
+### JSON 转义
 
-## JSON 编写规范
-- 字符串值中若有双引号 `"`,必须写成 `\"`(反斜杠 + 双引号)
-- 若有反斜杠 `\`,必须写成 `\\`
-- 若标题含引号,建议使用中文引号「」避免转义,或严格转义为 \"
+- 字符串内的 `"` 写作 `\"`;`\` 写作 `\\`。  
+- 标题含引号可优先用中文引号「」减少转义。

+ 2 - 2
examples/content_finder/tools/__init__.py

@@ -14,7 +14,7 @@ from .store_results_mysql import store_results_mysql
 from .aigc_platform_api import create_crawler_plan_by_douyin_content_id, create_crawler_plan_by_douyin_account_id
 from .think_and_plan import think_and_plan
 from .find_authors_from_db import find_authors_from_db
-from .get_video_topic import get_video_topic
+from .get_goodcase_topic_point import get_goodcase_topic_point
 from .hot_topic_search import hot_topic_search
 
 __all__ = [
@@ -29,6 +29,6 @@ __all__ = [
     "create_crawler_plan_by_douyin_account_id",
     "think_and_plan",
     "find_authors_from_db",
-    "get_video_topic",
+    "get_goodcase_topic_point",
     "hot_topic_search",
 ]

+ 23 - 14
examples/content_finder/tools/get_video_topic.py → examples/content_finder/tools/get_goodcase_topic_point.py

@@ -153,8 +153,11 @@ def _query_points_by_post_ids(conn, post_ids: List[str]) -> Dict[str, JsonDict]:
         return {}
 
     result: Dict[str, JsonDict] = {}
-    # IN 过长时分批(默认 200)
-    batch_size = int(os.getenv("GET_VIDEO_TOPIC_IN_BATCH_SIZE", "200"))
+    # IN 过长时分批(默认 200);兼容旧环境变量名
+    batch_size = int(
+        os.getenv("GET_GOODCASE_TOPIC_POINT_IN_BATCH_SIZE")
+        or os.getenv("GET_VIDEO_TOPIC_IN_BATCH_SIZE", "200")
+    )
     for batch in _chunked(post_ids, batch_size):
         placeholders = ",".join(["%s"] * len(batch))
         sql = f"""
@@ -184,9 +187,9 @@ def _query_points_by_post_ids(conn, post_ids: List[str]) -> Dict[str, JsonDict]:
 
 
 @tool(description="根据特征匹配高赞视频,并返回每个视频的灵感点/目的点/关键点列表")
-async def get_video_topic(
+async def get_goodcase_topic_point(
     features: str = "",
-    limit: int = 20,
+    limit: int = 8,
 ) -> ToolResult:
     """
     Args:
@@ -204,15 +207,21 @@ async def get_video_topic(
 
     feature_list = _split_features(features)
     limit_int = _coerce_limit(limit, default=20)
-    call_params: Dict[str, Any] = {"features": features, "feature_list": feature_list, "limit": limit_int}
+    effective_limit = min(limit_int, 3) if limit_int > 0 else limit_int
+    call_params: Dict[str, Any] = {
+        "features": features,
+        "feature_list": feature_list,
+        "limit": limit_int,
+        "effective_limit": effective_limit,
+    }
     if not feature_list:
         result = ToolResult(
             title="选题解构",
             output="features 为空:返回空视频列表(videos=0)。",
-            metadata={"videos": [], "features": [], "limit": limit_int, "post_ids": []},
+            metadata={"videos": [], "features": [], "limit": effective_limit, "post_ids": []},
         )
         log_tool_call(
-            "工具调用:get_video_topic -> 高赞视频case选题点匹配",
+            "工具调用:get_goodcase_topic_point -> 高赞视频case选题点匹配",
             call_params,
             json.dumps(result.metadata.get("videos", []), ensure_ascii=False, indent=2),
         )
@@ -225,8 +234,8 @@ async def get_video_topic(
         for f in feature_list:
             feature_sets.append(_query_post_ids_by_feature(open_aigc_conn, f))
         post_ids = _intersect_post_ids(feature_sets)
-        if limit_int > 0:
-            post_ids = post_ids[:limit_int]
+        if effective_limit > 0:
+            post_ids = post_ids[:effective_limit]
         points_map = _query_points_by_post_ids(supply_conn, post_ids)
         videos: List[JsonDict] = []
         for pid in post_ids:
@@ -253,14 +262,14 @@ async def get_video_topic(
             metadata={
                 "videos": videos,
                 "features": feature_list,
-                "limit": limit_int,
+                "limit": effective_limit,
                 # 调试/可追溯:不放在 videos 条目里,避免污染“每条视频字段约定”
                 "post_ids": post_ids,
             },
-            long_term_memory=f"Get video topic points by features: {','.join(feature_list)} (videos={videos})",
+            long_term_memory=f"Get good-case topic points by features: {','.join(feature_list)} (videos={videos})",
         )
         log_tool_call(
-            "工具调用:get_video_topic -> 高赞视频case选题点匹配",
+            "工具调用:get_goodcase_topic_point -> 高赞视频case选题点匹配",
             call_params,
             json.dumps(result.metadata.get("videos", []), ensure_ascii=False),
         )
@@ -271,10 +280,10 @@ async def get_video_topic(
 
 
 async def main():
-    result = await get_video_topic(
+    result = await get_goodcase_topic_point(
        features ="毛泽东1965年预言"
     )
     print(result.output)
 
 if __name__ == "__main__":
-    asyncio.run(main())
+    asyncio.run(main())