Prechádzať zdrojové kódy

how agent search update

liuzhiheng 2 mesiacov pred
rodič
commit
67852b0ead

+ 1 - 0
examples_how/overall_derivation/overall_derivation_agent_run.py

@@ -267,6 +267,7 @@ async def main(account_name, post_id):
         ("find_tree_node", "find_tree_node.py"),
         ("find_pattern", "find_pattern.py"),
         ("point_match", "point_match.py"),
+        ("search_and_eval", "search_and_eval.py"),
     ]:
         path = tools_dir / file_name
         if path.is_file():

+ 1 - 7
examples_how/overall_derivation/presets.json

@@ -3,12 +3,6 @@
     "max_iterations": 15,
     "temperature": 0.2,
     "skills": ["planning", "derivation_search"],
-    "description": "选题点推导-信息搜索子 Agent,根据主 agent 传入的参数自主构造搜索 query、执行搜索、调用评估子 agent 对结果进行评估,整理后返回搜索结果和匹配结果"
-  },
-  "derivation_search_eval": {
-    "max_iterations": 15,
-    "temperature": 0.2,
-    "skills": ["planning", "derivation_search_eval"],
-    "description": "选题点推导-搜索评估子 Agent,对搜索结果进行人设关联筛选、候选点提取和匹配判断"
+    "description": "选题点推导-信息搜索子 Agent,根据主 agent 传入的参数自主构造搜索 query、调用 search_and_eval 工具完成搜索与评估,整理后返回搜索结果和匹配结果"
   }
 }

+ 40 - 28
examples_how/overall_derivation/skills/derivation_search.md

@@ -1,19 +1,18 @@
 ---
 name: derivation_search
-description: 选题点推导-信息搜索子 Agent,根据主 agent 传入的参数自主构造搜索 query、执行搜索、调用评估子 agent 对结果进行评估,整理后返回搜索结果和匹配结果
+description: 选题点推导-信息搜索子 Agent,根据主 agent 传入的参数自主构造搜索 query、调用 search_and_eval 工具完成搜索与评估,整理后返回搜索结果和匹配结果
 ---
 
 # 选题点推导 - 信息搜索子任务
 
 ## 角色
-你是选题点推导流程中的**信息搜索协调者**,负责根据主 agent 传入的已推导集合等参数,自主构造搜索 query 并执行搜索,然后调用搜索评估子 agent 对搜索结果进行评估筛选与匹配,最终将搜索结果和评估匹配结果整理后返回给主 agent。
+你是选题点推导流程中的**信息搜索执行者**,负责根据主 agent 传入的已推导集合等参数,自主构造搜索 query,然后调用 `search_and_eval` 工具一次性完成帖子搜索、人设匹配评估和选题点匹配,最终将结果整理后返回给主 agent。
 
 ## 任务描述
 主 agent 在采用「信息搜索」推导方法时,会调用你(`agent_type="derivation_search"`),并在 `task` 中给出 `account_name`、`post_id`、已推导集合等参数。你的职责是:
 1. **构造搜索 query**:根据传入的已推导集合参数,按照关键词约束规则自主构造搜索 query。
-2. **执行搜索**:使用内置工具 `search_posts`,以构造的 query 作为搜索关键词执行一次搜索。
-3. **调用评估子 agent**:将搜索结果、账号名称、帖子 ID、已推导集合等信息传递给搜索评估子 agent(`agent_type="derivation_search_eval"`),由其筛选与账号人设相关的内容、提取候选点并调用 `point_match` 进行匹配。
-4. **整理返回**:将搜索 query、搜索结果和评估子 agent 的返回结果整理成结构化文本,返回给主 agent。
+2. **调用 search_and_eval 工具**:传入 `account_name`、`post_id`、构造好的 `query`,由工具完成搜索、人设匹配评估和选题点匹配,一次性返回结果。
+3. **整理返回**:从工具返回的帖子列表中提取候选点和匹配结果,整理成结构化文本返回给主 agent。
 
 ## 输入
 主 agent 传入的 `task` 中**必须包含**以下参数:
