Kaynağa Gözat

feat: Prompt优化

jihuaqiang 1 gün önce
ebeveyn
işleme
59b8fad3b6

+ 51 - 78
examples/content_finder/content_finder.prompt

@@ -8,90 +8,63 @@ $system$
 
 
 ## 重要约束
 ## 重要约束
 - 只在抖音平台搜索,不要切换到其他平台(小红书、B站等)
 - 只在抖音平台搜索,不要切换到其他平台(小红书、B站等)
-- 只使用 douyin_search、douyin_user_videos、get_content_fans_portrait、get_account_fans_portrait、store_results_mysql 这4个工具
-- 不要使用浏览器工具、或其他平台的搜索工具
-- **严格禁止调用任何名称以browser_开头的浏览器工具**(调用任何一个都是错误行为):
+- 可用工具:`douyin_search`、`douyin_user_videos`、`get_content_fans_portrait`、`get_account_fans_portrait`、`store_results_mysql`、`create_crawler_plan_by_douyin_content_id`、`create_crawler_plan_by_douyin_account_id`
+- **严格禁止**调用任何名称以 `browser_` 开头的浏览器工具
 
 
-## 平台背景(仅供参考):
+## 平台背景
 - 平台载体:微信小程序
 - 平台载体:微信小程序
 - 核心用户群:95% 是 50 岁以上中老年人
 - 核心用户群:95% 是 50 岁以上中老年人
 - 增长方式:微信分享裂变
 - 增长方式:微信分享裂变
 - 核心指标:分享率、DAU
 - 核心指标:分享率、DAU
 
 
-## 核心数据使用策略
-
-### 工具调用结果数据优先提取原则
-- **搜索结果**:调用 douyin_search 后,从 metadata.search_results 获取,不要解析 工具的output
-- **账号作品**:调用 douyin_user_videos 后,从 metadata.user_videos 获取数据
-- **画像判断**:使用 `metadata.has_portrait` 字段(True=有画像,False=无画像)
-- **画像数据**:从 `metadata.portrait_data` 获取结构化数据
-
-### 关键字段说明
-- `author.sec_uid`:约80字符,必须完整复制,不能截断
-- `aweme_id`:视频ID,用于内容链接和画像查询
-- `statistics`:包含 digg_count(点赞)、comment_count(评论)、share_count(分享)
-
-## 热度参考标准
-
-抖音视频点赞量参考:
-- 1000+: 一般热度
-- 5000+: 较高热度
-- 10000+: 高热度
-- 50000+: 爆款
-
-## 工具组合策略
-
-### 画像获取流程(必须执行)
-1. 对每条候选内容,先调用 `get_content_fans_portrait` 获取点赞用户画像
-2. 检查 `metadata.has_portrait` 判断是否有有效画像
-3. 如果 `has_portrait=False`,调用 `get_account_fans_portrait` 获取账号粉丝画像作为兜底
-4. 在结果中明确标注数据来源("内容点赞画像"或"账号粉丝画像")
-
-### 优质账号扩展(必须执行)
-- 如果账号粉丝画像中,目标人群占比 > 60% 且 tgi > 120
-- 调用 `douyin_user_videos` 获取该账号的 5-10 条作品
-- 对扩展作品做基础筛选(热度、相关性)
-- 在输出时说明是否发现优质账号及扩展情况
-
-### Token 管理(必须遵守)
-- **搜索控制**:用户要求 M 条内容,只搜索 N = M × 2 条,搜索到后立即停止
-- **分批处理**:先处理 10 条候选内容,不足再继续下一批
-- **工具调用限制**:每次最多并行调用 3 个画像工具
-- **画像获取完成标准**:获取画像后立即进入筛选阶段,不要继续搜索新内容
-
-## 数据真实性要求(严格遵守)
-**禁止编造数据**:这是最严重的错误,会导致 404 错误和用户体验问题。
-
-### 最终结果存储至远程数据库(必须执行)
-- 使用 store_results_mysql tool工具进行存储
-
-## 最终输出要求
-最终输出必须严格遵循 Skills 中「输出结果指南」要求的目录和结构。
-
-## 接入AIGC平台
-Skills 中的「AIGC 爬取计划生成」用于将寻找的结果接入AIGC平台。
-
-## 任务完成要求
-- 搜索 M × 2 条内容后,立即停止搜索
-- 对所有搜索到的内容获取画像后,立即进入筛选阶段
-- 筛选完成后,立即输出完整的推荐结果
-- 最终输出必须严格遵循 Skills 中「输出结果指南」,所有的key都必须严格按照schema的约定
-- 输出已写入到 %output_dir% 目录下当次执行的trace_id目录内的output.json文件。
-- 检查output.json里面文件的格式和 Skills 中「输出结果指南」中的格式完全一致。
-- 输出已经存储到远程数据库中。
-- 输出结果已经接入AIGC平台。
-- 输出完整的推荐结果后,任务会自动进行反思和知识保存
-- 反思完成后,输出简短的完成确认:✅ 任务完成!已为您找到 [数量] 条视频,并保存了执行经验
-
-请按照 content_finding_strategy 和 content_filtering_strategy 中的方法论执行任务。
-
-**关键提醒**:
-- 不要陷入”一直获取画像”的循环
-- 获取足够画像后,立即进入筛选和输出阶段
-- 必须输出最终推荐结果,不能在中途停止
-- 所有数据必须源于 TOOLS 返回,禁止捏造不存在的视频
-- 最终输出必须严格遵循 Skills 中「输出结果指南」,禁止自创/变体字段名或使用中文 key
-- 输出文件的保存地址严格按照要求,在 %output_dir% 目录下当次执行的trace_id目录内的output.json文件,不能随意放置。
+## 执行流程(按顺序,禁止跳步)
+1. **搜索阶段**:按 `content_finding_strategy` 执行
+2. **筛选阶段**:按 `content_filtering_strategy` 执行
+3. **输出阶段**:先按 `output_schema` 写入 `output.json`
+4. **Schema 校验阶段**:逐字段自检;不符合就重写 `output.json`
+5. **入库阶段**:仅在 Schema 校验通过后,调用 `store_results_mysql(trace_id)` 存储到远程数据库
+6. **接入平台阶段**:最后按 `aigc_platform_plan` 生成 AIGC 爬取计划
+
+## 强制要求(违反即为错误)
+
+### 画像工具必须调用
+对每条候选内容,**必须**按以下顺序获取画像:
+1. 先调用 `get_content_fans_portrait`,检查 `metadata.has_portrait`
+2. 若 `has_portrait=False`,再调用 `get_account_fans_portrait` 兜底
+3. **不允许跳过画像获取直接输出**
+
+### 输出字段必须严格遵循 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`
+- **禁止自创字段**(如 `results`、`metrics`、`tags`、`platform` 等)
+- **禁止使用中文 key**
+
+## 流程自检
+
+**在宣称任务完成或结束对话前,必须逐项确认;任一项未满足则继续执行,不得提前收尾。**
+
+### 1.画像(内容 + 账号)是否已获取
+- 对**最终写入 `contents` 的每一条**视频,是否都已调用过 `get_content_fans_portrait(aweme_id)`?
+- 对其中 `metadata.has_portrait=False` 的条目,是否**在同一条目上**已调用 `get_account_fans_portrait(account_id=author.sec_uid)` 作为兜底?
+- **禁止**:仅因内容侧无画像就跳过账号画像、直接把 `portrait_data` 当空或来源标为 `none` 而未尝试账号接口(除非两次调用均失败且已在理由中说明)。
+
+### 输出、校验、入库顺序是否正确
+- 是否已先写 `output.json`,再完成 Schema 校验,最后才调用 `store_results_mysql(trace_id)`?
+- **禁止**:未校验 Schema 就直接入库。
+
+### Schema 合规闸门(入库前必须通过)
+- 在调用 `store_results_mysql` 前,必须逐项核对 `output.json` 是否满足 `output_schema`;**不通过就先重写 JSON,不得入库**。
+- 顶层字段必须且仅能是:`trace_id`、`query`、`demand_id`、`summary`、`good_account_expansion`、`contents`。
+- `summary` 必须是对象,且包含:`candidate_count`、`portrait_content_like_count`、`portrait_account_fans_count`、`portrait_none_count`、`filtered_in_count`(禁止用字符串 summary)。
+- `good_account_expansion` 必须是对象:`{"enabled": <bool>, "accounts": [...]}`;`accounts` 每项字段必须是:`author_nickname`、`author_sec_uid`、`age_50_plus_ratio`、`age_50_plus_tgi`(禁止 `account_name`、`sec_uid` 等别名)。
+- 每条 `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`。
+
+### AIGC 接入(爬取计划)是否已接入
+- `contents` 中入选视频是否在**入库成功后**已按 `aigc_platform_plan` 调用 `create_crawler_plan_by_douyin_content_id`?
+- **禁止**:写完库就认为任务结束、不创建爬取计划。若某条创建失败,须在回复中说明原因;仅当入选视频已创建或已说明失败原因时,方可视为本阶段完成。
+
 
 
 $user$
 $user$
 任务:找10个与「%query%」相关的、老年人感兴趣的视频。
 任务:找10个与「%query%」相关的、老年人感兴趣的视频。

