knowledge.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262
  1. """
  2. 原子知识保存工具
  3. 提供便捷的 API 让 Agent 快速保存结构化的原子知识
  4. """
  5. import os
  6. import re
  7. import json
  8. import yaml
  9. import logging
  10. from datetime import datetime
  11. from pathlib import Path
  12. from typing import List, Dict, Optional, Any
  13. from agent.tools import tool, ToolResult, ToolContext
  14. from ...llm.openrouter import openrouter_llm_call
  15. from agent.core.prompts import (
  16. KNOWLEDGE_SEMANTIC_ROUTE_PROMPT_TEMPLATE,
  17. KNOWLEDGE_EVOLVE_PROMPT_TEMPLATE,
  18. KNOWLEDGE_SLIM_PROMPT_TEMPLATE,
  19. build_knowledge_semantic_route_prompt,
  20. build_knowledge_evolve_prompt,
  21. build_knowledge_slim_prompt,
  22. build_experience_entry,
  23. REFLECT_PROMPT,
  24. )
  25. from agent.llm.openrouter import openrouter_llm_call
  26. logger = logging.getLogger(__name__)
  27. def _generate_knowledge_id() -> str:
  28. """生成知识原子 ID(带微秒和随机后缀避免冲突)"""
  29. import uuid
  30. timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
  31. random_suffix = uuid.uuid4().hex[:4]
  32. return f"knowledge-{timestamp}-{random_suffix}"
  33. def _format_yaml_list(items: List[str], indent: int = 2) -> str:
  34. """格式化 YAML 列表"""
  35. if not items:
  36. return "[]"
  37. indent_str = " " * indent
  38. return "\n" + "\n".join(f"{indent_str}- {item}" for item in items)
  39. @tool()
  40. async def save_knowledge(
  41. scenario: str,
  42. content: str,
  43. tags_type: List[str],
  44. urls: List[str] = None,
  45. agent_id: str = "research_agent",
  46. score: int = 3,
  47. trace_id: str = "",
  48. ) -> ToolResult:
  49. """
  50. 保存原子知识到本地文件(JSON 格式)
  51. Args:
  52. scenario: 任务描述(在什么情景下 + 要完成什么目标 + 得到能达成一个什么结果)
  53. content: 核心内容
  54. tags_type: 知识类型标签,可选:tool, usercase, definition, plan, strategy
  55. urls: 参考来源链接列表(论文/GitHub/博客等)
  56. agent_id: 执行此调研的 agent ID
  57. score: 初始评分 1-5(默认 3)
  58. trace_id: 当前 trace ID(可选)
  59. Returns:
  60. 保存结果
  61. """
  62. try:
  63. # 生成 ID
  64. knowledge_id = _generate_knowledge_id()
  65. # 准备目录
  66. knowledge_dir = Path(".cache/knowledge_atoms")
  67. knowledge_dir.mkdir(parents=True, exist_ok=True)
  68. # 构建文件路径(使用 .json 扩展名)
  69. file_path = knowledge_dir / f"{knowledge_id}.json"
  70. # 构建 JSON 数据结构
  71. knowledge_data = {
  72. "id": knowledge_id,
  73. "trace_id": trace_id or "N/A",
  74. "tags": {
  75. "type": tags_type
  76. },
  77. "scenario": scenario,
  78. "content": content,
  79. "trace": {
  80. "urls": urls or [],
  81. "agent_id": agent_id,
  82. "timestamp": datetime.now().isoformat()
  83. },
  84. "eval": {
  85. "score": score,
  86. "helpful": 0,
  87. "harmful": 0,
  88. "helpful_history": [],
  89. "harmful_history": []
  90. },
  91. "metrics": {
  92. "helpful": 1,
  93. "harmful": 0
  94. },
  95. "created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  96. }
  97. # 保存为 JSON 文件
  98. with open(file_path, "w", encoding="utf-8") as f:
  99. json.dump(knowledge_data, f, ensure_ascii=False, indent=2)
  100. return ToolResult(
  101. title="✅ 原子知识已保存",
  102. output=f"知识 ID: {knowledge_id}\n文件路径: {file_path}\n\n场景:\n{scenario[:100]}...",
  103. long_term_memory=f"保存原子知识: {knowledge_id} - {scenario[:50]}",
  104. metadata={"knowledge_id": knowledge_id, "file_path": str(file_path)}
  105. )
  106. except Exception as e:
  107. return ToolResult(
  108. title="❌ 保存失败",
  109. output=f"错误: {str(e)}",
  110. error=str(e)
  111. )
  112. async def extract_and_save_experiences(
  113. trace_id: str,
  114. reflection_text: str,
  115. ) -> List[str]:
  116. """
  117. 从 LLM 反思文本中提取结构化经验并保存为原子知识库条目(strategy 标签)。
  118. Args:
  119. trace_id: 当前 Trace ID
  120. reflection_text: LLM 生成的原始文本,期望包含 [- [tags] 内容] 格式
  121. Returns:
  122. 包含提取出的知识 ID 列表
  123. """
  124. if not reflection_text:
  125. return []
  126. # 更灵活的正则解析,匹配 - [intent: ..., state: ...] 经验内容
  127. pattern = r"-\s*\[(?P<tags>.*?)\]\s*(?P<content>.*)"
  128. matches = list(re.finditer(pattern, reflection_text))
  129. saved_ids = []
  130. for match in matches:
  131. tags_str = match.group("tags")
  132. content = match.group("content").strip()
  133. # 提取标签详情
  134. intent_match = re.search(r"intent:\s*(.*?)(?:,|$)", tags_str, re.IGNORECASE)
  135. state_match = re.search(r"state:\s*(.*?)(?:,|$)", tags_str, re.IGNORECASE)
  136. intents = [i.strip() for i in intent_match.group(1).split(",")] if intent_match and intent_match.group(1) else []
  137. states = [s.strip() for s in state_match.group(1).split(",")] if state_match and state_match.group(1) else []
  138. # 构建 scenario
  139. scenario_parts = []
  140. if intents:
  141. scenario_parts.append(f"意图: {', '.join(intents)}")
  142. if states:
  143. scenario_parts.append(f"状态: {', '.join(states)}")
  144. scenario = " | ".join(scenario_parts) if scenario_parts else "通用经验"
  145. # 调用 save_knowledge 直接保存为原子知识
  146. res = await save_knowledge(
  147. scenario=scenario,
  148. content=content,
  149. tags_type=["strategy"],
  150. agent_id="reflection_logger",
  151. trace_id=trace_id
  152. )
  153. if res.metadata and "knowledge_id" in res.metadata:
  154. saved_ids.append(res.metadata["knowledge_id"])
  155. if saved_ids:
  156. logger.info(f"Successfully saved {len(saved_ids)} experiences as atomic strategy entries.")
  157. return saved_ids
  158. async def generate_and_save_reflection(
  159. trace_id: str,
  160. messages: List[Dict[str, Any]],
  161. llm_call_fn: Optional[Any] = None,
  162. model: str = "anthropic/claude-3-5-sonnet",
  163. focus: Optional[str] = None
  164. ) -> str:
  165. """
  166. 自闭环反思逻辑:构建 Prompt -> 调用 LLM -> 提取并保存经验。
  167. Args:
  168. trace_id: 当前轨迹 ID
  169. messages: 历史对话消息列表 (OpenAI 格式)
  170. llm_call_fn: 可选的 LLM 调用函数,默认为 openrouter_llm_call
  171. model: 使用的模型
  172. focus: 可选的关注焦点
  173. Returns:
  174. LLM 生成的原始反思文本
  175. """
  176. # 1. 构建反思 Prompt
  177. prompt = REFLECT_PROMPT
  178. if focus:
  179. prompt += f"\n\n请特别关注:{focus}"
  180. reflect_messages = list(messages) + [{"role": "user", "content": prompt}]
  181. # 2. 调用 LLM
  182. call_fn = llm_call_fn or openrouter_llm_call
  183. try:
  184. logger.info(f"Starting self-contained reflection for trace {trace_id}...")
  185. result = await call_fn(
  186. messages=reflect_messages,
  187. model=model,
  188. temperature=0.2
  189. )
  190. reflection_text = result.get("content", "").strip()
  191. except Exception as e:
  192. logger.error(f"LLM call failed during reflection: {e}")
  193. raise
  194. # 3. 提取并保存经验
  195. if reflection_text:
  196. await extract_and_save_experiences(
  197. trace_id=trace_id,
  198. reflection_text=reflection_text
  199. )
  200. else:
  201. logger.warning(f"No reflection content generated for trace {trace_id}")
  202. return reflection_text
  203. @tool()
  204. async def update_knowledge(
  205. knowledge_id: str,
  206. add_helpful_case: Optional[Dict[str, str]] = None,
  207. add_harmful_case: Optional[Dict[str, str]] = None,
  208. update_score: Optional[int] = None,
  209. evolve_feedback: Optional[str] = None,
  210. ) -> ToolResult:
  211. """
  212. 更新已有的原子知识的评估反馈
  213. Args:
  214. knowledge_id: 知识 ID(如 research-20260302-001)
  215. add_helpful_case: 添加好用的案例 {"case_id": "...", "scenario": "...", "result": "...", "timestamp": "..."}
  216. add_harmful_case: 添加不好用的案例 {"case_id": "...", "scenario": "...", "result": "...", "timestamp": "..."}
  217. update_score: 更新评分(1-5)
  218. evolve_feedback: 经验进化反馈(当提供时,会使用 LLM 重写知识内容)
  219. Returns:
  220. 更新结果
  221. """
  222. try:
  223. # 查找文件(支持 JSON 和 MD 格式)
  224. knowledge_dir = Path(".cache/knowledge_atoms")
  225. json_path = knowledge_dir / f"{knowledge_id}.json"
  226. md_path = knowledge_dir / f"{knowledge_id}.md"
  227. file_path = None
  228. if json_path.exists():
  229. file_path = json_path
  230. is_json = True
  231. elif md_path.exists():
  232. file_path = md_path
  233. is_json = False
  234. else:
  235. return ToolResult(
  236. title="❌ 文件不存在",
  237. output=f"未找到知识文件: {knowledge_id}",
  238. error="文件不存在"
  239. )
  240. # 读取现有内容
  241. with open(file_path, "r", encoding="utf-8") as f:
  242. content = f.read()
  243. # 解析数据
  244. if is_json:
  245. data = json.loads(content)
  246. else:
  247. # 解析 YAML frontmatter
  248. yaml_match = re.search(r'^---\n(.*?)\n---', content, re.DOTALL)
  249. if not yaml_match:
  250. return ToolResult(
  251. title="❌ 格式错误",
  252. output=f"无法解析知识文件格式: {file_path}",
  253. error="格式错误"
  254. )
  255. data = yaml.safe_load(yaml_match.group(1))
  256. # 更新内容
  257. updated = False
  258. summary = []
  259. if add_helpful_case:
  260. data["eval"]["helpful"] += 1
  261. data["eval"]["helpful_history"].append(add_helpful_case)
  262. data["metrics"]["helpful"] += 1
  263. summary.append(f"添加 helpful 案例: {add_helpful_case.get('case_id')}")
  264. updated = True
  265. if add_harmful_case:
  266. data["eval"]["harmful"] += 1
  267. data["eval"]["harmful_history"].append(add_harmful_case)
  268. data["metrics"]["harmful"] += 1
  269. summary.append(f"添加 harmful 案例: {add_harmful_case.get('case_id')}")
  270. updated = True
  271. if update_score is not None:
  272. data["eval"]["score"] = update_score
  273. summary.append(f"更新评分: {update_score}")
  274. updated = True
  275. # 经验进化机制
  276. if evolve_feedback:
  277. old_content = data.get("content", "")
  278. evolved_content = await _evolve_knowledge_with_llm(old_content, evolve_feedback)
  279. data["content"] = evolved_content
  280. data["metrics"]["helpful"] += 1
  281. summary.append(f"知识进化: 基于反馈重写内容")
  282. updated = True
  283. if not updated:
  284. return ToolResult(
  285. title="⚠️ 无更新",
  286. output="未指定任何更新内容",
  287. long_term_memory="尝试更新原子知识但未指定更新内容"
  288. )
  289. # 更新时间戳
  290. data["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  291. # 保存更新
  292. if is_json:
  293. with open(file_path, "w", encoding="utf-8") as f:
  294. json.dump(data, f, ensure_ascii=False, indent=2)
  295. else:
  296. # 重新生成 YAML frontmatter
  297. meta_str = yaml.dump(data, allow_unicode=True).strip()
  298. with open(file_path, "w", encoding="utf-8") as f:
  299. f.write(f"---\n{meta_str}\n---\n")
  300. return ToolResult(
  301. title="✅ 原子知识已更新",
  302. output=f"知识 ID: {knowledge_id}\n文件路径: {file_path}\n\n更新内容:\n" + "\n".join(f"- {s}" for s in summary),
  303. long_term_memory=f"更新原子知识: {knowledge_id}"
  304. )
  305. except Exception as e:
  306. return ToolResult(
  307. title="❌ 更新失败",
  308. output=f"错误: {str(e)}",
  309. error=str(e)
  310. )
  311. @tool()
  312. async def list_knowledge(
  313. limit: int = 10,
  314. tags_type: Optional[List[str]] = None,
  315. ) -> ToolResult:
  316. """
  317. 列出已保存的原子知识
  318. Args:
  319. limit: 返回数量限制(默认 10)
  320. tags_type: 按类型过滤(可选)
  321. Returns:
  322. 知识列表
  323. """
  324. try:
  325. knowledge_dir = Path(".cache/knowledge_atoms")
  326. if not knowledge_dir.exists():
  327. return ToolResult(
  328. title="📂 知识库为空",
  329. output="还没有保存任何原子知识",
  330. long_term_memory="知识库为空"
  331. )
  332. # 获取所有文件
  333. files = sorted(knowledge_dir.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True)
  334. if not files:
  335. return ToolResult(
  336. title="📂 知识库为空",
  337. output="还没有保存任何原子知识",
  338. long_term_memory="知识库为空"
  339. )
  340. # 读取并过滤
  341. results = []
  342. for file_path in files[:limit]:
  343. with open(file_path, "r", encoding="utf-8") as f:
  344. content = f.read()
  345. # 提取关键信息
  346. import re
  347. id_match = re.search(r"id: (.+)", content)
  348. scenario_match = re.search(r"scenario: \|\n (.+)", content)
  349. score_match = re.search(r"score: (\d+)", content)
  350. knowledge_id = id_match.group(1) if id_match else "unknown"
  351. scenario = scenario_match.group(1) if scenario_match else "N/A"
  352. score = score_match.group(1) if score_match else "N/A"
  353. results.append(f"- [{knowledge_id}] (⭐{score}) {scenario[:60]}...")
  354. output = f"共找到 {len(files)} 条原子知识,显示最近 {len(results)} 条:\n\n" + "\n".join(results)
  355. return ToolResult(
  356. title="📚 原子知识列表",
  357. output=output,
  358. long_term_memory=f"列出 {len(results)} 条原子知识"
  359. )
  360. except Exception as e:
  361. return ToolResult(
  362. title="❌ 列表失败",
  363. output=f"错误: {str(e)}",
  364. error=str(e)
  365. )
  366. # ===== 语义检索功能 =====
  367. async def _route_knowledge_by_llm(query_text: str, metadata_list: List[Dict], k: int = 5) -> List[str]:
  368. """
  369. 第一阶段:语义路由。
  370. 让 LLM 挑选出 2*k 个语义相关的 ID。
  371. """
  372. if not metadata_list:
  373. return []
  374. # 扩大筛选范围到 2*k
  375. routing_k = k * 2
  376. routing_data = [
  377. {
  378. "id": m["id"],
  379. "tags": m["tags"],
  380. "scenario": m["scenario"][:100] # 只取前100字符
  381. } for m in metadata_list
  382. ]
  383. prompt = build_knowledge_semantic_route_prompt(
  384. query_text=query_text,
  385. routing_data=json.dumps(routing_data, ensure_ascii=False, indent=1),
  386. routing_k=routing_k
  387. )
  388. try:
  389. print(f"\n[Step 1: 知识语义路由] 任务: '{query_text}' | 候选总数: {len(metadata_list)} | 目标提取数: {routing_k}")
  390. response = await openrouter_llm_call(
  391. messages=[{"role": "user", "content": prompt}],
  392. model="google/gemini-2.0-flash-001"
  393. )
  394. content = response.get("content", "").strip()
  395. selected_ids = [idx.strip() for idx in re.split(r'[,\s]+', content) if idx.strip().startswith(("knowledge-", "research-"))]
  396. print(f"[Step 1: 知识语义路由] LLM 初选 ID ({len(selected_ids)}个): {selected_ids}")
  397. return selected_ids
  398. except Exception as e:
  399. logger.error(f"LLM 知识路由失败: {e}")
  400. return []
  401. async def _evolve_knowledge_with_llm(old_content: str, feedback: str) -> str:
  402. """
  403. 使用 LLM 进行知识进化重写(类似经验进化机制)
  404. """
  405. prompt = build_knowledge_evolve_prompt(old_content, feedback)
  406. try:
  407. response = await openrouter_llm_call(
  408. messages=[{"role": "user", "content": prompt}],
  409. model="google/gemini-2.0-flash-001"
  410. )
  411. evolved_content = response.get("content", "").strip()
  412. # 简单安全校验:如果 LLM 返回太短或为空,回退到原内容+追加
  413. if len(evolved_content) < 5:
  414. raise ValueError("LLM output too short")
  415. return evolved_content
  416. except Exception as e:
  417. logger.warning(f"知识进化失败,采用追加模式回退: {e}")
  418. timestamp = datetime.now().strftime('%Y-%m-%d')
  419. return f"{old_content}\n\n---\n[Update {timestamp}]: {feedback}"
  420. async def _route_knowledge_by_llm(query_text: str, metadata_list: List[Dict], k: int = 5) -> List[str]:
  421. """
  422. 第一阶段:语义路由。
  423. 让 LLM 挑选出 2*k 个语义相关的 ID。
  424. """
  425. if not metadata_list:
  426. return []
  427. # 扩大筛选范围到 2*k
  428. routing_k = k * 2
  429. routing_data = [
  430. {
  431. "id": m["id"],
  432. "tags": m["tags"],
  433. "scenario": m["scenario"][:100] # 只取前100字符
  434. } for m in metadata_list
  435. ]
  436. prompt = build_knowledge_semantic_route_prompt(
  437. query_text=query_text,
  438. routing_data=json.dumps(routing_data, ensure_ascii=False, indent=1),
  439. routing_k=routing_k
  440. )
  441. try:
  442. print(f"\n[Step 1: 知识语义路由] 任务: '{query_text}' | 候选总数: {len(metadata_list)} | 目标提取数: {routing_k}")
  443. response = await openrouter_llm_call(
  444. messages=[{"role": "user", "content": prompt}],
  445. model="google/gemini-2.0-flash-001"
  446. )
  447. content = response.get("content", "").strip()
  448. selected_ids = [idx.strip() for idx in re.split(r'[,\s]+', content) if idx.strip().startswith(("knowledge-", "research-"))]
  449. print(f"[Step 1: 知识语义路由] LLM 初选 ID ({len(selected_ids)}个): {selected_ids}")
  450. return selected_ids
  451. except Exception as e:
  452. logger.error(f"LLM 知识路由失败: {e}")
  453. return []
  454. async def _get_structured_knowledge(
  455. query_text: str,
  456. top_k: int = 5,
  457. min_score: int = 3,
  458. context: Optional[Any] = None,
  459. tags_filter: Optional[List[str]] = None
  460. ) -> List[Dict]:
  461. """
  462. 语义检索原子知识(包括经验)
  463. 1. 解析知识库文件(支持 JSON 和 YAML 格式)
  464. 2. 语义路由:提取 2*k 个 ID
  465. 3. 质量精排:基于评分筛选出最终的 k 个
  466. Args:
  467. query_text: 查询文本
  468. top_k: 返回数量
  469. min_score: 最低评分过滤
  470. context: 上下文(兼容 experience 接口)
  471. tags_filter: 标签过滤(如 ["strategy"] 只返回经验)
  472. """
  473. knowledge_dir = Path(".cache/knowledge_atoms")
  474. if not knowledge_dir.exists():
  475. print(f"[Knowledge System] 警告: 知识库目录不存在 ({knowledge_dir})")
  476. return []
  477. # 同时支持 .json 和 .md 文件
  478. json_files = list(knowledge_dir.glob("*.json"))
  479. md_files = list(knowledge_dir.glob("*.md"))
  480. files = json_files + md_files
  481. if not files:
  482. print(f"[Knowledge System] 警告: 知识库为空")
  483. return []
  484. # --- 阶段 1: 解析所有知识文件 ---
  485. content_map = {}
  486. metadata_list = []
  487. for file_path in files:
  488. try:
  489. with open(file_path, "r", encoding="utf-8") as f:
  490. content = f.read()
  491. # 根据文件扩展名选择解析方式
  492. if file_path.suffix == ".json":
  493. # 解析 JSON 格式
  494. metadata = json.loads(content)
  495. else:
  496. # 解析 YAML frontmatter(兼容旧格式)
  497. yaml_match = re.search(r'^---\n(.*?)\n---', content, re.DOTALL)
  498. if not yaml_match:
  499. logger.warning(f"跳过无效文件: {file_path}")
  500. continue
  501. metadata = yaml.safe_load(yaml_match.group(1))
  502. if not isinstance(metadata, dict):
  503. logger.warning(f"跳过损坏的知识文件: {file_path}")
  504. continue
  505. kid = metadata.get("id")
  506. if not kid:
  507. logger.warning(f"跳过缺少 id 的知识文件: {file_path}")
  508. continue
  509. # 提取 scenario 和 content
  510. scenario = metadata.get("scenario", "").strip()
  511. content_text = metadata.get("content", "").strip()
  512. # 标签过滤
  513. tags = metadata.get("tags", {})
  514. if tags_filter:
  515. # 检查 tags.type 是否包含任何过滤标签
  516. tag_types = tags.get("type", [])
  517. if isinstance(tag_types, str):
  518. tag_types = [tag_types]
  519. if not any(tag in tag_types for tag in tags_filter):
  520. continue # 跳过不匹配的标签
  521. meta_item = {
  522. "id": kid,
  523. "tags": tags,
  524. "scenario": scenario,
  525. "score": metadata.get("eval", {}).get("score", 3),
  526. "helpful": metadata.get("metrics", {}).get("helpful", 0),
  527. "harmful": metadata.get("metrics", {}).get("harmful", 0),
  528. }
  529. metadata_list.append(meta_item)
  530. content_map[kid] = {
  531. "scenario": scenario,
  532. "content": content_text,
  533. "tags": tags,
  534. "score": meta_item["score"],
  535. "helpful": meta_item["helpful"],
  536. "harmful": meta_item["harmful"],
  537. }
  538. except Exception as e:
  539. logger.error(f"解析知识文件失败 {file_path}: {e}")
  540. continue
  541. if not metadata_list:
  542. print(f"[Knowledge System] 警告: 没有有效的知识条目")
  543. return []
  544. # --- 阶段 2: 语义路由 (取 2*k) ---
  545. candidate_ids = await _route_knowledge_by_llm(query_text, metadata_list, k=top_k)
  546. # --- 阶段 3: 质量精排 (根据评分和反馈选出最终的 k) ---
  547. print(f"[Step 2: 知识质量精排] 正在根据评分和反馈进行打分...")
  548. scored_items = []
  549. for kid in candidate_ids:
  550. if kid in content_map:
  551. item = content_map[kid]
  552. score = item["score"]
  553. helpful = item["helpful"]
  554. harmful = item["harmful"]
  555. # 计算综合分:基础分 + helpful - harmful*2
  556. quality_score = score + helpful - (harmful * 2.0)
  557. # 过滤门槛:评分低于 min_score 或质量分过低
  558. if score < min_score or quality_score < 0:
  559. print(f" - 剔除低质量知识: {kid} (Score: {score}, Helpful: {helpful}, Harmful: {harmful})")
  560. continue
  561. scored_items.append({
  562. "id": kid,
  563. "scenario": item["scenario"],
  564. "content": item["content"],
  565. "tags": item["tags"],
  566. "score": score,
  567. "quality_score": quality_score,
  568. "metrics": {
  569. "helpful": helpful,
  570. "harmful": harmful
  571. }
  572. })
  573. # 按照质量分排序
  574. final_sorted = sorted(scored_items, key=lambda x: x["quality_score"], reverse=True)
  575. # 截取最终的 top_k
  576. result = final_sorted[:top_k]
  577. print(f"[Step 2: 知识质量精排] 最终选定知识: {[it['id'] for it in result]}")
  578. print(f"[Knowledge System] 检索结束。\n")
  579. return result
  580. @tool()
  581. async def search_knowledge(
  582. query: str,
  583. top_k: int = 5,
  584. min_score: int = 3,
  585. tags_type: Optional[List[str]] = None,
  586. context: Optional[ToolContext] = None,
  587. ) -> ToolResult:
  588. """
  589. 语义检索原子知识库
  590. Args:
  591. query: 搜索查询(任务描述)
  592. top_k: 返回数量(默认 5)
  593. min_score: 最低评分过滤(默认 3)
  594. tags_type: 按类型过滤(tool/usercase/definition/plan)
  595. context: 工具上下文
  596. Returns:
  597. 相关知识列表
  598. """
  599. try:
  600. relevant_items = await _get_structured_knowledge(
  601. query_text=query,
  602. top_k=top_k,
  603. min_score=min_score
  604. )
  605. if not relevant_items:
  606. return ToolResult(
  607. title="🔍 未找到相关知识",
  608. output=f"查询: {query}\n\n知识库中暂无相关的高质量知识。建议进行调研。",
  609. long_term_memory=f"知识检索: 未找到相关知识 - {query[:50]}"
  610. )
  611. # 格式化输出
  612. output_lines = [f"查询: {query}\n", f"找到 {len(relevant_items)} 条相关知识:\n"]
  613. for idx, item in enumerate(relevant_items, 1):
  614. output_lines.append(f"\n### {idx}. [{item['id']}] (⭐ {item['score']})")
  615. output_lines.append(f"**场景**: {item['scenario'][:150]}...")
  616. output_lines.append(f"**内容**: {item['content'][:200]}...")
  617. return ToolResult(
  618. title="✅ 知识检索成功",
  619. output="\n".join(output_lines),
  620. long_term_memory=f"知识检索: 找到 {len(relevant_items)} 条相关知识 - {query[:50]}",
  621. metadata={
  622. "count": len(relevant_items),
  623. "knowledge_ids": [item["id"] for item in relevant_items],
  624. "items": relevant_items
  625. }
  626. )
  627. except Exception as e:
  628. logger.error(f"知识检索失败: {e}")
  629. return ToolResult(
  630. title="❌ 检索失败",
  631. output=f"错误: {str(e)}",
  632. error=str(e)
  633. )
  634. @tool(description="通过两阶段检索获取最相关的历史经验(strategy 标签的知识)")
  635. async def get_experience(
  636. query: str,
  637. k: int = 3,
  638. context: Optional[ToolContext] = None,
  639. ) -> ToolResult:
  640. """
  641. 检索历史经验(兼容旧接口,实际调用 search_knowledge 并过滤 strategy 标签)
  642. Args:
  643. query: 搜索查询(任务描述)
  644. k: 返回数量(默认 3)
  645. context: 工具上下文
  646. Returns:
  647. 相关经验列表
  648. """
  649. try:
  650. relevant_items = await _get_structured_knowledge(
  651. query_text=query,
  652. top_k=k,
  653. min_score=1, # 经验的评分门槛较低
  654. context=context,
  655. tags_filter=["strategy"] # 只返回经验
  656. )
  657. if not relevant_items:
  658. return ToolResult(
  659. title="🔍 未找到相关经验",
  660. output=f"查询: {query}\n\n经验库中暂无相关的经验。",
  661. long_term_memory=f"经验检索: 未找到相关经验 - {query[:50]}",
  662. metadata={"items": [], "count": 0}
  663. )
  664. # 格式化输出(兼容旧格式)
  665. output_lines = [f"查询: {query}\n", f"找到 {len(relevant_items)} 条相关经验:\n"]
  666. for idx, item in enumerate(relevant_items, 1):
  667. output_lines.append(f"\n### {idx}. [{item['id']}]")
  668. output_lines.append(f"{item['content'][:300]}...")
  669. return ToolResult(
  670. title="✅ 经验检索成功",
  671. output="\n".join(output_lines),
  672. long_term_memory=f"经验检索: 找到 {len(relevant_items)} 条相关经验 - {query[:50]}",
  673. metadata={
  674. "items": relevant_items,
  675. "count": len(relevant_items)
  676. }
  677. )
  678. except Exception as e:
  679. logger.error(f"经验检索失败: {e}")
  680. return ToolResult(
  681. title="❌ 检索失败",
  682. output=f"错误: {str(e)}",
  683. error=str(e)
  684. )
  685. # ===== 批量更新功能(类似经验机制)=====
  686. async def _batch_update_knowledge(
  687. update_map: Dict[str, Dict[str, Any]],
  688. context: Optional[Any] = None
  689. ) -> int:
  690. """
  691. 内部函数:批量更新知识(兼容 experience 接口)
  692. Args:
  693. update_map: 更新映射 {knowledge_id: {"action": "helpful/harmful/evolve", "feedback": "..."}}
  694. context: 上下文(兼容 experience 接口)
  695. Returns:
  696. 成功更新的数量
  697. """
  698. if not update_map:
  699. return 0
  700. knowledge_dir = Path(".cache/knowledge_atoms")
  701. if not knowledge_dir.exists():
  702. return 0
  703. success_count = 0
  704. evolution_tasks = []
  705. evolution_registry = {} # task_idx -> (file_path, data)
  706. for knowledge_id, instr in update_map.items():
  707. try:
  708. # 查找文件
  709. json_path = knowledge_dir / f"{knowledge_id}.json"
  710. md_path = knowledge_dir / f"{knowledge_id}.md"
  711. file_path = None
  712. is_json = False
  713. if json_path.exists():
  714. file_path = json_path
  715. is_json = True
  716. elif md_path.exists():
  717. file_path = md_path
  718. is_json = False
  719. else:
  720. continue
  721. # 读取并解析
  722. with open(file_path, "r", encoding="utf-8") as f:
  723. content = f.read()
  724. if is_json:
  725. data = json.loads(content)
  726. else:
  727. yaml_match = re.search(r'^---\n(.*?)\n---', content, re.DOTALL)
  728. if not yaml_match:
  729. continue
  730. data = yaml.safe_load(yaml_match.group(1))
  731. # 更新 metrics
  732. action = instr.get("action")
  733. feedback = instr.get("feedback", "")
  734. # 处理 mixed 中间态
  735. if action == "mixed":
  736. data["metrics"]["helpful"] = data.get("metrics", {}).get("helpful", 0) + 1
  737. action = "evolve"
  738. if action == "helpful":
  739. data["metrics"]["helpful"] = data.get("metrics", {}).get("helpful", 0) + 1
  740. elif action == "harmful":
  741. data["metrics"]["harmful"] = data.get("metrics", {}).get("harmful", 0) + 1
  742. elif action == "evolve" and feedback:
  743. # 注册进化任务
  744. old_content = data.get("content", "")
  745. task = _evolve_knowledge_with_llm(old_content, feedback)
  746. evolution_tasks.append(task)
  747. evolution_registry[len(evolution_tasks) - 1] = (file_path, data, is_json)
  748. data["metrics"]["helpful"] = data.get("metrics", {}).get("helpful", 0) + 1
  749. data["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  750. # 如果不需要进化,直接保存
  751. if action != "evolve" or not feedback:
  752. if is_json:
  753. with open(file_path, "w", encoding="utf-8") as f:
  754. json.dump(data, f, ensure_ascii=False, indent=2)
  755. else:
  756. meta_str = yaml.dump(data, allow_unicode=True).strip()
  757. with open(file_path, "w", encoding="utf-8") as f:
  758. f.write(f"---\n{meta_str}\n---\n")
  759. success_count += 1
  760. except Exception as e:
  761. logger.error(f"更新知识失败 {knowledge_id}: {e}")
  762. continue
  763. # 并发进化
  764. if evolution_tasks:
  765. import asyncio
  766. print(f"🧬 并发处理 {len(evolution_tasks)} 条知识进化...")
  767. evolved_results = await asyncio.gather(*evolution_tasks)
  768. # 回填进化结果
  769. for task_idx, (file_path, data, is_json) in evolution_registry.items():
  770. data["content"] = evolved_results[task_idx].strip()
  771. if is_json:
  772. with open(file_path, "w", encoding="utf-8") as f:
  773. json.dump(data, f, ensure_ascii=False, indent=2)
  774. else:
  775. meta_str = yaml.dump(data, allow_unicode=True).strip()
  776. with open(file_path, "w", encoding="utf-8") as f:
  777. f.write(f"---\n{meta_str}\n---\n")
  778. success_count += 1
  779. return success_count
  780. @tool()
  781. async def batch_update_knowledge(
  782. feedback_list: List[Dict[str, Any]],
  783. context: Optional[ToolContext] = None,
  784. ) -> ToolResult:
  785. """
  786. 批量反馈知识的有效性(类似经验机制)
  787. Args:
  788. feedback_list: 评价列表,每个元素包含:
  789. - knowledge_id: (str) 知识 ID
  790. - is_effective: (bool) 是否有效
  791. - feedback: (str, optional) 改进建议,若有效且有建议则触发知识进化
  792. Returns:
  793. 批量更新结果
  794. """
  795. try:
  796. if not feedback_list:
  797. return ToolResult(
  798. title="⚠️ 反馈列表为空",
  799. output="未提供任何反馈",
  800. long_term_memory="批量更新知识: 反馈列表为空"
  801. )
  802. knowledge_dir = Path(".cache/knowledge_atoms")
  803. if not knowledge_dir.exists():
  804. return ToolResult(
  805. title="❌ 知识库不存在",
  806. output="知识库目录不存在",
  807. error="知识库不存在"
  808. )
  809. success_count = 0
  810. failed_items = []
  811. for item in feedback_list:
  812. knowledge_id = item.get("knowledge_id")
  813. is_effective = item.get("is_effective")
  814. feedback = item.get("feedback", "")
  815. if not knowledge_id:
  816. failed_items.append({"id": "unknown", "reason": "缺少 knowledge_id"})
  817. continue
  818. try:
  819. # 查找文件
  820. json_path = knowledge_dir / f"{knowledge_id}.json"
  821. md_path = knowledge_dir / f"{knowledge_id}.md"
  822. file_path = None
  823. is_json = False
  824. if json_path.exists():
  825. file_path = json_path
  826. is_json = True
  827. elif md_path.exists():
  828. file_path = md_path
  829. is_json = False
  830. else:
  831. failed_items.append({"id": knowledge_id, "reason": "文件不存在"})
  832. continue
  833. # 读取并解析
  834. with open(file_path, "r", encoding="utf-8") as f:
  835. content = f.read()
  836. if is_json:
  837. data = json.loads(content)
  838. else:
  839. yaml_match = re.search(r'^---\n(.*?)\n---', content, re.DOTALL)
  840. if not yaml_match:
  841. failed_items.append({"id": knowledge_id, "reason": "格式错误"})
  842. continue
  843. data = yaml.safe_load(yaml_match.group(1))
  844. # 更新 metrics
  845. if is_effective:
  846. data["metrics"]["helpful"] = data.get("metrics", {}).get("helpful", 0) + 1
  847. # 如果有反馈建议,触发进化
  848. if feedback:
  849. old_content = data.get("content", "")
  850. evolved_content = await _evolve_knowledge_with_llm(old_content, feedback)
  851. data["content"] = evolved_content
  852. else:
  853. data["metrics"]["harmful"] = data.get("metrics", {}).get("harmful", 0) + 1
  854. data["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
  855. # 保存
  856. if is_json:
  857. with open(file_path, "w", encoding="utf-8") as f:
  858. json.dump(data, f, ensure_ascii=False, indent=2)
  859. else:
  860. meta_str = yaml.dump(data, allow_unicode=True).strip()
  861. with open(file_path, "w", encoding="utf-8") as f:
  862. f.write(f"---\n{meta_str}\n---\n")
  863. success_count += 1
  864. except Exception as e:
  865. failed_items.append({"id": knowledge_id, "reason": str(e)})
  866. continue
  867. output_lines = [f"成功更新 {success_count} 条知识"]
  868. if failed_items:
  869. output_lines.append(f"\n失败 {len(failed_items)} 条:")
  870. for item in failed_items:
  871. output_lines.append(f" - {item['id']}: {item['reason']}")
  872. return ToolResult(
  873. title="✅ 批量更新完成",
  874. output="\n".join(output_lines),
  875. long_term_memory=f"批量更新知识: 成功 {success_count} 条,失败 {len(failed_items)} 条"
  876. )
  877. except Exception as e:
  878. logger.error(f"批量更新知识失败: {e}")
  879. return ToolResult(
  880. title="❌ 批量更新失败",
  881. output=f"错误: {str(e)}",
  882. error=str(e)
  883. )
  884. # ===== 知识库瘦身功能(类似经验机制)=====
  885. @tool()
  886. async def slim_knowledge(
  887. model: str = "anthropic/claude-sonnet-4.5",
  888. context: Optional[ToolContext] = None,
  889. ) -> ToolResult:
  890. """
  891. 知识库瘦身:调用顶级大模型,将知识库中语义相似的知识合并精简
  892. Args:
  893. model: 使用的模型(默认 claude-sonnet-4.5)
  894. context: 工具上下文
  895. Returns:
  896. 瘦身结果报告
  897. """
  898. try:
  899. knowledge_dir = Path(".cache/knowledge_atoms")
  900. if not knowledge_dir.exists():
  901. return ToolResult(
  902. title="📂 知识库不存在",
  903. output="知识库目录不存在,无需瘦身",
  904. long_term_memory="知识库瘦身: 目录不存在"
  905. )
  906. # 获取所有文件
  907. json_files = list(knowledge_dir.glob("*.json"))
  908. md_files = list(knowledge_dir.glob("*.md"))
  909. files = json_files + md_files
  910. if len(files) < 2:
  911. return ToolResult(
  912. title="📂 知识库过小",
  913. output=f"知识库仅有 {len(files)} 条,无需瘦身",
  914. long_term_memory=f"知识库瘦身: 仅有 {len(files)} 条"
  915. )
  916. # 解析所有知识
  917. parsed = []
  918. for file_path in files:
  919. try:
  920. with open(file_path, "r", encoding="utf-8") as f:
  921. content = f.read()
  922. if file_path.suffix == ".json":
  923. data = json.loads(content)
  924. else:
  925. yaml_match = re.search(r'^---\n(.*?)\n---', content, re.DOTALL)
  926. if not yaml_match:
  927. continue
  928. data = yaml.safe_load(yaml_match.group(1))
  929. parsed.append({
  930. "file_path": file_path,
  931. "data": data,
  932. "is_json": file_path.suffix == ".json"
  933. })
  934. except Exception as e:
  935. logger.error(f"解析文件失败 {file_path}: {e}")
  936. continue
  937. if len(parsed) < 2:
  938. return ToolResult(
  939. title="📂 有效知识过少",
  940. output=f"有效知识仅有 {len(parsed)} 条,无需瘦身",
  941. long_term_memory=f"知识库瘦身: 有效知识 {len(parsed)} 条"
  942. )
  943. # 构造发给大模型的内容
  944. entries_text = ""
  945. for p in parsed:
  946. data = p["data"]
  947. entries_text += f"[ID: {data.get('id')}] [Tags: {data.get('tags', {})}] "
  948. entries_text += f"[Metrics: {data.get('metrics', {})}] [Score: {data.get('eval', {}).get('score', 3)}]\n"
  949. entries_text += f"Scenario: {data.get('scenario', 'N/A')}\n"
  950. entries_text += f"Content: {data.get('content', '')[:200]}...\n\n"
  951. prompt = build_knowledge_slim_prompt(entries_text)
  952. print(f"\n[知识瘦身] 正在调用 {model} 分析 {len(parsed)} 条知识...")
  953. response = await openrouter_llm_call(
  954. messages=[{"role": "user", "content": prompt}],
  955. model=model
  956. )
  957. content = response.get("content", "").strip()
  958. if not content:
  959. return ToolResult(
  960. title="❌ 大模型返回为空",
  961. output="大模型返回为空,瘦身失败",
  962. error="大模型返回为空"
  963. )
  964. # 解析大模型输出
  965. report_line = ""
  966. new_entries = []
  967. blocks = [b.strip() for b in content.split("===") if b.strip()]
  968. for block in blocks:
  969. if block.startswith("REPORT:"):
  970. report_line = block
  971. continue
  972. lines = block.split("\n")
  973. kid, tags, metrics, score, scenario, content_lines = None, {}, {}, 3, "", []
  974. current_field = None
  975. for line in lines:
  976. if line.startswith("ID:"):
  977. kid = line[3:].strip()
  978. current_field = None
  979. elif line.startswith("TAGS:"):
  980. try:
  981. tags = yaml.safe_load(line[5:].strip()) or {}
  982. except Exception:
  983. tags = {}
  984. current_field = None
  985. elif line.startswith("METRICS:"):
  986. try:
  987. metrics = yaml.safe_load(line[8:].strip()) or {}
  988. except Exception:
  989. metrics = {"helpful": 0, "harmful": 0}
  990. current_field = None
  991. elif line.startswith("SCORE:"):
  992. try:
  993. score = int(line[6:].strip())
  994. except Exception:
  995. score = 3
  996. current_field = None
  997. elif line.startswith("SCENARIO:"):
  998. scenario = line[9:].strip()
  999. current_field = "scenario"
  1000. elif line.startswith("CONTENT:"):
  1001. content_lines.append(line[8:].strip())
  1002. current_field = "content"
  1003. elif current_field == "scenario":
  1004. scenario += "\n" + line
  1005. elif current_field == "content":
  1006. content_lines.append(line)
  1007. if kid and content_lines:
  1008. new_data = {
  1009. "id": kid,
  1010. "tags": tags,
  1011. "scenario": scenario,
  1012. "content": "\n".join(content_lines).strip(),
  1013. "metrics": metrics,
  1014. "eval": {
  1015. "score": score,
  1016. "helpful": 0,
  1017. "harmful": 0,
  1018. "helpful_history": [],
  1019. "harmful_history": []
  1020. },
  1021. "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
  1022. }
  1023. new_entries.append(new_data)
  1024. if not new_entries:
  1025. return ToolResult(
  1026. title="❌ 解析失败",
  1027. output="解析大模型输出失败,知识库未修改",
  1028. error="解析失败"
  1029. )
  1030. # 删除旧文件
  1031. for p in parsed:
  1032. try:
  1033. p["file_path"].unlink()
  1034. except Exception as e:
  1035. logger.error(f"删除旧文件失败 {p['file_path']}: {e}")
  1036. # 写入新文件(统一使用 JSON 格式)
  1037. for data in new_entries:
  1038. file_path = knowledge_dir / f"{data['id']}.json"
  1039. with open(file_path, "w", encoding="utf-8") as f:
  1040. json.dump(data, f, ensure_ascii=False, indent=2)
  1041. result = f"瘦身完成:{len(parsed)} → {len(new_entries)} 条知识"
  1042. if report_line:
  1043. result += f"\n{report_line}"
  1044. print(f"[知识瘦身] {result}")
  1045. return ToolResult(
  1046. title="✅ 知识库瘦身完成",
  1047. output=result,
  1048. long_term_memory=f"知识库瘦身: {len(parsed)} → {len(new_entries)} 条"
  1049. )
  1050. except Exception as e:
  1051. logger.error(f"知识库瘦身失败: {e}")
  1052. return ToolResult(
  1053. title="❌ 瘦身失败",
  1054. output=f"错误: {str(e)}",
  1055. error=str(e)
  1056. )