@@ -68,32 +67,46 @@ partial_derived_set=[{"source_node":"趣味道具"}]
 - `xxx账号 分享 日常物品`(使用了账号名称)
 - `分享 创意生活 家居改造`("创意生活"和"家居改造"不在合法来源中,属于自行联想)
 
-### 步骤三:执行搜索
-调用工具 **search_posts**,传入步骤二构造的 query(及你认为合理的条数等参数),执行一次搜索。
-**`search_post`工具参数使用规则**
-- 搜索渠道 `channel` 优先使用小红书(xhs),如果小红书渠道不可用(搜索出现错误或者返回空数据),使用知乎(zhihu)再搜索一次
-
-### 步骤四:调用评估子 agent
-将搜索结果及相关参数传递给搜索评估子 agent,调用方式:
-```
-agent(task="评估搜索结果\naccount_name=<账号名称>\npost_id=<帖子ID>\nderived_success_set=<JSON数组>\npartial_derived_set=<JSON数组>\nsearch_results=<搜索结果原始数据>", agent_type="derivation_search_eval")
+### 步骤三:调用 search_and_eval 工具
+调用工具 **search_and_eval**,传入以下参数:
+- `account_name`:账号名称
+- `post_id`:帖子 ID
+- `query`:步骤二构造的搜索词
+
+工具内部会自动完成:搜索(优先 xhs,失败或空则降级 zhihu)、人设匹配评估、关键词提取和选题点匹配,无需额外操作。
+
+工具返回的数据结构为帖子列表,每项包含:
+```json
+{
+  "channel_content_id": "帖子ID",
+  "title": "标题",
+  "body_text": "正文",
+  "images": ["图片URL"],
+  "persona_match_result": true,
+  "post_keywords": ["关键词1", "关键词2"],
+  "point_match_results": [
+    {"推导选题点": "关键词1", "帖子选题点": "xxx", "匹配分数": 0.85}
+  ]
+}
 ```
 
-传递给评估子 agent 的 `search_results` 应包含搜索返回的完整内容(过长时保留前 10 条),确保评估子 agent 有足够信息进行筛选。
-
-### 步骤五:整理返回
-根据搜索结果和评估子 agent 的返回,按以下固定格式返回给主 agent:
+### 步骤四:整理返回
+从工具返回的帖子列表中提取数据,按以下固定格式返回给主 agent:
 
 ```
 【query】<本次实际使用的搜索关键词>
 
-【result】<摘要:概括搜索结果中与选题相关的关键主题、高频词、可能的推导方向,约 100~200 字>
+【result】<摘要:概括搜索结果中与账号人设相关的内容、高频关键词、可能的推导方向,约 100~200 字>
 
-【raw_result】<搜索工具返回的原始结果(过长时保留前 5~10 条,其余省略)>
+【raw_result】<search_and_eval 工具返回的帖子列表>
 
-【candidate_points】<评估子 agent 筛选出的推导候选点列表,如:["家居改造利用", "废旧物品利用"]>
+【candidate_points】<从所有 persona_match_result=true 的帖子中汇总去重后的 post_keywords,格式为 JSON 数组,如:["家居改造利用", "废旧物品利用"]>
 
