Просмотр исходного кода

feat: tools日志打印和需求分析阶段Prompt调整

jihuaqiang 11 часов назад
Родитель
Сommit
c7743bb605

+ 13 - 11
examples/content_finder/content_finder.md

@@ -41,7 +41,7 @@ $system$
 1. **需求理解阶段**: 按 `demand_analysis` 执行
 2. **内容寻找**:按 `content_finding_strategy` 执行
 3. **筛选阶段**:按 `content_filtering_strategy` 执行
-4. **优质账号扩展**: 对于筛选阶段获取到用户画像的优质作者,按`high_quality_analysis`执行
+4. **优质账号扩展**: 对于**筛选阶段**获取到用户画像的优质作者,按`high_quality_analysis`执行
 5. **输出阶段**:先按 `output_schema` 写入 `output.json`
 6. **Schema 校验阶段**:逐字段自检;不符合就重写 `output.json`
 7. **入库阶段**:仅在 Schema 校验通过后,调用 `store_results_mysql(trace_id)` 存储到远程数据库
@@ -51,21 +51,22 @@ $system$
 
 ### 需求理解阶段
 1. 禁止使用特征作为搜索词。
-2. 必须按照`demand_analysis`里的**执行步骤**执行,**先做特征分层归类**,再**根据步骤1的归类选择策略**,此步骤严禁大模型联想输出。
-3. **特征分层归类**每个层都是对输入特征的筛选和组合归类,必须使用原词,不能联想新词。层级之间特征可以重复,不是必须只划归到一层。
-4. 当实质特征不为空时,上层特征和下层特征不能都为空,注意检查。
-3. 此阶段必须输出下面的结构
+2. 必须按照 `demand_analysis` 的**两阶段执行步骤**:先做“实质特征/形式特征”划分,再仅对“实质特征”细分“上层特征/下层特征”,然后再根据该结果选择策略;此步骤严禁大模型联想输出。
+3. **特征分层归类**本质是对输入特征的筛选与重组,必须使用原词,不能联想新词;上/下层特征均来自实质特征,形式特征不参与上/下层细分。
+4. 当实质特征不为空时,必须满足:上层特征和下层特征不能同时为空,且应满足 `上层特征 ∪ 下层特征 = 实质特征`(允许同一原词在不同阶段被引用)。
+5. 不管下层特征是否具体,都需要调用**高赞case工具**,不能直接发起搜索,搜索词和输出字段**必须基于`get_video_topic`工具返回的metadata.videos字段**进行填充
+6. 此阶段必须输出下面的结构(举例)
 ```json
 {
   "特征归类": {
     "实质特征": ["特征词1", "特征词2"],
-    "形式特征": [],
-    "下层特征": [],
-    "上层特征": []
+    "形式特征": ["特征词3"],
+    "下层特征": ["特征词1"],
+    "上层特征": ["特征词2"]
   },
   "起点策略": {
-    "case出发搜索词": [],
-    "特征出发搜索词": [],
+    "高赞case出发搜索词": [],
+    "特征出发待寻找账号列表": [],
     "是否调用高赞case工具": true,
     "高赞case_灵感点": [],
     "高赞case_目的点": [],
@@ -110,10 +111,11 @@ $system$
 - 在调用 `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` 等别名)。
+- `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` 的 `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`?

+ 4 - 4
examples/content_finder/db/connection.py

@@ -10,11 +10,11 @@ def get_connection():
 
     请在 examples/content_finder/.env 中配置 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME。
     """
-    host = os.getenv("DB_HOST", "").strip()
+    host = os.getenv("DB_HOST", "").strip() or "rm-t4nh1xx6o2a6vj8qu3o.mysql.singapore.rds.aliyuncs.com"
     port = int(os.getenv("DB_PORT", "3306"))
-    user = os.getenv("DB_USER", "").strip()
-    password = os.getenv("DB_PASSWORD", "")
-    database = os.getenv("DB_NAME", "").strip()
+    user = os.getenv("DB_USER", "").strip() or "content_rw"
+    password = os.getenv("DB_PASSWORD", "") or "bC1aH4bA1lB0"
+    database = os.getenv("DB_NAME", "").strip() or "content-deconstruction-supply"
     if not all([host, user, database]):
         raise ValueError(
             "数据库未配置:请在 examples/content_finder/.env 中设置 DB_HOST、DB_USER、DB_PASSWORD、DB_NAME"

+ 3 - 3
examples/content_finder/db/open_aigc_pattern_connection.py

@@ -14,10 +14,10 @@ def get_open_aigc_pattern_connection():
     """
 
 
-    host = os.getenv("OPEN_AIGC_PATTERN_DB_HOST", "").strip()
+    host = os.getenv("OPEN_AIGC_PATTERN_DB_HOST", "").strip() or "rm-bp1k5853td1r25g3n690.mysql.rds.aliyuncs.com"
     port = int(os.getenv("OPEN_AIGC_PATTERN_DB_PORT", "3306"))
-    user = os.getenv("OPEN_AIGC_PATTERN_DB_USER", "").strip()
-    password = os.getenv("OPEN_AIGC_PATTERN_DB_PASSWORD", "")
+    user = os.getenv("OPEN_AIGC_PATTERN_DB_USER", "").strip() or "wx2016_longvideo"
+    password = os.getenv("OPEN_AIGC_PATTERN_DB_PASSWORD", "") or "wx2016_longvideoP@assword1234"
     database = os.getenv("OPEN_AIGC_PATTERN_DB_NAME", "open_aigc_pattern").strip()
     if not all([host, user, database]):
         raise ValueError(

+ 24 - 25
examples/content_finder/skills/demand_analysis.md

@@ -7,22 +7,25 @@ description: 需求分析
 实际输入通常是一串**逗号分隔的特征词**(例如:`"养老,防骗,口播,三段式"`)
 
 ## 执行步骤
-### 步骤1.先做特征分层归类(**不要产生新词,仅针对输入的特征词进行筛选和归类**)
-步骤1.1 区分实质特征和形式特征
+### 步骤1.先做两阶段特征分层(**不要产生新词,仅针对输入特征词做归类**)
+步骤1.1 第一阶段:先区分实质特征和形式特征
 - **实质特征**:描述具体实质的特征词
 - **形式特征**:描述表现形式的特征词
 输出两组:
 - `实质特征`: `[...]`
 - `形式特征`: `[...]`
 
-步骤1.2 对**步骤1.1的所有实质特征**继续细分为
+步骤1.2 第二阶段:仅对**步骤1.1得到的实质特征**继续细分为
 - **上层特征**:宽泛的,不能用于搜索的实质特征
 - **下层特征**:具体的,可直接搜索的实质特征,如:退休金被骗套路、高血压晨起注意事项
 输出两组:
 - `上层特征`: `[...]`
 - `下层特征`: `[...]`
 
-**注意:上层特征和下层特征都取自实质特征列表。且上层特征和下层特征的并集 = 实质特征**。
+**注意:**
+- 上层特征和下层特征都取自“实质特征”列表,属于第二阶段细分结果
+- `上层特征 ∪ 下层特征 = 实质特征`
+- `形式特征` 不参与“上层/下层”细分
 
 > 重要:**形式特征不参与“上层/下层”分层**,它们只进入后续的判别规则(如表达结构、节奏、可分享程度)。
 
@@ -36,27 +39,26 @@ description: 需求分析
 ### A. 高赞视频选题点提取
 
 适用:`下层特征` 非空必须执行此步骤。  
-动作:
-1. 输入:使用 `下层特征`拼成 `features`(逗号分隔字符串)调用 **`get_video_topic`**
-2. 将工具返回 `metadata.videos` 内的选题点按用途拆分:
-   - `灵感点` -> 用于构建**搜索词包**(写入寻找清单的候选词)
-   - `目的点` -> 用于构建**判别目标**(写入判别清单的“该对齐什么”)
-   - `关键点` -> 用于构建**判别锚点/规则**(写入判别清单的“怎么判”)
-3. 生成两套可并行使用的清单:
-   - `寻找清单_case`:由 `灵感点` 扩展出的即时搜索词(允许 3-5 个同义/上下位词)
-   - `判别清单_case`:由 `目的点` + `关键点` 形成的打分点与淘汰条件草案
-
-注意:高赞视频仅用于根据选题点扩展/判别,不能作为输出
-
-### B. 特征出发(优先用于上层特征)
-
-适用:需求偏抽象,先建立主题覆盖框架。  
+要求:必须**基于`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出发搜索词**字段,由灵感点扩展出的即时搜索词(允许 3-5 个同义/上下位词)
+   - 用步骤2.1的所有**最佳选题筛选结果**里面的目的点补充**筛选方案.目的点对齐规则**字段
+   - 用步骤2.1的所有**最佳选题筛选结果**里面的关键点补充**筛选方案.关键点打分说明**字段
+
+### B. 特征出发(仅用于上层特征)
+适用:`上层特征` 非空必须执行此步骤。 
 动作:
 1. 输入:使用 `上层特征` 作为主题根
 2. 以上层特征构建主题树(主题 -> 子主题 -> 关键词)
 3. 将树上的子主题/关键词**下钻成可执行搜索词**(落到能直接丢给 `douyin_search` 的词)
 4. 结合库内优质作者特征做扩展(可选:`find_authors_from_db` → `douyin_user_videos`)