+ 2 - 2
examples/content_finder/core.py

@@ -41,8 +41,8 @@ from tools import (
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 # 默认搜索词
 # 默认搜索词
-DEFAULT_QUERY = "毛泽东1965年深秋预言"
-DEFAULT_DEMAND_ID = 2629
+DEFAULT_QUERY = "戏曲表演"
+DEFAULT_DEMAND_ID = 1
 
 
 
 
 async def run_agent(
 async def run_agent(

+ 35 - 16
examples/content_finder/db/store_results.py

@@ -13,14 +13,20 @@ def upsert_good_authors(
 ) -> int:
 ) -> int:
     """
     """
     将 good_account_expansion 中的 accounts 写入 demand_find_author 表。
     将 good_account_expansion 中的 accounts 写入 demand_find_author 表。
+    兼容两种格式:
+      - 标准格式:{"enabled": true, "accounts": [...]}
+      - 降级格式:直接传 list(agent 未严格遵守 schema 时的兜底)
     """
     """
     if not good_account_block:
     if not good_account_block:
         return 0
         return 0
 
 
-    if not good_account_block.get("enabled"):
-        return 0
+    if isinstance(good_account_block, list):
+        accounts: List[Dict[str, Any]] = good_account_block
+    else:
+        if not good_account_block.get("enabled"):
+            return 0
+        accounts = good_account_block.get("accounts") or []
 
 
-    accounts: List[Dict[str, Any]] = good_account_block.get("accounts") or []
     if not accounts:
     if not accounts:
         return 0
         return 0
 
 
@@ -35,12 +41,17 @@ def upsert_good_authors(
     with conn.cursor() as cur:
     with conn.cursor() as cur:
         rows = 0
         rows = 0
         for acc in accounts:
         for acc in accounts:
-            author_name = acc.get("author_nickname") or ""
+            # 与 output_schema 一致:author_nickname / author_sec_uid / author_url
+            # 兼容 Agent 常用别名:account_name、sec_uid(见 good_account_expansion 数组简写)
+            author_name = (
+                acc.get("author_nickname")
+                or acc.get("account_name")
+                or ""
+            )
             author_link = acc.get("author_url") or ""
             author_link = acc.get("author_url") or ""
-            if not author_name or not author_link:
-                sec_uid = acc.get("author_sec_uid")
-                if sec_uid and not author_link:
-                    author_link = f"https://www.douyin.com/user/{sec_uid}"
+            sec_uid = acc.get("author_sec_uid") or acc.get("sec_uid")
+            if not author_link and sec_uid:
+                author_link = f"https://www.douyin.com/user/{sec_uid}"
             if not author_name or not author_link:
             if not author_name or not author_link:
                 continue
                 continue
 
 
@@ -77,12 +88,12 @@ def insert_contents(
 
 
     sql = """
     sql = """
     INSERT INTO demand_find_content_result (
     INSERT INTO demand_find_content_result (
-      trace_id, query, rank_no, aweme_id, video_url, title, author_name, author_link,
+      trace_id, query, rank_no, aweme_id, video_url, title, author_name, author_id, author_link,
       digg_count, comment_count, share_count,
       digg_count, comment_count, share_count,
       portrait_source, elderly_ratio, elderly_tgi, recommendation_reason,
       portrait_source, elderly_ratio, elderly_tgi, recommendation_reason,
       demand_content_id
       demand_content_id
     ) VALUES (
     ) VALUES (
-      %s, %s, %s, %s, %s, %s, %s, %s,
+      %s, %s, %s, %s, %s, %s, %s, %s, %s,
       %s, %s, %s,
       %s, %s, %s,
       %s, %s, %s, %s,
       %s, %s, %s, %s,
       %s
       %s
@@ -92,6 +103,12 @@ def insert_contents(
         rows = 0
         rows = 0
         for item in contents:
         for item in contents:
             video_url = item.get("video_url") or ""
             video_url = item.get("video_url") or ""
+            stats = item.get("statistics") or {}
+            portrait = item.get("portrait_data") or {}
+            # age_distribution 是 agent 有时输出的非标准结构,兜底提取 50+ 占比
+            age_dist = portrait.get("age_distribution") or {}
+            age_50_plus_ratio = portrait.get("age_50_plus_ratio") or age_dist.get("50+") or ""
+            age_50_plus_tgi = portrait.get("age_50_plus_tgi") or ""
             cur.execute(
             cur.execute(
                 sql,
                 sql,
                 (
                 (
@@ -102,13 +119,15 @@ def insert_contents(
                     video_url,
                     video_url,
                     item.get("title") or "",
                     item.get("title") or "",
                     item.get("author_nickname") or "",
                     item.get("author_nickname") or "",
+                    item.get("author_sec_uid") or "",
                     item.get("author_url") or "",
                     item.get("author_url") or "",
-                    int(item.get("statistics", {}).get("digg_count") or 0),
-                    int(item.get("statistics", {}).get("comment_count") or 0),
-                    int(item.get("statistics", {}).get("share_count") or 0),
-                    item.get("portrait_data").get("source") or "",
-                    str(item.get("portrait_data").get("age_50_plus_ratio")  or ""),
-                    str(item.get("portrait_data").get("age_50_plus_tgi") or ""),
+                    # like_count 是 agent 有时输出的非标准字段名,兜底处理
+                    int(stats.get("digg_count") or stats.get("like_count") or 0),
+                    int(stats.get("comment_count") or 0),
+                    int(stats.get("share_count") or 0),
+                    portrait.get("source") or "",
+                    str(age_50_plus_ratio) if age_50_plus_ratio != "" else "",
+                    str(age_50_plus_tgi) if age_50_plus_tgi != "" else "",
                     item.get("reason") or "",
                     item.get("reason") or "",
                     demand_content_id,
                     demand_content_id,
                 ),
                 ),

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

@@ -9,8 +9,10 @@ description: AIGC 爬取计划生成
 
 
 ## 适用场景
 ## 适用场景
 - 内容寻找任务执行完成,`output.json` 已生成
 - 内容寻找任务执行完成,`output.json` 已生成
+- `output.json` 已通过 `output_schema` 校验
+- `store_results_mysql(trace_id)` 已执行成功
 - 需要把筛选出的抖音视频批量接入到自有抓取 / 发布平台
 - 需要把筛选出的抖音视频批量接入到自有抓取 / 发布平台
 
 
 ## 行为约定
 ## 行为约定
 1. 从内容寻找输出中查看**视频列表**。
 1. 从内容寻找输出中查看**视频列表**。
-2. 如果有视频结果,先调用`get_current_context` 获取 `trace_id`,再调用 `create_crawler_plan_by_douyin_content_id` 创建爬取计划。
+2. 仅在入库成功后执行本技能:如果有视频结果,调用 `create_crawler_plan_by_douyin_content_id` 创建爬取计划。

+ 55 - 172
examples/content_finder/skills/content_filtering_strategy.md

@@ -1,214 +1,97 @@
-# 内容筛选方法论
-
-## 核心方法:分阶段筛选 → 画像兜底 → 分层输出
-
-本方案采用结构化的筛选流程,明确每个阶段的评估标准和执行顺序。
-
 ---
 ---
-
-## 完整筛选流程
-
-### 阶段一:基础质量筛选
-
-在获取画像数据前,先进行快速的基础筛选,减少不必要的 API 调用。
-
-#### 热度评估
-
-**量化标准**(可根据需求调整):
-- 1000+: 一般热度
-- 5000+: 较高热度
-- 10000+: 高热度
-- 50000+: 爆款
-
-**评估维度**:
-- 点赞数(digg_count)
-- 评论数(comment_count)
-- 分享数(share_count)
-
-**筛选策略**:
-- 根据用户需求设定最低热度门槛
-- 如果用户未明确要求,保持宽松标准
-
-#### 相关性评估
-
-**评估依据**:
-- 内容描述(desc)是否包含关键词
-- 内容主题是否与需求相关
-- 明显不相关的内容直接过滤
-
-**输出**:保留通过基础筛选的候选内容列表
-
+name: content_filtering_strategy
+description: 内容筛选方法论
 ---
 ---
 
 
-### 阶段二:画像匹配筛选
-
-对通过基础筛选的内容,进行精细的画像匹配评估。
-
-#### 画像数据获取策略
+# 内容筛选方法论
 
 
-**优先级1:内容点赞用户画像**
-- 调用 `get_content_fans_portrait(content_id=aweme_id)`
-- 检查返回的 metadata.has_portrait 字段
-- 如果 has_portrait 为 True:
-  - 从 metadata.portrait_data 中获取结构化画像数据
-  - 评估是否符合目标人群
-  - 在结果中标注"数据来源:内容点赞画像"
-
-**优先级2:账号粉丝画像(兜底)**
-- 如果 metadata.has_portrait 为 False(画像数据缺失)
-- 调用 `get_account_fans_portrait(account_id=author.sec_uid)`
-- 检查返回的 metadata.has_portrait 字段
-- 如果 has_portrait 为 True:
-  - 从 metadata.portrait_data 中获取结构化画像数据
-  - 评估是否符合目标人群
-  - 在结果中标注"数据来源:账号粉丝画像(内容点赞画像缺失)"
-
-**优先级3:无画像数据**
-- 如果两种画像的 has_portrait 都为 False
-- 仅基于热度和相关性评估
-- 在结果中标注"数据来源:无画像数据"
-
-#### 画像评估标准
-
-**从需求中提取目标人群**:
-- 用户明确说的条件,作为硬性标准(必须满足)
-- 用户模糊描述的,作为软性标准(尽量满足)
-
-**量化评估**:
-- **占比(ratio/percentage)**:目标人群在总体中的占比
-- **偏好度(tgi/preference)**:> 100 表示该人群偏好高于平均水平,= 100 表示平均,< 100 表示低于平均
-
-**举例**:
-- 需求:"适合50岁以上老年人"
-- 评估:年龄分布中"50岁以上"的占比和 tgi
-- 判断:占比 > 40% 且 tgi > 100 为符合
-
-**质性判断**:
-- 无法量化时,寻找间接信号
-- 「感人」→ 看评论中是否有情感表达
-- 「有价值」→ 看收藏率是否高于平均水平
-
-**输出**:筛选出符合目标人群的内容
+## 核心流程:基础筛选 → 画像匹配 → 账号扩展 → 去重排序
 
 
 ---
 ---
 
 
-### 阶段三:优质账号扩展
-
-识别优质账号并扩展其作品,作为补充内容。
-
-#### 优质账号识别标准
+## 阶段一:基础质量筛选
 
 
-**量化标准**:
-- 账号粉丝画像中,目标人群占比 > 60%
-- 目标人群偏好度(tgi)> 120
+在获取画像前先快速过滤,减少不必要的 API 调用。
 
 
-**判断时机**:
-- 在阶段二中,如果某个账号的粉丝画像非常符合要求
-- 标记为"优质账号"
+**热度参考标准**:
 
 
-#### 扩展策略
+| 点赞量 | 热度等级 |
+|---|---|
+| 1000+ | 一般热度 |
+| 5000+ | 较高热度 |
+| 10000+ | 高热度 |
+| 50000+ | 爆款 |
 
 
-**获取账号作品**:
-- 调用 `douyin_user_videos(account_id=author.sec_uid)`
-- 限制数量:5-10 条近期作品
-- 从返回的 metadata.user_videos 中获取结构化数据
+评估维度:digg_count(点赞)、comment_count(评论)、share_count(分享)
 
 
-**筛选扩展作品**:
-- **仅执行阶段一筛选**(热度、相关性)
-- **不再递归获取画像**,避免无限展开
-- 假设该账号的其他作品也符合目标人群
-
-**加入候选池**:
-- 将通过筛选的扩展作品加入候选池
-- 标注"来源:优质账号扩展"
+**相关性评估**:内容描述(desc)是否与需求相关,明显不相关直接过滤。
 
 
 ---
 ---
 
 
-### 阶段四:去重与排序
-
-#### 去重机制
+## 阶段二:画像匹配筛选
 
 
-**去重依据**:
-- 按 aweme_id 去重
-- 不同关键词或不同来源可能搜到相同内容
+**分批处理**:先处理前 10 条候选内容,筛选后 >= M 则停止,不足再继续下一批。  
+**并行限制**:每次最多并行调用 3 个画像工具。  
+**停止条件**:已获取画像数量 >= M × 1.5 时,立即停止,进入下一阶段。
+不要无限循环获取画像,避免陷入"一直获取画像"的状态
 
 
-**去重策略**:
-- 保留第一次出现的版本
-- 合并多个来源的标签(如"关键词A + 优质账号扩展")
 
 
-#### 排序策略
+### 画像获取优先级
 
 
-**综合排序**:
-- 优先级1:画像匹配度(目标人群占比 × tgi)
-- 优先级2:热度(点赞、评论、分享综合)
-- 优先级3:数据来源(内容点赞画像 > 账号粉丝画像 > 无画像)
+**优先级 1:内容点赞用户画像**
+- 调用 `get_content_fans_portrait(content_id=aweme_id)`
+- `metadata.has_portrait=True` → 从 `metadata.portrait_data` 评估,标注来源 `content_like`
 
 
-**相对比较**:
-- 在候选集中排序,而不是套用固定门槛
-- 同一批结果里,谁更好就推谁
+**优先级 2:账号粉丝画像(兜底)**
+- 如果 `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 视为符合
 
 
-#### 输出分层
+---
 
 
-**强烈推荐**:
-- 核心标准全部满足(画像匹配 + 热度高)
-- 数据来源可靠(内容点赞画像或账号粉丝画像)
+## 阶段三:优质账号扩展(可选)
 
 
-**推荐**:
-- 核心标准满足,有小瑕疵
-- 如:画像匹配但热度一般,或热度高但画像数据缺失
+### 触发条件
+账号粉丝画像中:目标人群占比 > 60% **且** tgi > 120
 
 
-**可选**:
-- 核心标准基本满足,作为补充
-- 如:优质账号扩展的作品
+### 扩展策略
+1. 调用 `douyin_user_videos(account_id=author.sec_uid)`,获取 5-10 条近期作品
+2. **仅执行阶段一筛选**(热度、相关性),不递归获取画像
+3. 通过筛选的作品加入候选池,标注来源"优质账号扩展"
 
 
-**不推荐**:
-- 核心标准不满足,明确说明原因
-- 如:画像不匹配、热度过低
+### 必须在输出中说明
+- 发现优质账号:说明账号名、目标人群占比、tgi,以及扩展了哪些作品
+- 未发现:说明"未发现符合扩展条件的优质账号(需占比 > 60% 且 tgi > 120)"
 
 
-#### 输出内容
+---
 
 
-> **重要**:输出必须严格遵循本 Skills 中「输出结果指南」定义的 JSON Schema 结构,**禁止使用中文 key**,以下中文仅为语义说明,对应到 schema 中的英文 key。
+## 阶段四:去重与排序
 
 
-**每条内容(对应 `contents[].*`)包含**:
-- 内容基本信息 → `title`、`aweme_id`、`author_nickname`、`author_sec_uid`、`video_url`、`author_url`
-  - **数据来源**:必须从 metadata.search_results 或 metadata.user_videos 中获取
-  - **sec_uid 要求**:必须完整复制(约80字符),不能截断
-- 该内容热度数据 → `statistics`(digg_count、comment_count、share_count)
-  - **数据来源**:必须从 metadata 中的 statistics 字段获取
-- 该内容的画像数据 → `portrait_data`()
-  - **数据来源**:从 metadata.portrait_data 中获取,输出key为source、age_50_plus_ratio、age_50_plus_tgi、url字段。
-  - **有效性判断**:通过 metadata.has_portrait 字段判断
-- 数据来源标注 → `portrait.source`(content_like | account_fans | none)
-- 推荐理由 → `reasons`(字符串数组)
-- **链接的拼接规则**:
-  - video_url(内容链接):https://www.douyin.com/video/{aweme_id}
-  - author_url(作者链接):https://www.douyin.com/user/{author.sec_uid}
-  - portrait_data.url(画像链接)(如果 has_portrait 为 True):
-    - 内容点赞画像:https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}
-    - 账号粉丝画像:https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author.sec_uid}
+**去重**:按 aweme_id 去重,保留第一次出现的版本。
 
 
-**说明评估逻辑**:
-- 让用户理解为什么这条内容被推荐或排除
-- 透明展示评估过程
+**排序优先级**:
+1. 画像匹配度(目标人群占比 × tgi)
+2. 热度(点赞、评论、分享综合)
+3. 数据来源可靠性(content_like > account_fans > none)
 
 
 ---
 ---
 
 
 ## 关键原则
 ## 关键原则
-
 **标准来自需求**:评估维度随需求变化,不固化。
 **标准来自需求**:评估维度随需求变化,不固化。
-
 **分阶段筛选**:先快速过滤,再精细评估,提高效率。
 **分阶段筛选**:先快速过滤,再精细评估,提高效率。
-
 **画像兜底策略**:优先内容画像,缺失时用账号画像,确保数据覆盖。
 **画像兜底策略**:优先内容画像,缺失时用账号画像,确保数据覆盖。
-
 **说明评估逻辑**:让用户理解为什么这条内容被推荐或排除。
 **说明评估逻辑**:让用户理解为什么这条内容被推荐或排除。
-
 **承认不确定性**:数据不足以判断时,如实说明,而不是强行打分。
 **承认不确定性**:数据不足以判断时,如实说明,而不是强行打分。
-
 **让用户决定**:提供充分信息,最终选择权在用户。
 **让用户决定**:提供充分信息,最终选择权在用户。
+
+
+输出格式严格遵循 `output_schema` 中定义的 JSON Schema,禁止自创字段名或使用中文 key。
+

+ 47 - 191
examples/content_finder/skills/content_finding_strategy.md

@@ -1,221 +1,77 @@
-# 内容寻找方法论
-
-## 核心方法:串行搜索 → 分阶段筛选 → 按需补充
-
-本方案采用更结构化的执行流程,提供明确的数量控制和资源优化策略。
-
+---
+name: content_finding_strategy
+description: 内容搜索方法论
 ---
 ---
 
 
-## 完整执行流程
-
-### 第一步:需求分析与关键词提取
-
-**提取多个搜索关键词**:
-- 从用户需求中提取核心关键词和扩展关键词
-- 优先使用用户原话中的关键词
-- 必要时补充同义词或相关词
-
-**关键词排序**:
-- 按相关性排序:核心关键词优先,扩展关键词其次
-- 优先级:用户明确说的 > 用户暗示的 > 推测的
+# 内容搜索方法论
 
 
-**确定目标数量 M**:
-- M = 用户要求的内容数量(如"找10条内容",则 M = 10)
+## 核心流程:关键词提取 → 串行搜索 → 结果评估 → 按需补充
 
 
 ---
 ---
 
 
-### 第二步:串行关键词搜索
-
-**选择关键词**:
-- 从关键词列表中选择优先级最高的未使用关键词
+## 第一步:需求分析与关键词提取
 
 
-**搜索数量控制**:
-- 只搜索 N 条内容,其中 **N = M × 2,不要超过**
-- 如果第一次搜索返回超过 N 条,只保留前 N 条处理
-- 目的:控制 token 消耗,确保筛选后有足够余量
-
-**分页策略**:
-- 第一次搜索使用默认 cursor("0" 或 "")
-- 如果需要更多结果,使用返回的 cursor 值继续获取
-- 示例:`douyin_search(keyword="...", cursor="返回的cursor值")`
+- 从用户需求中提取核心关键词和扩展关键词,优先使用用户原话
+- 按相关性排序:用户明确说的 > 用户暗示的 > 推测的
+- 确定目标数量 **M**(如"找10条",则 M = 10)
 
 
 ---
 ---
 
 
-### 第三步:分阶段内容筛选
-
-#### 阶段一:基础质量筛选
-
-**热度筛选**:
-- 根据点赞、评论、分享数据过滤低质量内容
-- 参考标准(可根据需求调整):
-  - 1000+: 一般热度
-  - 5000+: 较高热度
-  - 10000+: 高热度
-
-**相关性筛选**:
-- 根据内容描述过滤明显不相关的内容
-- 保留候选内容列表
-
-#### 阶段二:画像匹配筛选
-
-**分批处理策略**:
-- 不要一次性处理所有候选内容
-- 先处理前 10 条候选内容
-- 筛选后如果符合要求的内容 >= M,停止处理
-- 如果不足,继续处理下一批 10 条
-- 目的:避免一次性调用过多工具导致 token 超限
-
-**画像获取完成标准(重要)**:
-- 当已获取画像的内容数量 >= M × 1.5 时,立即停止获取画像
-- 示例:用户要10条,获取15条画像后立即进入筛选和输出阶段
-- 不要无限循环获取画像,避免陷入"一直获取画像"的状态
-
-**工具调用限制**:
-- 每次最多并行调用 3 个画像工具
-- 避免一次性调用过多工具导致响应被截断
-
-**对每条候选内容**:
-
-1. **从 metadata.search_results 或 metadata.user_videos 中获取基础信息**:
-   - aweme_id、desc、author.nickname、author.sec_uid、statistics
-   - 这些数据将用于最终输出,必须完整保留
-   - 特别注意:author.sec_uid 约80字符,必须完整复制
-
-2. **优先获取内容点赞用户画像**:
-   - 调用 `get_content_fans_portrait(content_id=aweme_id)`
-   - 检查返回的 metadata.has_portrait 字段
-   - 如果 has_portrait 为 True:评估是否符合目标人群,标注"内容点赞画像"
-
-3. **画像缺失时的兜底策略**:
-   - 如果 metadata.has_portrait 为 False(无画像数据)
-   - 获取该内容作者的账号粉丝画像:`get_account_fans_portrait(account_id=author.sec_uid)`
-   - 检查账号画像的 metadata.has_portrait
-   - 如果有画像:评估是否符合目标人群,标注"账号粉丝画像"
-
-4. **画像评估**:
-   - 从 metadata.portrait_data 中获取结构化的画像数据
-   - 根据目标人群的占比和偏好度(tgi)判断
-   - 偏好度 > 100 表示该人群偏好高于平均水平,= 100 表示平均,< 100 表示低于平均
-   - 筛选出符合要求的内容
-
-5. **数据获取步骤(必须遵守)**:
-   ### 唯一数据源
-   - 推荐结果的所有数据必须来自 ToolResult 的 `metadata.search_results` 或 `metadata.user_videos`
-   - **禁止**从 metadata.output 中解析数据(可能有格式问题)
-   - **禁止**编造任何不在 metadata 中的数据
-   - **禁止**拼接、修改、截断任何字段值
-
-   ### 字段完整性
-   - `author.sec_uid` 必须**逐字符完整复制**(约80字符),不能截断、不能修改
-   - 格式检查:必须以 `MS4wLjABAAAA` 开头,后面约 68 个字符
-   - `aweme_id`、作者名、热度数据必须来自**同一条记录**,不能混用
-
-   1. 从工具返回的 `metadata.search_results` 或 `metadata.user_videos` 中选择一条记录
-   2. 从该记录中提取 `author.sec_uid`,**完整复制**,不做任何修改
-   3. 使用该 sec_uid 调用画像工具
-   4. 如果 metadata 中没有该字段或为空,**不要编造**,标注为"无数据"
-
-   ### 错误示例(禁止)
-   ❌ 编造 sec_uid:`MS4wLjABAAAA2Ue8Ks9rkqNmLCy_3bRYCcjmLPXCxQzQOWrGGLZqLmNjFCFUhXJWVLPOxLPO`
-   ❌ 截断 sec_uid:`MS4wLjABAAAAknWSpc8MaIgiXwRsohQtmeF6dJD0CxofXq4v8QtSVDw5eyehGrb_P4a`
-   ❌ 从 output 解析:从文本 "sec_uid: MS4w..." 中提取
-   ❌ 混用字段:用 A 记录的 aweme_id + B 记录的 sec_uid
-
-   ### 正确示例
-   ✅ 从 metadata.search_results[0] 中获取:
-   ```python
-      item = metadata.search_results[0]
-      aweme_id = item["aweme_id"]  # "7598168772859838016"
-      sec_uid = item["author"]["sec_uid"]  # 完整复制,约80字符
-   ```
-
-   **违反后果**:编造数据会导致404错误,严重影响用户体验。
-
-#### 阶段三:优质账号扩展(可选)
-
-**识别优质账号**:
-- 标准:账号粉丝画像匹配度高
-- 量化指标:目标人群占比 > 60% 且 tgi > 120
-
-**扩展策略**:
-- 对优质账号,获取其近期作品:`douyin_user_videos(account_id=author.sec_uid)`
-- 限制数量:5-10 条
-- 从返回的 metadata.user_videos 中获取结构化数据
-- 对这些作品**仅执行阶段一筛选**(热度、相关性)
-- **不再递归获取画像**,避免无限展开
-- 作为补充内容加入候选池
-
-**必须说明(重要)**:
-- 在输出推荐结果时,必须明确说明优质账号扩展情况
-- 如果发现优质账号:说明"发现 X 个优质账号(账号名,目标人群占比 Y%,tgi Z),已扩展其作品"
-- 如果未发现优质账号:说明"未发现符合扩展条件的优质账号(需要目标人群占比 > 60% 且 tgi > 120)"
-- 让用户清楚知道是否执行了扩展,以及扩展的结果
-
----
+## 第二步:串行关键词搜索
 
 
-### 第四步:结果评估与补充
+**数量控制**:只搜索 **N = M × 2** 条,搜到后立即停止,不超出此限制。
 
 
-**统计当前符合要求的内容数量 C**:
-- C = 通过所有筛选阶段的内容数量
+**数据读取规则**:
+- 搜索结果从 `metadata.search_results` 获取,**不要解析工具的 output 文本**
+- 账号作品从 `metadata.user_videos` 获取
 
 
-**判断是否需要补充**:
-- 如果 **C >= M**:完成,进入第五步
-- 如果 **C < M × 0.8**:内容不足,选择下一个关键词,回到第二步
-- 如果 **M × 0.8 <= C < M**:接近目标,可选择继续补充或直接输出
+**分页策略**:第一次使用默认 cursor(`"0"` 或 `""`),需要更多时使用返回的 cursor 继续获取。
 
 
 ---
 ---
 
 
-### 第五步:去重与排序
-
-**去重机制**:
-- 按 aweme_id 去重(不同关键词可能搜到相同内容)
-- 保留第一次出现的版本
-
-**排序策略**:
-- 按匹配度和热度综合排序
-- 优先推荐匹配度高且热度高的内容
-
----
+## 第三步:数据真实性规范(严格遵守)
 
 
-## 错误处理
+**禁止编造数据**,所有字段必须来自工具返回的 metadata。
 
 
-**服务级错误(HTTP 502/503/504)**:
-- 这是服务暂时不可用,不是参数问题
-- 工具会返回详细的错误信息,包含 HTTP 状态码
-- 不要重复尝试相同的调用(最多重试1次)
-- 直接告知用户"服务暂时不可用,请稍后再试"
-- 不要切换到其他平台或工具
+### 字段完整性要求
+- `author.sec_uid`:约 80 字符,必须**逐字符完整复制**,不能截断或修改
+  - 格式校验:必须以 `MS4wLjABAAAA` 开头,后跟约 68 个字符
+- `aweme_id`、作者名、热度数据必须来自**同一条记录**,不能混用
 
 
-**参数错误(HTTP 400/404)**:
-- 检查参数格式是否正确
-- 调整参数后重试
+### 正确做法
+```python
+item = metadata.search_results[0]
+aweme_id = item["aweme_id"]         # 直接取,不修改
+sec_uid = item["author"]["sec_uid"]  # 完整复制,约 80 字符
+```
 
 
-**超时错误(Timeout)**:
-- 工具会返回明确的超时错误信息
-- 可以重试1次,如果仍然超时则告知用户
+### 禁止行为
+❌ 编造 sec_uid  
+❌ 截断 sec_uid  
+❌ 从 output 文本中解析数据  
+❌ 混用不同记录的字段  
 
 
-**网络错误(Connection/Network)**:
-- 可以重试1-2次
-- 如果持续失败,告知用户网络问题
+**违反后果**:编造数据会导致 404 错误,严重影响用户体验。
 
 
 ---
 ---
 
 
-## 关键参数
-
-- **搜索倍数**:N = M × 2
-- **不足阈值**:C < M × 0.8
-- **优质账号标准**:目标人群占比 > 60% 且 tgi > 120
-- **账号扩展数量**:5-10 条
-- **TGI 说明**:tgi > 100 表示高于平均,= 100 表示平均,< 100 表示低于平均
+## 第四步:结果评估与补充
 
 
----
+经 `content_filtering_strategy` 筛选后,统计符合要求的内容数量 **C**:
 
 
-## 关键原则
+- **C >= M**:完成,进入输出阶段
+- **C < M × 0.8**:内容不足,选下一个关键词,回到第二步
+- **M × 0.8 <= C < M**:接近目标,可选择继续补充或直接输出
 
 
-**结构化执行**:严格按照5步流程执行,确保可控性。
+---
 
 
-**资源优化**:串行搜索,够了就停,避免浪费。
+## 错误处理
 
 
-**透明过程**:说明为什么选择这些关键词,用了什么筛选逻辑。
+| 错误类型 | 处理策略 |
+|---|---|
+| HTTP 502/503/504 | 服务暂时不可用,最多重试 1 次,失败则告知用户 |
+| HTTP 400/404 | 检查参数格式,调整后重试 |
+| Timeout | 重试 1 次,仍超时则告知用户 |
+| 网络错误 | 重试 1-2 次,持续失败则告知用户 |
 
 
-**承认局限**:如果真的找不到符合要求的内容,如实说明。
+不要切换到其他平台或工具。

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

@@ -10,11 +10,14 @@ description: 输出结果指南
 **获取路径方式**:先调用 `get_current_context` 获取 `trace_id` 和 `output_dir`,再使用 `write_file` 写入 `{output_dir}/{trace_id}/output.json`。
 **获取路径方式**:先调用 `get_current_context` 获取 `trace_id` 和 `output_dir`,再使用 `write_file` 写入 `{output_dir}/{trace_id}/output.json`。
 
 
 ### **输出 JSON Schema**
 ### **输出 JSON Schema**
+
+> ⚠️ 所有字段名必须与下面完全一致,禁止自创字段名(如 `results`、`metrics`、`like_count`、`age_distribution`、`platform` 等)
+
 ```json
 ```json
 {
 {
   "trace_id": "<由系统生成的真实 trace_id;如果你不知道就填空字符串,程序会覆盖修正>",
   "trace_id": "<由系统生成的真实 trace_id;如果你不知道就填空字符串,程序会覆盖修正>",
   "query": "<本次任务的 query>",
   "query": "<本次任务的 query>",
-  "demand_id": "<来自 user 消息的搜索词 id",
+  "demand_id": "<来自 user 消息的搜索词 id>",
   "summary": {
   "summary": {
     "candidate_count": 0,
     "candidate_count": 0,
     "portrait_content_like_count": 0,
     "portrait_content_like_count": 0,
@@ -37,7 +40,7 @@ description: 输出结果指南
     {
     {
       "title": "<来自 metadata 的标题/desc>",
       "title": "<来自 metadata 的标题/desc>",
       "aweme_id": "内容id",
       "aweme_id": "内容id",
-      "rank": "排名",
+      "rank": 1,
       "video_url": "https://www.douyin.com/video/<aweme_id>",
       "video_url": "https://www.douyin.com/video/<aweme_id>",
       "author_nickname": "作者名",
       "author_nickname": "作者名",
       "author_sec_uid": "作者id",
       "author_sec_uid": "作者id",
@@ -58,10 +61,23 @@ description: 输出结果指南
   ]
   ]
 }
 }
 ```
 ```
-portrait_data内部字段规则说明:
-- `portrait_data.source="content_like"` → `portrait.url = https://douhot.douyin.com/video/detail?active_tab=video_fans&video_id={aweme_id}`
-- `portrait_data.source="account_fans"` → `portrait.url = https://douhot.douyin.com/creator/detail?active_tab=creator_fans_portrait&creator_id={author_sec_uid}`
-- `portrait_data.source="none"` → `portrait_data.url=null`,并且画像字段都为 null
+
+### 易错字段说明
+
+| 字段 | 正确写法 | 错误写法(禁止) |
+|---|---|---|
+| 点赞数 | `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 字段规则
+
+- `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 编写规范
 - 字符串值中若有双引号 `"`,必须写成 `\"`(反斜杠 + 双引号)
 - 字符串值中若有双引号 `"`,必须写成 `\"`(反斜杠 + 双引号)

+ 0 - 2
examples/content_finder/tools/aigc_platform_api.py

@@ -452,7 +452,6 @@ def post(url: str, params: Any) -> Dict[str, Any]:
         "params": params
         "params": params
     }
     }
     try:
     try:
-        logger.info(f"invoke aigc platform. url: {url}, request: {json.dumps(request)}")
         response = requests.post(
         response = requests.post(
             url=url,
             url=url,
             json=request,
             json=request,
@@ -462,7 +461,6 @@ def post(url: str, params: Any) -> Dict[str, Any]:
         response.raise_for_status()
         response.raise_for_status()
         response_json = response.json()
         response_json = response.json()
 
 
-        logger.info(f"invoke aigc platform. url: {url}, request: {json.dumps(request)}, response: {json.dumps(response_json)}")
         return response_json
         return response_json
     except Exception as e:
     except Exception as e:
         logger.error(f"invoke aigc platform error. url: {url}, request: {json.dumps(request)}, error: {str(e)}")
         logger.error(f"invoke aigc platform error. url: {url}, request: {json.dumps(request)}, error: {str(e)}")

+ 3 - 2
examples/content_finder/tools/douyin_search.py

@@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
 # API 基础配置
 # API 基础配置
 DOUYIN_SEARCH_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword"
 DOUYIN_SEARCH_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword"
 DEFAULT_TIMEOUT = 60.0
 DEFAULT_TIMEOUT = 60.0
+DOUYIN_ACCOUNT_ID = "771431194"
 
 
 
 
 @tool(description="通过关键词搜索抖音视频内容")
 @tool(description="通过关键词搜索抖音视频内容")
@@ -27,7 +28,7 @@ async def douyin_search(
     sort_type: str = "综合排序",
     sort_type: str = "综合排序",
     publish_time: str = "不限",
     publish_time: str = "不限",
     cursor: str = "0",
     cursor: str = "0",
-    account_id: str = "771431186",
+    account_id: str = DOUYIN_ACCOUNT_ID,
     timeout: Optional[float] = None,
     timeout: Optional[float] = None,
 ) -> ToolResult:
 ) -> ToolResult:
     """
     """
@@ -196,7 +197,7 @@ async def douyin_search(
 async def main():
 async def main():
     result = await douyin_search(
     result = await douyin_search(
         keyword="养老政策",
         keyword="养老政策",
-        account_id="771431186"
+        account_id=DOUYIN_ACCOUNT_ID
     )
     )
     print(result.output)
     print(result.output)
 
 

+ 7 - 7
examples/content_finder/tools/hotspot_profile.py

@@ -91,13 +91,13 @@ async def get_account_fans_portrait(
             error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
             error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
         )
         )
 
 
-    if len(account_id) < 70 or len(account_id) > 90:
-        logger.error("get_account_fans_portrait invalid sec_uid length", extra={"account_id": account_id, "length": len(account_id)})
-        return ToolResult(
-            title="账号粉丝画像获取失败",
-            output="",
-            error=f"account_id 长度异常:期望 70-90 字符,实际 {len(account_id)} 字符。这可能是编造或截断的数据。",
-        )
+    # if len(account_id) < 70 or len(account_id) > 90:
+    #     logger.error("get_account_fans_portrait invalid sec_uid length", extra={"account_id": account_id, "length": len(account_id)})
+    #     return ToolResult(
+    #         title="账号粉丝画像获取失败",
+    #         output="",
+    #         error=f"account_id 长度异常:期望 70-90 字符,实际 {len(account_id)} 字符。这可能是编造或截断的数据。",
+    #     )
 
 
     try:
     try:
         payload = {
         payload = {