-【match_result】<评估子 agent 调用 point_match 后返回的匹配结果,格式为 JSON 数组,每项包含 candidate_point、is_matched、matched_post_point、matched_score,如:
+【match_result】<从所有帖子的 point_match_results 中汇总并转换为以下格式的 JSON 数组,每项包含 candidate_point、is_matched、matched_post_point、matched_score:
+- 若某关键词在 point_match_results 中存在匹配记录,则 is_matched=true,填入最高分的 matched_post_point 和 matched_score
+- 若某关键词在 candidate_points 中但 point_match_results 无对应记录,则 is_matched=false,matched_post_point 和 matched_score 为 null
+
+示例:
 [
   {"candidate_point": "家居改造利用", "is_matched": true, "matched_post_point": "家居改造", "matched_score": 0.85},
   {"candidate_point": "废旧物品利用", "is_matched": false, "matched_post_point": null, "matched_score": null}
@@ -101,12 +114,11 @@ agent(task="评估搜索结果\naccount_name=<账号名称>\npost_id=<帖子ID>\
 ```
 
 **异常处理**:
-- 若 `search_posts` 返回空结果或无相关内容,`result` 填写"未找到相关内容",`raw_result` 返回原始空结果,`candidate_points` 为空数组 `[]`,`match_result` 为空数组 `[]`,**不得捏造任何内容**,也无需调用评估子 agent
-- 若评估子 agent 返回无候选点(搜索结果与账号人设无关联),`candidate_points` 为空数组 `[]`,`match_result` 为空数组 `[]`。
+- 若 `search_and_eval` 返回空列表,`result` 填写"未找到相关内容",`raw_result` 填写空数组,`candidate_points` 为 `[]`,`match_result` 为 `[]`,**不得捏造任何内容**
+- 若所有帖子的 `persona_match_result` 均为 `false`(搜索结果与账号人设无关联),`candidate_points` 为 `[]`,`match_result` 为 `[]`。
 
 ## 约束
-- **仅执行一次搜索**:每次被调用只调用一次 `search_posts`,不要多轮搜索或合并历史结果。
+- **仅调用一次 search_and_eval**:每次被调用只调用一次 `search_and_eval` 工具,不要多轮搜索或合并历史结果。
 - **闭眼搜索**:query 中的关键词**只能来自** `derived_success_set` 的 `topic`/`source_node` 以及 `partial_derived_set` 的 `source_node`,不得自行编造或联想新关键词,不得使用账号名称。
-- **仅调用一次评估子 agent**:每次搜索后只调用一次 `derivation_search_eval` 子 agent。
-- **不替主 agent 做推导**:你只负责构造 query、执行搜索、协调评估、整理返回结果。不判断"能推导出哪些选题点"或"该选题点是否应加入推导集合";由主 agent 根据你的返回整理推导路径。
-- **不直接调用 point_match**:`point_match` 的调用由评估子 agent 负责,你不得直接调用。
+- **不替主 agent 做推导**:你只负责构造 query、调用工具、整理返回结果。不判断"能推导出哪些选题点"或"该选题点是否应加入推导集合";由主 agent 根据你的返回整理推导路径。
+- **不直接调用 search_posts 或 point_match**:搜索、评估、匹配均由 `search_and_eval` 工具内部完成,你不得单独调用这些工具。

+ 0 - 95
examples_how/overall_derivation/skills/derivation_search_eval.md

@@ -1,95 +0,0 @@
----
-name: derivation_search_eval
-description: 选题点推导-搜索评估子 Agent,对搜索结果进行人设关联筛选、候选点提取和匹配判断
----
-
-# 选题点推导 - 搜索评估子任务
-
-## 角色
-你是选题点推导流程中的**搜索结果评估者**,负责对搜索子 agent 传入的搜索结果进行评估:筛选出与账号人设相关联的内容,从中提取推导候选点(关键词),并调用 `point_match` 工具对候选点进行匹配判断,最终将候选点列表和匹配结果返回给搜索子 agent。
-
-## 任务描述
-搜索子 agent(`derivation_search`)执行搜索后,会调用你(`agent_type="derivation_search_eval"`),在 `task` 中传入搜索结果及相关参数。你的职责:
-1. **筛选关联内容**:从搜索结果中筛选出与账号人设相关联的内容。
-2. **提取候选点**:从筛选后的内容中提取推导候选点(关键词),作为可能的推导选题点。
-3. **调用匹配工具**:使用 `point_match` 工具对候选点进行匹配判断。
-4. **返回结果**:将候选点列表和匹配结果返回给搜索子 agent。
-
-## 输入
-搜索子 agent 传入的 `task` 中包含以下参数:
-
-- **account_name**:账号名称
-- **post_id**:帖子 ID
-- **derived_success_set**:完全推导成功的选题点列表,每项包含 `topic` 和 `source_node`
-- **partial_derived_set**:部分推导成功的选题点列表,每项只包含 `source_node`
-- **search_results**:搜索工具返回的原始结果数据
-
-## 操作步骤
-
-### 步骤一:筛选与账号人设相关联的内容
-
-1. 读取账号人设数据: 从 `/Users/liuzhiheng/work/aigc/code/Agent/examples_how/overall_derivation/input/家有大志/persona_data/persona_summary.json` 文件中读取账号人设内容,主要 `{account_name}` 要替换成具体的账号名称
-
-2. 从 `search_results` 中逐条分析,筛选出与账号人设相关联的内容。判断依据:
-- 内容反映了账号相关的创作方向、题材、风格或表达方式
-
-**注意**:
-- 筛选时以**语义相关性**为标准,不要求关键词完全一致
-- 若搜索结果中无任何与账号人设相关的内容,直接返回空结果
-- **禁止**基于大模型自身世界知识联想出搜索结果中不存在的内容
-
-### 步骤二:提取推导候选点
-
-从步骤一筛选出的关联内容中,提取推导候选点(关键词)。候选点应满足:
-- 是搜索结果中**明确出现**的主题词、关键词或核心概念
-- 与账号人设存在关联,有可能成为帖子的选题点
-- **排除**已完全推导成功的选题点(`derived_success_set` 中的 `topic`),避免重复
-- 候选点使用**简洁的名词短语**表述(如"家居改造利用"、"废旧物品创意"),不使用完整句子
-
-**注意**:
-- 候选点必须来源于搜索结果,**不得**自行编造或联想
-- 每次评估提取的候选点数量建议控制在 3~8 个,过多会降低匹配精度
-- 部分推导成功的选题点(`partial_derived_set` 中的 `source_node` 对应的内容)可以作为候选点,以争取更高匹配分数
-
-### 步骤三:调用匹配工具
-
-将步骤二提取的候选点列表传入 `point_match` 工具进行匹配判断:
-- 调用 `point_match`,传入候选点列表、`account_name`、`post_id`
-- **仅调用一次** `point_match`,将所有候选点一次性传入
-- 读取工具返回的匹配结果
-
-若步骤二无候选点(搜索结果与人设无关联),则跳过此步骤。
-
-### 步骤四:整理返回
-
-按以下固定格式返回给搜索子 agent:
-
-```
-【candidate_points】<候选点列表,JSON 数组格式,如 ["家居改造利用", "废旧物品利用"]>
-
-【match_result】<point_match 工具返回的匹配结果,整理为 JSON 数组格式,每项包含:
-- candidate_point: 候选点名称
-- is_matched: 布尔值,是否匹配成功
-- matched_post_point: 匹配到的帖子选题点名称(未匹配则为 null)
-- matched_score: 匹配分数(未匹配则为 null)
-
-示例:
-[
-  {"candidate_point": "家居改造利用", "is_matched": true, "matched_post_point": "家居改造", "matched_score": 0.85},
-  {"candidate_point": "废旧物品利用", "is_matched": false, "matched_post_point": null, "matched_score": null}
-]>
-
-【eval_summary】<简要评估说明:筛选了哪些关联内容、为何提取这些候选点、匹配结果概况,约 50~100 字>
-```
-
-**异常处理**:
-- 若搜索结果中无任何与账号人设相关的内容:`candidate_points` 为 `[]`,`match_result` 为 `[]`,`eval_summary` 说明"搜索结果与账号人设无关联内容"。
-- 若 `point_match` 返回结果中所有候选点均未匹配:如实返回所有 `is_matched=false` 的记录,**不得捏造匹配结果**。
-
-## 约束
-- **仅调用一次 `point_match`**:每次被调用只调用一次 `point_match`,将所有候选点一次性传入。
-- **候选点来源于搜索结果**:候选点必须从搜索结果中提取,不得自行编造或联想。
-- **排除已完全推导成功的选题点**:`derived_success_set` 中的 `topic` 不作为候选点(已无需再次匹配)。
-- **不替上游做推导决策**:你只负责筛选、提取、匹配,不判断"该选题点应加入哪个集合"或"推导路径如何组织";这些由主 agent 负责。
-- **不直接调用 `search_posts`**:搜索由搜索子 agent 负责,你不得执行搜索。
-- **忠实于工具返回**:匹配结果必须如实反映 `point_match` 工具的返回,不得修改或捏造匹配分数。

+ 39 - 0
examples_how/overall_derivation/tools/match_and_extract_prompt.md

@@ -0,0 +1,39 @@
+# 角色
+你是一个账号内容分析师,负责分析搜索到的帖子内容,判断该帖子是否与账号人设相关联,并从帖子中提取核心关键词(作为潜在的推导选题点)。
+
+# 账号人设
+{persona}
+
+# 任务
+分析传入的帖子(包含标题、正文、图集),完成以下两项工作:
+1. **人设匹配判断**:判断该帖子与账号人设在创作方向、题材、风格或表达方式上是否存在语义关联,并输出判断理由。
+2. **关键词提取**:从帖子内容中提取核心关键词,作为可能的推导选题点候选
+
+# 人设匹配判断标准
+**persona_match_result = true**(满足其中之一即可):
+- 帖子题材、主题与账号核心创作方向高度相关
+- 帖子风格、创作手法与账号表达方式相似
+- 帖子内容反映了账号受众关注的生活方式、兴趣领域或消费场景
+
+**persona_match_result = false**:
+- 帖子与账号人设在主题、风格、受众、内容领域上无任何关联
+- 判断时以语义相关性为标准,不要求关键词完全一致
+
+# 关键词提取规则
+- 提取帖子中**明确出现**的主题词、核心概念(可参考标题、正文话题标签、图片可见内容)
+- 使用简洁的名词短语表述(如"DIY改造"、"脑洞创意"、"趣味道具"、"场景化种草")
+- 每个帖子提取 3~8 个关键词,过多会降低精度
+- **禁止**自行编造或联想帖子中不存在的关键词
+- 若 persona_match_result = false,post_keywords 仍应提取帖子本身的关键词(不受人设限制)
+
+# 输出格式
+**必须**严格按以下 JSON 格式返回,不包含任何额外说明或文字:
+
+```json
+{
+    "channel_content_id": "帖子的channel_content_id原值",
+    "persona_match_result": true,
+    "persona_match_reason": "人设匹配判断理由",
+    "post_keywords": ["关键词1", "关键词2", "关键词3"]
+}
+```

+ 359 - 0
examples_how/overall_derivation/tools/search_and_eval.py

@@ -0,0 +1,359 @@
+"""
+搜索评估工具:搜索帖子并评估是否与账号人设匹配,提取关键词并匹配选题点。
+
+处理流程:
+1. 使用 xhs(失败或空则用 zhihu)搜索帖子
+2. 并发对每篇帖子调用 LLM 判断人设匹配 & 提取关键词
+3. 对匹配人设的帖子,调用 match_derivation_to_post_points 匹配选题点
+4. 返回完整评估结果列表
+"""
+
+import asyncio
+import json
+import logging
+import re
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+import httpx
+
+# 保证直接运行或作为包加载时都能解析 utils/tools(IDE 可跳转)
+_root = Path(__file__).resolve().parent.parent
+if str(_root) not in sys.path:
+    sys.path.insert(0, str(_root))
+
+from tools.point_match import match_derivation_to_post_points
+
+try:
+    from agent.tools import tool, ToolResult, ToolContext
+    from agent.llm.openrouter import openrouter_llm_call
+except ImportError:
+    def tool(*args, **kwargs):
+        return lambda f: f
+    ToolResult = None
+    ToolContext = None
+    openrouter_llm_call = None
+
+_BASE_INPUT = Path(__file__).resolve().parent.parent / "input"
+_TOOLS_DIR = Path(__file__).resolve().parent
+
+BASE_URL = "http://aigc-channel.aiddit.com/aigc/channel"
+DEFAULT_TIMEOUT = 60.0
+
+# 支持多模态(视觉+文本)的 LLM 模型
+EVAL_LLM_MODEL = "google/gemini-3-flash-preview"
+
+# 每篇帖子最多传入的图片数量(避免 token 过多)
+MAX_IMAGES_PER_POST = 20
+
+
+def _load_match_and_extract_prompt() -> str:
+    """读取帖子人设匹配 & 关键词提取的 system prompt 模板"""
+    prompt_file = _TOOLS_DIR / "match_and_extract_prompt.md"
+    with open(prompt_file, "r", encoding="utf-8") as f:
+        return f.read()
+
+
+def _load_persona_text(account_name: str) -> str:
+    """读取账号人设摘要,返回可读字符串;文件不存在时返回空人设提示"""
+    persona_file = _BASE_INPUT / account_name / "persona_data" / "persona_summary.json"
+    if not persona_file.is_file():
+        logger.warning("_load_persona_text: persona file not found: %s", persona_file)
+        return f"账号:{account_name}(暂无人设数据)"
+    with open(persona_file, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    logger.debug("_load_persona_text: loaded persona for account=%s", account_name)
+    return json.dumps(data, ensure_ascii=False, indent=2)
+
+
+async def _do_search(query: str, channel: str) -> Optional[List[dict]]:
+    """执行单次搜索,返回帖子列表;失败或空列表返回 None"""
+    logger.debug("_do_search: channel=%s, query=%s", channel, query)
+    payload = {
+        "type": channel,
+        "keyword": query,
+        "cursor": "0",
+        "max_count": 5,
+        "content_type": "图文",
+    }
+    try:
+        async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
+            resp = await client.post(
+                f"{BASE_URL}/data",
+                json=payload,
+                headers={"Content-Type": "application/json"},
+            )
+            resp.raise_for_status()
+            data = resp.json()
+        posts = data.get("data") or []
+        count = len(posts) if posts else 0
+        logger.info("_do_search: channel=%s, query=%s -> %d posts", channel, query, count)
+        return posts if posts else None
+    except Exception as e:
+        logger.warning("_do_search: channel=%s, query=%s failed: %s", channel, query, e)
+        return None
+
+
+async def _search_posts(query: str) -> List[dict]:
+    """优先用 xhs 搜索,失败或空则用 zhihu,返回帖子列表"""
+    posts = await _do_search(query, "xhs")
+    if posts:
+        logger.info("_search_posts: using xhs, %d posts for query=%s", len(posts), query)
+        return posts
+    posts = await _do_search(query, "zhihu")
+    if posts:
+        logger.info("_search_posts: xhs empty/failed, using zhihu, %d posts for query=%s", len(posts), query)
+    else:
+        logger.warning("_search_posts: no posts from xhs or zhihu for query=%s", query)
+    return posts or []
+
+
+def _build_user_message_content(post: dict) -> List[dict]:
+    """
+    将帖子数据构建为 OpenAI 多模态 user message content。
+    包含帖子文本描述 + 前 MAX_IMAGES_PER_POST 张图片。
+    """
+    parts: List[dict] = []
+
+    # 文本部分:将帖子的关键字段序列化给 LLM
+    post_text = json.dumps(
+        {
+            "channel_content_id": post.get("channel_content_id", ""),
+            "title": post.get("title", ""),
+            "body_text": post.get("body_text", ""),
+        },
+        ensure_ascii=False,
+    )
+    parts.append({"type": "text", "text": post_text})
+
+    # 图片部分
+    images = post.get("images") or []
+    for img_url in images[:MAX_IMAGES_PER_POST]:
+        if img_url:
+            parts.append({"type": "image_url", "image_url": {"url": img_url}})
+
+    return parts
+
+
+def _extract_json_object(content: str) -> dict:
+    """从 LLM 回复中解析第一个 JSON 对象(允许被 ```json ... ``` 包裹)"""
+    content = content.strip()
+    m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
+    if m:
+        content = m.group(1).strip()
+    # 找到最外层 { ... }
+    start = content.find("{")
+    end = content.rfind("}")
+    if start != -1 and end != -1:
+        content = content[start : end + 1]
+    return json.loads(content)
+
+
+async def _eval_single_post(
+    post: dict,
+    system_prompt: str,
+    account_name: str,
+    post_id: str,
+) -> dict:
+    """
+    评估单篇帖子:
+    1. 调用 LLM 判断人设匹配并提取关键词
+    2. 若匹配,调用 match_derivation_to_post_points 匹配选题点
+    返回完整评估结果字典。
+    """
+    post_cid = post.get("channel_content_id", "")
+    result: dict = {
+        "channel_content_id": post_cid,
+        "title": post.get("title", ""),
+        "body_text": post.get("body_text", ""),
+        "images": post.get("images") or [],
+        "persona_match_result": False,
+        "persona_match_reason": "",
+        "post_keywords": [],
+        "point_match_results": [],
+    }
+
+    try:
+        logger.debug("_eval_single_post: evaluating post_id=%s, title=%s", post_cid, (result["title"] or "")[:40])
+        user_content = _build_user_message_content(post)
+        messages = [
+            {"role": "system", "content": system_prompt},
+            {"role": "user", "content": user_content},
+        ]
+
+        llm_result = await openrouter_llm_call(messages, model=EVAL_LLM_MODEL)
+        content = llm_result.get("content", "")
+        if not content:
+            result["error"] = "LLM 未返回内容"
+            logger.warning("_eval_single_post: post_id=%s LLM returned empty content", post_cid)
+            return result
+
+        parsed = _extract_json_object(content)
+        result["persona_match_result"] = bool(parsed.get("persona_match_result", False))
+        result["persona_match_reason"] = parsed.get("persona_match_reason", "")
+        result["post_keywords"] = parsed.get("post_keywords") or []
+        logger.info(
+            "_eval_single_post: post_id=%s persona_match=%s keywords=%s",
+            post_cid,
+            result["persona_match_result"],
+            result["post_keywords"],
+        )
+
+        # 仅对与人设匹配的帖子做选题点匹配
+        if result["persona_match_result"] and result["post_keywords"]:
+            matched = await match_derivation_to_post_points(
+                result["post_keywords"], account_name, post_id
+            )
+            result["point_match_results"] = matched
+            logger.info(
+                "_eval_single_post: post_id=%s point_match count=%d",
+                post_cid,
+                len(matched),
+            )
+
+    except Exception as e:
+        logger.exception("_eval_single_post: post_id=%s error: %s", post_cid, e)
+        result["error"] = str(e)
+
+    return result
+
+
+@tool(
+    description=(
+        "搜索帖子并评估是否与账号人设匹配,提取帖子关键词并与帖子选题点进行匹配。"
+        "参数:account_name 账号名称;post_id 帖子ID;query 搜索词。"
+    )
+)
+async def search_and_eval(
+    account_name: str,
+    post_id: str,
+    query: str,
+    context: Optional[ToolContext] = None,
+) -> ToolResult:
+    """
+    搜索帖子并评估是否与账号人设匹配,提取关键词并匹配选题点。
+
+    Args:
+        account_name: 账号名称,用于读取人设数据和选题点文件
+        post_id: 帖子ID,用于定位选题点匹配文件
+        query: 搜索词
+
+    Returns:
+        ToolResult,output 为 JSON 格式的帖子评估结果列表,每项包含:
+        - channel_content_id: 帖子ID
+        - title: 标题
+        - body_text: 正文
+        - images: 图集URL列表
+        - persona_match_result: 是否与账号人设匹配(bool)
+        - post_keywords: 提取的帖子关键词列表
+        - point_match_results: 关键词与帖子选题点的匹配结果列表,
+          每项含「推导选题点」「帖子选题点」「匹配分数」
+    """
+    logger.info(
+        "search_and_eval: account_name=%s post_id=%s query=%s",
+        account_name,
+        post_id,
+        query,
+    )
+    try:
+        # 1. 搜索帖子
+        posts = await _search_posts(query)
+        if not posts:
+            logger.warning("search_and_eval: no posts found for query=%s", query)
+            return ToolResult(
+                title=f"搜索评估: {query}",
+                output="[]",
+                long_term_memory=f"search_and_eval: query='{query}', no posts found",
+            )
+
+        logger.info("search_and_eval: got %d posts, loading prompt and persona", len(posts))
+        # 2. 构建 system prompt(替换账号人设)
+        prompt_template = _load_match_and_extract_prompt()
+        persona_text = _load_persona_text(account_name)
+        system_prompt = prompt_template.replace("{persona}", persona_text)
+
+        # 3. 并发评估所有帖子
+        tasks = [
+            _eval_single_post(post, system_prompt, account_name, post_id)
+            for post in posts
+        ]
+        results: List[dict] = await asyncio.gather(*tasks)
+
+        matched_count = sum(1 for r in results if r.get("persona_match_result"))
+        error_count = sum(1 for r in results if r.get("error"))
+        logger.info(
+            "search_and_eval: done. total=%d persona_matched=%d errors=%d",
+            len(results),
+            matched_count,
+            error_count,
+        )
+        output = json.dumps(results, ensure_ascii=False, indent=2)
+        logger.info("search_and_eval: output=%s", output)
+
+        return ToolResult(
+            title=(
+                f"搜索评估: {query} "
+                f"(共 {len(results)} 条,{matched_count} 条匹配人设)"
+            ),
+            output=output,
+            long_term_memory=(
+                f"search_and_eval: query='{query}', "
+                f"found {len(results)} posts, {matched_count} matched persona"
+            ),
+            metadata={"items": results},
+        )
+
+    except Exception as e:
+        logger.exception("search_and_eval: failed: %s", e)
+        return ToolResult(
+            title="搜索评估失败",
+            output="",
+            error=str(e),
+        )
+
+
+def main() -> None:
+    """本地测试:用家有大志账号测试搜索评估"""
+    import asyncio
+
+    logging.basicConfig(
+        level=logging.DEBUG,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+        datefmt="%H:%M:%S",
+    )
+    account_name = "家有大志"
+    post_id = "68fb6a5c000000000302e5de"
+    query = "柴犬 鞋子"
+
+    async def run():
+        if ToolResult is None:
+            print("agent 依赖未安装,无法直接运行 tool 版本")
+            return
+        result = await search_and_eval(
+            account_name=account_name,
+            post_id=post_id,
+            query=query,
+        )
+        if result.error:
+            print(f"Error: {result.error}")
+        else:
+            print(result.title)
+            data = json.loads(result.output)
+            for item in data:
+                print(
+                    f"  [{item.get('persona_match_result')}] {item.get('title', '')[:30]}"
+                    f" | keywords: {item.get('post_keywords')}"
+                    f" | matches: {len(item.get('point_match_results', []))}"
+                )
+
+    asyncio.run(run())
+
+
+if __name__ == "__main__":
+    _project_root = str(Path(__file__).resolve().parent.parent.parent.parent)
+    if _project_root not in sys.path:
+        sys.path.insert(0, _project_root)
+    main()