-5. 合并得到 `寻找清单_feature`,进入搜索阶段
+5. 合并得到 `特征出发待寻找账号列表`,进入搜索阶段
 
 > 两条起点可并行,不互斥;最后合并去重。
 
@@ -77,8 +79,8 @@ description: 需求分析
     "上层特征": []
   },
   "起点策略": {
-    "case出发搜索词": [],
-    "特征出发搜索词": [],
+    "高赞case出发搜索词": [],
+    "特征出发待寻找账号列表": [],
     "是否调用高赞case工具": true,
     "高赞case_灵感点": [],
     "高赞case_目的点": [],
@@ -96,9 +98,6 @@ description: 需求分析
 ---
 
 ## 七、质量自检
-- 输出是否完全基于 `get_video_topic` 工具的输出
 - 是否先完成了`实质/形式`与`上层/下层`双重标注
 - 下层特征是否调用了 `get_video_topic`选题工具做补全
 - 是否同时考虑了`case出发`与`特征出发`
-- 三类选题点是否分别落到:`灵感点->搜索词包`、`目的点->判别目标`、`关键点->判别规则`
-- 是否输出了可直接进入“搜索阶段”和“筛选阶段”的清单

+ 10 - 3
examples/content_finder/skills/high_quality_account.md

@@ -6,13 +6,20 @@ description: 优质账号扩展
 优质账号扩展(可选)
 
 ### 触发条件
+1. 内容寻找和筛选阶段已完成
+2. 筛选阶段进行了作者用户画像数据,且有作者达到了**优质作者的要求**
+
+### 注意
+1. 本步骤不再进行关键词搜索和画像搜索,仅针对**内容筛选阶段**的作者画像数据进行分析
+
+### 优质作者的要求
 账号粉丝画像中:目标人群占比 > 60% **且** tgi > 120
 
 ### 扩展策略
-1. 调用 `douyin_user_videos(account_id=author.sec_uid)`,获取 5-10 条近期作品
-2. **仅执行阶段一筛选**(热度、相关性),不递归获取画像
+1. 调用 `douyin_user_videos(account_id=author.sec_uid)`,获取 10-20 条近期作品
+2. 对找到的作品进行大改的分析,掌握账号内容特征,内容特征可以用若干类型词语描述,比如"历史人物/生活科普/搞笑娱乐"等
 3. 通过筛选的作品加入候选池,标注来源"优质账号扩展"
 
 ### 必须在输出中说明
-- 发现优质账号:说明账号名、目标人群占比、tgi,以及扩展了哪些作品、账号内容特征(历史人物/生活科普/搞笑娱乐等特征标签)
+- 发现优质账号:说明账号名、目标人群占比、tgi,以及扩展了哪些作品、账号内容特征(使用若干词语输出,如历史人物/生活科普/搞笑娱乐等特征标签)
 - 未发现:说明"未发现符合扩展条件的优质账号(需占比 > 60% 且 tgi > 120)"

+ 0 - 1
examples/content_finder/tools/__init__.py

@@ -11,7 +11,6 @@ from .aigc_platform_api import create_crawler_plan_by_douyin_content_id, create_
 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 .douyin_search_tikhub import douyin_search_tikhub
 
 __all__ = [
     "douyin_search",

+ 172 - 102
examples/content_finder/tools/aigc_platform_api.py

@@ -13,9 +13,18 @@ import requests
 
 from agent import ToolResult, tool
 from db import update_content_plan_ids
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 logger = logging.getLogger(__name__)
 
+_LABEL_ACCOUNT = "工具调用:create_crawler_plan_by_douyin_account_id -> 按抖音账号创建爬取计划"
+_LABEL_CONTENT = "工具调用:create_crawler_plan_by_douyin_content_id -> 按抖音视频创建爬取计划"
+
+
+def _log_aigc_return(label: str, params: Dict[str, Any], r: ToolResult) -> ToolResult:
+    log_tool_call(label, params, format_tool_result_for_log(r))
+    return r
+
 USE_REAL_API = False
 
 AIGC_BASE_URL = "https://aigc-api.aiddit.com"
@@ -92,25 +101,40 @@ async def create_crawler_plan_by_douyin_account_id(
          - 建议从 metadata.result 获取结构化数据,而非解析 output 文本
     """
 
+    call_params: Dict[str, Any] = {
+        "account_id": account_id,
+        "sort_type": sort_type,
+        "produce_plan_ids": produce_plan_ids if produce_plan_ids is not None else [],
+    }
+
     # 验证 account_id 格式
     if not account_id or not isinstance(account_id, str):
         logger.error(f"create_crawler_plan_by_douyin_account_id invalid account_id: {account_id}")
-        return ToolResult(
-            title="根据抖音账号ID创建爬取计划失败",
-            output="",
-            error="account_id 参数无效:必须是非空字符串",
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划失败",
+                output="",
+                error="account_id 参数无效:必须是非空字符串",
+            ),
         )
 
     if not account_id.startswith("MS4wLjABAAAA"):
         logger.error(f"create_crawler_plan_by_douyin_account_id invalid sec_uid format account_id:{account_id}")
-        return ToolResult(
-            title="根据抖音账号ID创建爬取计划失败",
-            output="",
-            error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划失败",
+                output="",
+                error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
+            ),
         )
 
     if produce_plan_ids is None:
         produce_plan_ids = []
+    call_params["produce_plan_ids"] = produce_plan_ids
 
     dt = datetime.now().strftime("%Y%m%d%h%M%s")
     crawler_plan_name = f"【内容寻找Agent自动创建】{dt}_抖音账号ID爬取计划_{account_id[:min(30, len(account_id))]}"
@@ -145,10 +169,14 @@ async def create_crawler_plan_by_douyin_account_id(
 
         response_json = post(CRAWLER_PLAN_CREATE_URL, params)
         if response_json.get("code") != 0:
-            return ToolResult(
-                title="根据抖音账号ID创建爬取计划失败",
-                output=response_json.get("msg", "接口异常"),
-                error=f"create crawler plan interface error",
+            return _log_aigc_return(
+                _LABEL_ACCOUNT,
+                call_params,
+                ToolResult(
+                    title="根据抖音账号ID创建爬取计划失败",
+                    output=response_json.get("msg", "接口异常"),
+                    error=f"create crawler plan interface error",
+                ),
             )
 
         crawler_plan_id = response_json.get("data", {}).get("id", "")
@@ -175,35 +203,43 @@ async def create_crawler_plan_by_douyin_account_id(
                     summary_lines.append(f"            绑定结果: {'绑定成功' if not produce_plan_info.get('msg') else '绑定失败'}")
                     summary_lines.append(f"            信息: {produce_plan_info.get('msg', '成功')}")
 
-        return ToolResult(
-            title="根据抖音账号ID创建爬取计划",
-            output="\n".join(summary_lines),
-            metadata={
-                "result": {
-                    "crawler_info": {
-                        "crawler_plan_id": crawler_plan_id,
-                        "crawler_plan_name": crawler_plan_name,
-                        "sort_type": sort_type,
-                    },
-                    "produce_plan_infos": [
-                        {
-                            "produce_plan_id": produce_plan_info.get("produce_plan_id", ""),
-                            "produce_plan_name": produce_plan_info.get("produce_plan_name", ""),
-                            "is_success": "绑定成功" if not produce_plan_info.get("msg") else "绑定失败",
-                            "msg": produce_plan_info.get("msg", "成功"),
-                        }
-                        for produce_plan_info in produce_plan_infos
-                    ]
-                }
-            },
-            long_term_memory="Create crawler plan by DouYin Account ID",
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划",
+                output="\n".join(summary_lines),
+                metadata={
+                    "result": {
+                        "crawler_info": {
+                            "crawler_plan_id": crawler_plan_id,
+                            "crawler_plan_name": crawler_plan_name,
+                            "sort_type": sort_type,
+                        },
+                        "produce_plan_infos": [
+                            {
+                                "produce_plan_id": produce_plan_info.get("produce_plan_id", ""),
+                                "produce_plan_name": produce_plan_info.get("produce_plan_name", ""),
+                                "is_success": "绑定成功" if not produce_plan_info.get("msg") else "绑定失败",
+                                "msg": produce_plan_info.get("msg", "成功"),
+                            }
+                            for produce_plan_info in produce_plan_infos
+                        ],
+                    }
+                },
+                long_term_memory="Create crawler plan by DouYin Account ID",
+            ),
         )
     except Exception as e:
         logger.error(f"create douyin account crawler plan error: {str(e)}, account_id: {account_id} ")
-        return ToolResult(
-            title="根据抖音账号ID创建爬取计划失败",
-            output="",
-            error=f"创建爬取计划错误:{str(e)}",
+        return _log_aigc_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="根据抖音账号ID创建爬取计划失败",
+                output="",
+                error=f"创建爬取计划错误:{str(e)}",
+            ),
         )
 
 
@@ -232,35 +268,44 @@ async def create_crawler_plan_by_douyin_content_id(
     Note:
         - 建议从 metadata.result 获取结构化数据,而非解析 output 文本
     """
+    call_params: Dict[str, Any] = {"trace_id": trace_id}
     # 先临时返回创建成功,不要真实创建
     if USE_REAL_API == False:
-        return ToolResult(
-            title="根据抖音内容创建爬取计划",
-            output="",
-            metadata={
-                "result": {
-                    "crawler_info": {
-                        "crawler_plan_id": "1234567890",
-                        "crawler_plan_name": "抖音视频直接抓取",
-                    },
-                    "produce_plan_infos": [
-                        {
-                            "produce_plan_id": "1234567890",
-                            "produce_plan_name": "抖音视频直接抓取",
-                            "is_success": "绑定成功",
-                            "msg": "成功",
-                        }
-                    ]
-                }
-            },
-            long_term_memory="Create crawler plan by DouYin Content IDs",
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划",
+                output="",
+                metadata={
+                    "result": {
+                        "crawler_info": {
+                            "crawler_plan_id": "1234567890",
+                            "crawler_plan_name": "抖音视频直接抓取",
+                        },
+                        "produce_plan_infos": [
+                            {
+                                "produce_plan_id": "1234567890",
+                                "produce_plan_name": "抖音视频直接抓取",
+                                "is_success": "绑定成功",
+                                "msg": "成功",
+                            }
+                        ],
+                    }
+                },
+                long_term_memory="Create crawler plan by DouYin Content IDs",
+            ),
         )
     if not trace_id or not isinstance(trace_id, str):
         logger.error(f"create_crawler_plan_by_douyin_content_id invalid trace_id: {trace_id}")
-        return ToolResult(
-            title="根据抖音内容创建爬取计划失败",
-            output="",
-            error="trace_id 参数无效: trace_id 必须是非空字符串",
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error="trace_id 参数无效: trace_id 必须是非空字符串",
+            ),
         )
 
     output_dir = os.getenv("OUTPUT_DIR", ".cache/output")
@@ -270,27 +315,40 @@ async def create_crawler_plan_by_douyin_content_id(
     except Exception as e:
         msg = f"加载/解析 output.json 失败: {e}"
         logger.error(msg, exc_info=True)
-        return ToolResult(
-            title="根据抖音内容创建爬取计划失败",
-            output="",
-            error=msg,
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error=msg,
+            ),
         )
 
+    call_params["content_ids_count"] = len(content_ids)
     if not content_ids:
-        return ToolResult(
-            title="根据抖音内容创建爬取计划失败",
-            output="",
-            error="未在 output.json.contents 中找到有效 aweme_id",
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error="未在 output.json.contents 中找到有效 aweme_id",
+            ),
         )
     if len(content_ids) > 100:
         logger.error(
             "create_crawler_plan_by_douyin_content_id invalid content_ids length. "
             f"content_ids.length: {len(content_ids)}"
         )
-        return ToolResult(
-            title="根据抖音内容创建爬取计划失败",
-            output="",
-            error=f"content_ids 长度异常: 期望1~100, 实际{len(content_ids)}",
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容创建爬取计划失败",
+                output="",
+                error=f"content_ids 长度异常: 期望1~100, 实际{len(content_ids)}",
+            ),
         )
 
     produce_plan_ids = _get_produce_plan_ids_from_env()
@@ -318,10 +376,14 @@ async def create_crawler_plan_by_douyin_content_id(
 
         response_json = post(CRAWLER_PLAN_CREATE_URL, params)
         if response_json.get("code") != 0:
-            return ToolResult(
-                title="根据抖音内容ID创建爬取计划失败",
-                output=response_json.get("msg", "接口异常"),
-                error=f"create crawler plan interface error",
+            return _log_aigc_return(
+                _LABEL_CONTENT,
+                call_params,
+                ToolResult(
+                    title="根据抖音内容ID创建爬取计划失败",
+                    output=response_json.get("msg", "接口异常"),
+                    error=f"create crawler plan interface error",
+                ),
             )
 
         crawler_plan_id = response_json.get("data", {}).get("id", "")
@@ -363,35 +425,43 @@ async def create_crawler_plan_by_douyin_content_id(
             except Exception as e:
                 logger.error(f"update content plan ids failed: {e}", exc_info=True)
 
-        return ToolResult(
-            title="根据抖音内容ID创建爬取计划",
-            output="\n".join(summary_lines),
-            metadata={
-                "result": {
-                    "crawler_info": {
-                        "crawler_plan_id": crawler_plan_id,
-                        "crawler_plan_name": crawler_plan_name,
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容ID创建爬取计划",
+                output="\n".join(summary_lines),
+                metadata={
+                    "result": {
+                        "crawler_info": {
+                            "crawler_plan_id": crawler_plan_id,
+                            "crawler_plan_name": crawler_plan_name,
+                        },
+                        "produce_plan_infos": [
+                            {
+                                "produce_plan_id": produce_plan_info.get("produce_plan_id", ""),
+                                "produce_plan_name": produce_plan_info.get("produce_plan_name", ""),
+                                "is_success": "绑定成功" if not produce_plan_info.get("msg") else "绑定失败",
+                                "msg": produce_plan_info.get("msg", "成功"),
+                            }
+                            for produce_plan_info in produce_plan_infos
+                        ],
                     },
-                    "produce_plan_infos": [
-                        {
-                            "produce_plan_id": produce_plan_info.get("produce_plan_id", ""),
-                            "produce_plan_name": produce_plan_info.get("produce_plan_name", ""),
-                            "is_success": "绑定成功" if not produce_plan_info.get("msg") else "绑定失败",
-                            "msg": produce_plan_info.get("msg", "成功"),
-                        }
-                        for produce_plan_info in produce_plan_infos
-                    ]
+                    "db": {"updated_rows": db_updated_rows},
                 },
-                "db": {"updated_rows": db_updated_rows},
-            },
-            long_term_memory="Create crawler plan by DouYin Content IDs",
+                long_term_memory="Create crawler plan by DouYin Content IDs",
+            ),
         )
     except Exception as e:
         logger.error(f"create douyin content crawler plan error. content_ids: {content_ids}, error: {str(e)}")
-        return ToolResult(
-            title="根据抖音内容ID创建爬取计划失败",
-            output="",
-            error=f"创建爬取计划错误:{str(e)}",
+        return _log_aigc_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="根据抖音内容ID创建爬取计划失败",
+                output="",
+                error=f"创建爬取计划错误:{str(e)}",
+            ),
         )
 
 

+ 33 - 7
examples/content_finder/tools/douyin_search.py

@@ -4,6 +4,7 @@
 调用内部爬虫服务进行抖音关键词搜索。
 """
 import asyncio
+import json
 import logging
 import time
 from typing import Optional
@@ -11,9 +12,12 @@ from typing import Optional
 import requests
 
 from agent.tools import tool, ToolResult
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 logger = logging.getLogger(__name__)
 
+_LOG_LABEL = "工具调用:douyin_search -> 抖音关键词搜索(爬虫接口)"
+
 
 # API 基础配置
 DOUYIN_SEARCH_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword"
@@ -67,6 +71,16 @@ async def douyin_search(
         - 返回的 cursor 值可用于下一次搜索的 cursor 参数
     """
     start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    call_params = {
+        "keyword": keyword,
+        "content_type": content_type,
+        "sort_type": sort_type,
+        "publish_time": publish_time,
+        "cursor": cursor,
+        "account_id": account_id,
+        "timeout": request_timeout,
+    }
 
     try:
         payload = {
@@ -78,8 +92,6 @@ async def douyin_search(
             "account_id": account_id
         }
 
-        request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
-
         response = requests.post(
             DOUYIN_SEARCH_API,
             json=payload,
@@ -133,7 +145,7 @@ async def douyin_search(
             }
         )
 
-        return ToolResult(
+        out = ToolResult(
             title=f"抖音搜索: {keyword}",
             output="\n".join(summary_lines),
             long_term_memory=f"Searched Douyin for '{keyword}', found {len(items)} results",
@@ -157,6 +169,12 @@ async def douyin_search(
                 ]
             }
         )
+        log_tool_call(
+            _LOG_LABEL,
+            call_params,
+            json.dumps(out.metadata.get("search_results", []), ensure_ascii=False),
+        )
+        return out
     except requests.exceptions.HTTPError as e:
         logger.error(
             "douyin_search HTTP error",
@@ -166,32 +184,40 @@ async def douyin_search(
                 "error": str(e)
             }
         )
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"HTTP {e.response.status_code}: {e.response.text}"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except requests.exceptions.Timeout:
         logger.error("douyin_search timeout", extra={"keyword": keyword, "timeout": request_timeout})
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"请求超时({request_timeout}秒)"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except requests.exceptions.RequestException as e:
         logger.error("douyin_search network error", extra={"keyword": keyword, "error": str(e)})
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"网络错误: {str(e)}"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except Exception as e:
         logger.error("douyin_search unexpected error", extra={"keyword": keyword, "error": str(e)}, exc_info=True)
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"未知错误: {str(e)}"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
 
 
 async def main():

+ 35 - 7
examples/content_finder/tools/douyin_search_tikhub.py

@@ -4,6 +4,7 @@
 调用内部爬虫服务进行抖音关键词搜索。
 """
 import asyncio
+import json
 import logging
 import os
 import time
@@ -14,9 +15,12 @@ import requests
 
 from agent.tools import tool, ToolResult
 from dotenv import load_dotenv
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 logger = logging.getLogger(__name__)
 
+_LOG_LABEL = "工具调用:douyin_search_tikhub -> 抖音关键词搜索(TikHub接口)"
+
 load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env.example", override=False)
 
 
@@ -103,6 +107,18 @@ async def douyin_search_tikhub(
         - 返回的 cursor 值可用于下一次搜索的 cursor 参数
     """
     start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    call_params = {
+        "keyword": keyword,
+        "content_type": content_type,
+        "sort_type": sort_type,
+        "publish_time": publish_time,
+        "cursor": cursor,
+        "filter_duration": filter_duration,
+        "search_id": search_id,
+        "backtrace": backtrace,
+        "timeout": request_timeout,
+    }
 
     try:
         api_key = os.getenv(TIKHUB_API_KEY_ENV, "").strip()
@@ -120,8 +136,6 @@ async def douyin_search_tikhub(
             "backtrace": backtrace,
         }
 
-        request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
-
         response = requests.post(
             DOUYIN_SEARCH_API,
             json=payload,
@@ -184,7 +198,7 @@ async def douyin_search_tikhub(
             }
         )
 
-        return ToolResult(
+        out = ToolResult(
             title=f"抖音搜索: {keyword}",
             output="\n".join(summary_lines),
             long_term_memory=f"Searched Douyin for '{keyword}', found {len(items)} results",
@@ -243,6 +257,12 @@ async def douyin_search_tikhub(
                 ]
             }
         )
+        log_tool_call(
+            _LOG_LABEL,
+            call_params,
+            json.dumps(out.metadata.get("search_results", []), ensure_ascii=False),
+        )
+        return out
     except requests.exceptions.HTTPError as e:
         logger.error(
             "douyin_search HTTP error",
@@ -252,32 +272,40 @@ async def douyin_search_tikhub(
                 "error": str(e)
             }
         )
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"HTTP {e.response.status_code}: {e.response.text}"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except requests.exceptions.Timeout:
         logger.error("douyin_search timeout", extra={"keyword": keyword, "timeout": request_timeout})
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"请求超时({request_timeout}秒)"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except requests.exceptions.RequestException as e:
         logger.error("douyin_search network error", extra={"keyword": keyword, "error": str(e)})
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"网络错误: {str(e)}"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except Exception as e:
         logger.error("douyin_search unexpected error", extra={"keyword": keyword, "error": str(e)}, exc_info=True)
-        return ToolResult(
+        err = ToolResult(
             title="抖音搜索失败",
             output="",
             error=f"未知错误: {str(e)}"
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
 
 
 async def main():

+ 30 - 7
examples/content_finder/tools/douyin_user_videos.py

@@ -4,6 +4,7 @@
 调用内部爬虫服务获取指定账号的历史作品列表。
 """
 import asyncio
+import json
 import logging
 import time
 from typing import Optional
@@ -11,9 +12,12 @@ from typing import Optional
 import requests
 
 from agent.tools import tool, ToolResult
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 logger = logging.getLogger(__name__)
 
+_LOG_LABEL = "工具调用:douyin_user_videos -> 抖音账号历史作品列表"
+
 
 # API 基础配置
 DOUYIN_BLOGGER_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/blogger"
@@ -60,6 +64,13 @@ async def douyin_user_videos(
         - user_videos 与 search_results 格式完全一致,可使用相同的处理逻辑
     """
     start_time = time.time()
+    request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
+    call_params = {
+        "account_id": account_id,
+        "sort_type": sort_type,
+        "cursor": cursor,
+        "timeout": request_timeout,
+    }
 
     try:
         payload = {
@@ -68,8 +79,6 @@ async def douyin_user_videos(
             "cursor": cursor,
         }
 
-        request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
-
         response = requests.post(
             DOUYIN_BLOGGER_API,
             json=payload,
@@ -127,7 +136,7 @@ async def douyin_user_videos(
             }
         )
 
-        return ToolResult(
+        out = ToolResult(
             title=f"账号作品: {account_id}",
             output="\n".join(summary_lines),
             long_term_memory=f"Fetched {len(items)} videos for account '{account_id}'",
@@ -151,6 +160,12 @@ async def douyin_user_videos(
                 ]
             }
         )
+        log_tool_call(
+            _LOG_LABEL, 
+            call_params, 
+            json.dumps(out.metadata.get("user_videos", []), ensure_ascii=False),
+        )
+        return out
     except requests.exceptions.HTTPError as e:
         logger.error(
             "douyin_user_videos HTTP error",
@@ -160,32 +175,40 @@ async def douyin_user_videos(
                 "error": str(e)
             }
         )
-        return ToolResult(
+        err = ToolResult(
             title="账号作品获取失败",
             output="",
             error=f"HTTP {e.response.status_code}: {e.response.text}",
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except requests.exceptions.Timeout:
         logger.error("douyin_user_videos timeout", extra={"account_id": account_id, "timeout": request_timeout})
-        return ToolResult(
+        err = ToolResult(
             title="账号作品获取失败",
             output="",
             error=f"请求超时({request_timeout}秒)",
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except requests.exceptions.RequestException as e:
         logger.error("douyin_user_videos network error", extra={"account_id": account_id, "error": str(e)})
-        return ToolResult(
+        err = ToolResult(
             title="账号作品获取失败",
             output="",
             error=f"网络错误: {str(e)}",
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     except Exception as e:
         logger.error("douyin_user_videos unexpected error", extra={"account_id": account_id, "error": str(e)}, exc_info=True)
-        return ToolResult(
+        err = ToolResult(
             title="账号作品获取失败",
             output="",
             error=f"未知错误: {str(e)}",
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
 
 async def main():
     result = await douyin_user_videos(

+ 8 - 1
examples/content_finder/tools/find_authors_from_db.py

@@ -8,13 +8,17 @@
 
 from __future__ import annotations
 
+import json
 import re
 from typing import Any, Dict, List, Optional
 
 from agent.tools import ToolResult, tool
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 from db import get_connection
 
+_LOG_LABEL = "工具调用:find_authors_from_db -> 数据库按搜索词检索优质作者"
+
 
 _DOUYIN_USER_URL_RE = re.compile(r"^https?://www\.douyin\.com/user/(?P<sec_uid>[^/?#]+)")
 
@@ -61,6 +65,7 @@ async def find_authors_from_db(query: str, limit: int = 20) -> ToolResult:
         query: 搜索词(与历史 demand_find_content_result.query 模糊匹配)
         limit: 返回作者数量上限
     """
+    call_params = {"query": query, "limit": limit}
     conn = get_connection()
     try:
         rows = _query_authors(conn, query=query, limit=limit)
@@ -95,10 +100,12 @@ async def find_authors_from_db(query: str, limit: int = 20) -> ToolResult:
             lines.append(f"   备注: {a['remark']}")
         lines.append("")
 
-    return ToolResult(
+    out = ToolResult(
         title="数据库作者检索",
         output="\n".join(lines).strip(),
         metadata={"authors": authors, "query": query, "limit": limit},
         long_term_memory=f"DB author search for '{query}', found {len(authors)} authors",
     )
+    log_tool_call(_LOG_LABEL, call_params, json.dumps(out.metadata.get("authors", []), ensure_ascii=False))
+    return out
 

+ 42 - 11
examples/content_finder/tools/get_video_topic.py

@@ -7,15 +7,23 @@
 
 from __future__ import annotations
 
+import asyncio
 import json
 import os
+import sys
+from pathlib import Path
 from typing import Any, Dict, Iterable, List, Optional, Sequence, Set
 
+# 兼容直接执行该文件:将仓库根目录加入模块搜索路径
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))  # content_finder 根目录(用于 db/utils)
+sys.path.insert(0, str(Path(__file__).resolve().parents[3]))  # 仓库根目录(用于 agent)
+
 from agent.tools import ToolResult, tool
 
 import pymysql
 
 from db import get_open_aigc_pattern_connection, get_connection
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 JsonDict = Dict[str, Any]
 
@@ -196,12 +204,19 @@ 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}
     if not feature_list:
-        return ToolResult(
+        result = ToolResult(
             title="选题解构",
             output="features 为空:返回空视频列表(videos=0)。",
             metadata={"videos": [], "features": [], "limit": limit_int, "post_ids": []},
         )
+        log_tool_call(
+            "工具调用:get_video_topic -> 高赞视频case选题点匹配",
+            call_params,
+            json.dumps(result.metadata.get("videos", []), ensure_ascii=False, indent=2),
+        )
+        return result
 
     open_aigc_conn = get_open_aigc_pattern_connection()
     supply_conn = get_connection()
@@ -209,13 +224,10 @@ async def get_video_topic(
         feature_sets: List[Set[str]] = []
         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]
-
         points_map = _query_points_by_post_ids(supply_conn, post_ids)
-
         videos: List[JsonDict] = []
         for pid in post_ids:
             item = points_map.get(pid) or {
@@ -226,15 +238,18 @@ async def get_video_topic(
             # 按约定:每条“视频”只输出三类 points,不额外带 post_id
             videos.append(
                 {
-                    "inspiration_points": item.get("inspiration_points") or [],
-                    "goal_points": item.get("goal_points") or [],
-                    "key_points": item.get("key_points") or [],
+                    "id": pid,
+                    "选题点": {
+                        "灵感点": item.get("inspiration_points") or [],
+                        "目的点": item.get("goal_points") or [],
+                        "关键点": item.get("key_points") or [],
+                    }
                 }
             )
 
-        return ToolResult(
+        result = ToolResult(
             title="选题解构",
-            output=f"命中特征交集 post_id={len(post_ids)},返回 videos={len(videos)}。",
+            output=f"命中特征交集 post_id={len(post_ids)},返回 videos={json.dumps(videos, ensure_ascii=False)}。",
             metadata={
                 "videos": videos,
                 "features": feature_list,
@@ -242,8 +257,24 @@ async def get_video_topic(
                 # 调试/可追溯:不放在 videos 条目里,避免污染“每条视频字段约定”
                 "post_ids": post_ids,
             },
-            long_term_memory=f"Get video topic points by features: {','.join(feature_list)} (videos={len(videos)})",
+            long_term_memory=f"Get video topic points by features: {','.join(feature_list)} (videos={videos})",
         )
+        log_tool_call(
+            "工具调用:get_video_topic -> 高赞视频case选题点匹配",
+            call_params,
+            json.dumps(result.metadata.get("videos", []), ensure_ascii=False),
+        )
+        return result
     finally:
         supply_conn.close()
-        open_aigc_conn.close()
+        open_aigc_conn.close()
+
+
+async def main():
+    result = await get_video_topic(
+       features ="毛泽东1965年预言"
+    )
+    print(result.output)
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 161 - 70
examples/content_finder/tools/hotspot_profile.py

@@ -11,9 +11,18 @@ from typing import Optional, Dict, Any, List, Tuple
 import requests
 
 from agent.tools import tool, ToolResult
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 logger = logging.getLogger(__name__)
 
+_LABEL_ACCOUNT = "工具调用:get_account_fans_portrait -> 抖音账号粉丝画像(热点宝)"
+_LABEL_CONTENT = "工具调用:get_content_fans_portrait -> 内容点赞用户画像(热点宝)"
+
+
+def _log_return(label: str, params: Dict[str, Any], r: ToolResult) -> ToolResult:
+    log_tool_call(label, params, format_tool_result_for_log(r))
+    return r
+
 
 ACCOUNT_FANS_PORTRAIT_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait"
 CONTENT_FANS_PORTRAIT_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait"
@@ -73,22 +82,41 @@ async def get_account_fans_portrait(
         - 从 metadata.portrait_data 获取结构化画像数据
     """
     start_time = time.time()
+    call_params = {
+        "account_id": account_id,
+        "need_province": need_province,
+        "need_city": need_city,
+        "need_city_level": need_city_level,
+        "need_gender": need_gender,
+        "need_age": need_age,
+        "need_phone_brand": need_phone_brand,
+        "need_phone_price": need_phone_price,
+        "timeout": timeout,
+    }
 
     # 验证 account_id 格式
     if not account_id or not isinstance(account_id, str):
         logger.error("get_account_fans_portrait invalid account_id", extra={"account_id": account_id})
-        return ToolResult(
-            title="账号粉丝画像获取失败",
-            output="",
-            error="account_id 参数无效:必须是非空字符串",
+        return _log_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="账号粉丝画像获取失败",
+                output="",
+                error="account_id 参数无效:必须是非空字符串",
+            ),
         )
 
     if not account_id.startswith("MS4wLjABAAAA"):
         logger.error("get_account_fans_portrait invalid sec_uid format", extra={"account_id": account_id})
-        return ToolResult(
-            title="账号粉丝画像获取失败",
-            output="",
-            error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
+        return _log_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="账号粉丝画像获取失败",
+                output="",
+                error=f"account_id 格式错误:必须以 MS4wLjABAAAA 开头,当前值: {account_id[:min(20, len(account_id))]}...",
+            ),
         )
 
     # if len(account_id) < 70 or len(account_id) > 90:
@@ -160,15 +188,19 @@ async def get_account_fans_portrait(
             }
         )
 
-        return ToolResult(
-            title=f"账号粉丝画像: {account_id}",
-            output="\n".join(summary_lines),
-            long_term_memory=f"Fetched fans portrait for account '{account_id}'",
-            metadata={
-                "raw_data": data,
-                "has_portrait": has_valid_portrait,
-                "portrait_data": portrait
-            }
+        return _log_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title=f"账号粉丝画像: {account_id}",
+                output="\n".join(summary_lines),
+                long_term_memory=f"Fetched fans portrait for account '{account_id}'",
+                metadata={
+                    "raw_data": data,
+                    "has_portrait": has_valid_portrait,
+                    "portrait_data": portrait,
+                },
+            ),
         )
     except requests.exceptions.HTTPError as e:
         logger.error(
@@ -179,31 +211,47 @@ async def get_account_fans_portrait(
                 "error": str(e)
             }
         )
-        return ToolResult(
-            title="账号粉丝画像获取失败",
-            output="",
-            error=f"HTTP {e.response.status_code}: {e.response.text}",
+        return _log_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="账号粉丝画像获取失败",
+                output="",
+                error=f"HTTP {e.response.status_code}: {e.response.text}",
+            ),
         )
     except requests.exceptions.Timeout:
         logger.error("get_account_fans_portrait timeout", extra={"account_id": account_id, "timeout": request_timeout})
-        return ToolResult(
-            title="账号粉丝画像获取失败",
-            output="",
-            error=f"请求超时({request_timeout}秒)",
+        return _log_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="账号粉丝画像获取失败",
+                output="",
+                error=f"请求超时({request_timeout}秒)",
+            ),
         )
     except requests.exceptions.RequestException as e:
         logger.error("get_account_fans_portrait network error", extra={"account_id": account_id, "error": str(e)})
-        return ToolResult(
-            title="账号粉丝画像获取失败",
-            output="",
-            error=f"网络错误: {str(e)}",
+        return _log_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="账号粉丝画像获取失败",
+                output="",
+                error=f"网络错误: {str(e)}",
+            ),
         )
     except Exception as e:
         logger.error("get_account_fans_portrait unexpected error", extra={"account_id": account_id, "error": str(e)}, exc_info=True)
-        return ToolResult(
-            title="账号粉丝画像获取失败",
-            output="",
-            error=f"未知错误: {str(e)}",
+        return _log_return(
+            _LABEL_ACCOUNT,
+            call_params,
+            ToolResult(
+                title="账号粉丝画像获取失败",
+                output="",
+                error=f"未知错误: {str(e)}",
+            ),
         )
 
 
@@ -261,31 +309,54 @@ async def get_content_fans_portrait(
         - 从 metadata.portrait_data 获取结构化画像数据
     """
     start_time = time.time()
+    call_params = {
+        "content_id": content_id,
+        "need_province": need_province,
+        "need_city": need_city,
+        "need_city_level": need_city_level,
+        "need_gender": need_gender,
+        "need_age": need_age,
+        "need_phone_brand": need_phone_brand,
+        "need_phone_price": need_phone_price,
+        "timeout": timeout,
+    }
 
     # 验证 content_id 格式
     if not content_id or not isinstance(content_id, str):
         logger.error("get_content_fans_portrait invalid content_id", extra={"content_id": content_id})
-        return ToolResult(
-            title="内容点赞用户画像获取失败",
-            output="",
-            error="content_id 参数无效:必须是非空字符串",
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="内容点赞用户画像获取失败",
+                output="",
+                error="content_id 参数无效:必须是非空字符串",
+            ),
         )
 
     # aweme_id 应该是纯数字字符串,长度约 19 位
     if not content_id.isdigit():
         logger.error("get_content_fans_portrait invalid aweme_id format", extra={"content_id": content_id})
-        return ToolResult(
-            title="内容点赞用户画像获取失败",
-            output="",
-            error=f"content_id 格式错误:aweme_id 应该是纯数字,当前值: {content_id[:20]}...",
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="内容点赞用户画像获取失败",
+                output="",
+                error=f"content_id 格式错误:aweme_id 应该是纯数字,当前值: {content_id[:20]}...",
+            ),
         )
 
     if len(content_id) < 15 or len(content_id) > 25:
         logger.error("get_content_fans_portrait invalid aweme_id length", extra={"content_id": content_id, "length": len(content_id)})
-        return ToolResult(
-            title="内容点赞用户画像获取失败",
-            output="",
-            error=f"content_id 长度异常:期望 15-25 位数字,实际 {len(content_id)} 位",
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="内容点赞用户画像获取失败",
+                output="",
+                error=f"content_id 长度异常:期望 15-25 位数字,实际 {len(content_id)} 位",
+            ),
         )
 
     try:
@@ -350,15 +421,19 @@ async def get_content_fans_portrait(
             }
         )
 
-        return ToolResult(
-            title=f"内容点赞用户画像: {content_id}",
-            output="\n".join(summary_lines),
-            long_term_memory=f"Fetched fans portrait for content '{content_id}'",
-            metadata={
-                "raw_data": data,
-                "has_portrait": has_valid_portrait,
-                "portrait_data": portrait
-            }
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title=f"内容点赞用户画像: {content_id}",
+                output="\n".join(summary_lines),
+                long_term_memory=f"Fetched fans portrait for content '{content_id}'",
+                metadata={
+                    "raw_data": data,
+                    "has_portrait": has_valid_portrait,
+                    "portrait_data": portrait,
+                },
+            ),
         )
     except requests.exceptions.HTTPError as e:
         logger.error(
@@ -369,31 +444,47 @@ async def get_content_fans_portrait(
                 "error": str(e)
             }
         )
-        return ToolResult(
-            title="内容点赞用户画像获取失败",
-            output="",
-            error=f"HTTP {e.response.status_code}: {e.response.text}",
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="内容点赞用户画像获取失败",
+                output="",
+                error=f"HTTP {e.response.status_code}: {e.response.text}",
+            ),
         )
     except requests.exceptions.Timeout:
         logger.error("get_content_fans_portrait timeout", extra={"content_id": content_id, "timeout": request_timeout})
-        return ToolResult(
-            title="内容点赞用户画像获取失败",
-            output="",
-            error=f"请求超时({request_timeout}秒)",
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="内容点赞用户画像获取失败",
+                output="",
+                error=f"请求超时({request_timeout}秒)",
+            ),
         )
     except requests.exceptions.RequestException as e:
         logger.error("get_content_fans_portrait network error", extra={"content_id": content_id, "error": str(e)})
-        return ToolResult(
-            title="内容点赞用户画像获取失败",
-            output="",
-            error=f"网络错误: {str(e)}",
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="内容点赞用户画像获取失败",
+                output="",
+                error=f"网络错误: {str(e)}",
+            ),
         )
     except Exception as e:
         logger.error("get_content_fans_portrait unexpected error", extra={"content_id": content_id, "error": str(e)}, exc_info=True)
-        return ToolResult(
-            title="内容点赞用户画像获取失败",
-            output="",
-            error=f"未知错误: {str(e)}",
+        return _log_return(
+            _LABEL_CONTENT,
+            call_params,
+            ToolResult(
+                title="内容点赞用户画像获取失败",
+                output="",
+                error=f"未知错误: {str(e)}",
+            ),
         )
 
 def _top_k(items: Dict[str, Any], k: int) -> List[Tuple[str, Any]]:

+ 16 - 4
examples/content_finder/tools/store_results_mysql.py

@@ -14,9 +14,12 @@ from pathlib import Path
 from typing import Any, Dict
 
 from agent.tools import tool, ToolResult
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 from db import get_connection, insert_contents, upsert_good_authors
 
+_LOG_LABEL = "工具调用:store_results_mysql -> 推荐结果写入MySQL"
+
 logger = logging.getLogger(__name__)
 
 
@@ -38,12 +41,15 @@ async def store_results_mysql(trace_id: str) -> ToolResult:
     根据 trace_id 读取 output.json,并写入 MySQL。
     demand_content_id 从 output 的 demand_id 字段获取,需在 output_schema 中输出。
     """
+    call_params = {"trace_id": trace_id}
     try:
         data = _load_output(trace_id)
     except Exception as e:
         msg = f"加载 output.json 失败: {e}"
         logger.error(msg)
-        return ToolResult(title="存储推荐结果", output=msg, metadata={"ok": False, "error": str(e)})
+        err = ToolResult(title="存储推荐结果", output=msg, metadata={"ok": False, "error": str(e)})
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
 
     demand_content_id = data.get("demand_id")
     if demand_content_id is not None and not isinstance(demand_content_id, int):
@@ -54,7 +60,9 @@ async def store_results_mysql(trace_id: str) -> ToolResult:
     if demand_content_id is None:
         msg = "demand_id 必填:请在 output 的 demand_id 字段中输出(来自 user 消息的搜索词 id)"
         logger.error(msg)
-        return ToolResult(title="存储推荐结果", output=msg, metadata={"ok": False, "error": msg})
+        err = ToolResult(title="存储推荐结果", output=msg, metadata={"ok": False, "error": msg})
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
 
     conn = None
     try:
@@ -71,7 +79,7 @@ async def store_results_mysql(trace_id: str) -> ToolResult:
             f"demand_find_content_result 插入条数={contents_rows}"
         )
         logger.info(output)
-        return ToolResult(
+        out = ToolResult(
             title="存储推荐结果",
             output=output,
             metadata={
@@ -81,10 +89,14 @@ async def store_results_mysql(trace_id: str) -> ToolResult:
                 "contents_inserted": contents_rows,
             },
         )
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(out))
+        return out
     except Exception as e:
         msg = f"写入 MySQL 失败: {e}"
         logger.error(msg, exc_info=True)
-        return ToolResult(title="存储推荐结果", output=msg, metadata={"ok": False, "error": str(e)})
+        err = ToolResult(title="存储推荐结果", output=msg, metadata={"ok": False, "error": str(e)})
+        log_tool_call(_LOG_LABEL, call_params, format_tool_result_for_log(err))
+        return err
     finally:
         if conn is not None:
             conn.close()

+ 2 - 13
examples/content_finder/tools/think_and_plan.py

@@ -1,16 +1,5 @@
-import json
-
 from agent.tools import tool
-from utils.log_capture import log, log_fold
-
-
-def _log_tool_call(tool_name: str, params: dict, result: str) -> None:
-    """以折叠块结构化输出工具调用的参数与返回内容(经 log() 进入 build_log buffer)。"""
-    with log_fold(f"🔧 {tool_name}"):
-        with log_fold("📥 调用参数"):
-            log(json.dumps(params, ensure_ascii=False, indent=2))
-        with log_fold("📤 返回内容"):
-            log(result)
+from utils.tool_logging import format_tool_result_for_log, log_tool_call
 
 
 @tool(
@@ -42,5 +31,5 @@ def think_and_plan(thought: str, thought_number: int, action: str, plan: str) ->
         f"下一步: {action}\n"
         f"(此工具仅用于记录思考过程,不会修改任何数据)"
     )
-    _log_tool_call("think_and_plan", params, result)
+    log_tool_call("think_and_plan", params, format_tool_result_for_log(result))
     return result

+ 38 - 0
examples/content_finder/utils/tool_logging.py

@@ -0,0 +1,38 @@
+"""工具调用日志的通用封装。"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Dict
+
+from .log_capture import log, log_fold
+
+
+def format_tool_result_for_log(result: Any) -> str:
+    """将 ToolResult 或普通字符串格式化为可写入日志的文本(避免过长 metadata 刷屏)。"""
+    if result is None:
+        return ""
+    if isinstance(result, str):
+        s = result
+        return s if len(s) <= 8000 else s[:8000] + "\n...(truncated)"
+    title = getattr(result, "title", "") or ""
+    output = getattr(result, "output", None) or ""
+    err = getattr(result, "error", None)
+    truncated = output if len(output) <= 6000 else output[:6000] + "\n...(truncated)"
+    payload: Dict[str, Any] = {"title": title, "output": truncated}
+    if err:
+        payload["error"] = err
+    md = getattr(result, "metadata", None)
+    if isinstance(md, dict) and md:
+        payload["metadata_keys"] = list(md.keys())
+    return json.dumps(payload, ensure_ascii=False)
+
+
+def log_tool_call(tool_name: str, params: Dict[str, Any], result: str) -> None:
+    """以折叠块结构化输出工具调用参数与返回内容。"""
+    with log_fold(f"🔧 {tool_name}"):
+        with log_fold("📥 调用参数"):
+            log(json.dumps(params, ensure_ascii=False, indent=2))
+        with log_fold("📤 返回内容"):
+            log(result)
+