|
|
@@ -6,12 +6,28 @@ import asyncio
|
|
|
import re
|
|
|
from typing import List, Optional, Dict, Any
|
|
|
from datetime import datetime
|
|
|
-from ...llm.openrouter import openrouter_llm_call
|
|
|
+from ...llm.openrouter import openrouter_llm_call
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
-# 固定经验存储路径
|
|
|
-EXPERIENCES_PATH = "./.cache/experiences.md"
|
|
|
+# 默认经验存储路径(当无法从 context 获取时使用)
|
|
|
+DEFAULT_EXPERIENCES_PATH = "./.cache/experiences_0.md"
|
|
|
+
|
|
|
+def _get_experiences_path(context: Optional[Any] = None) -> str:
|
|
|
+ """
|
|
|
+ 从 context 中获取 experiences_path,回退到默认路径。
|
|
|
+
|
|
|
+ context 可能包含 runner 引用,从中读取配置的路径。
|
|
|
+ """
|
|
|
+ if context and isinstance(context, dict):
|
|
|
+ runner = context.get("runner")
|
|
|
+ if runner and hasattr(runner, "experiences_path"):
|
|
|
+ path = runner.experiences_path or DEFAULT_EXPERIENCES_PATH
|
|
|
+ print(f"[Experience] 使用 runner 配置的路径: {runner.experiences_path}")
|
|
|
+ return path
|
|
|
+
|
|
|
+ print(f"[Experience] 使用默认路径: {DEFAULT_EXPERIENCES_PATH}")
|
|
|
+ return DEFAULT_EXPERIENCES_PATH
|
|
|
|
|
|
# ===== 经验进化重写 =====
|
|
|
async def _evolve_body_with_llm(old_body: str, feedback: str) -> str:
|
|
|
@@ -100,30 +116,44 @@ async def _route_experiences_by_llm(query_text: str, metadata_list: List[Dict],
|
|
|
logger.error(f"LLM 经验路由失败: {e}")
|
|
|
return []
|
|
|
|
|
|
-async def _get_structured_experiences(query_text: str, top_k: int = 3):
|
|
|
+async def _get_structured_experiences(query_text: str, top_k: int = 3, context: Optional[Any] = None):
|
|
|
"""
|
|
|
1. 解析物理文件
|
|
|
2. 语义路由:提取 2*k 个 ID
|
|
|
3. 质量精排:基于 Metrics 筛选出最终的 k 个
|
|
|
"""
|
|
|
- if not os.path.exists(EXPERIENCES_PATH):
|
|
|
- print(f"[Experience System] 警告: 经验文件不存在 ({EXPERIENCES_PATH})")
|
|
|
+ print(f"[Experience System] runner.experiences_path: {context.get('runner').experiences_path if context and context.get('runner') else None}")
|
|
|
+ experiences_path = _get_experiences_path(context)
|
|
|
+
|
|
|
+ if not os.path.exists(experiences_path):
|
|
|
+ print(f"[Experience System] 警告: 经验文件不存在 ({experiences_path})")
|
|
|
return []
|
|
|
|
|
|
- with open(EXPERIENCES_PATH, "r", encoding="utf-8") as f:
|
|
|
+ with open(experiences_path, "r", encoding="utf-8") as f:
|
|
|
file_content = f.read()
|
|
|
|
|
|
# --- 阶段 1: 解析 ---
|
|
|
- entries = file_content.split("---")
|
|
|
+ # 使用正则表达式匹配 YAML frontmatter 块,避免误分割
|
|
|
+ pattern = r'---\n(.*?)\n---\n(.*?)(?=\n---\n|\Z)'
|
|
|
+ matches = re.findall(pattern, file_content, re.DOTALL)
|
|
|
+
|
|
|
content_map = {}
|
|
|
metadata_list = []
|
|
|
|
|
|
- for i in range(1, len(entries), 2):
|
|
|
+ for yaml_str, raw_body in matches:
|
|
|
try:
|
|
|
- metadata = yaml.safe_load(entries[i])
|
|
|
- raw_body = entries[i+1].strip()
|
|
|
+ metadata = yaml.safe_load(yaml_str)
|
|
|
+
|
|
|
+ # 检查 metadata 类型
|
|
|
+ if not isinstance(metadata, dict):
|
|
|
+ logger.error(f"跳过损坏的经验块: metadata 不是 dict,而是 {type(metadata).__name__}")
|
|
|
+ continue
|
|
|
+
|
|
|
eid = metadata.get("id")
|
|
|
-
|
|
|
+ if not eid:
|
|
|
+ logger.error("跳过损坏的经验块: 缺少 id 字段")
|
|
|
+ continue
|
|
|
+
|
|
|
meta_item = {
|
|
|
"id": eid,
|
|
|
"tags": metadata.get("tags", {}),
|
|
|
@@ -131,10 +161,11 @@ async def _get_structured_experiences(query_text: str, top_k: int = 3):
|
|
|
}
|
|
|
metadata_list.append(meta_item)
|
|
|
content_map[eid] = {
|
|
|
- "content": raw_body,
|
|
|
+ "content": raw_body.strip(),
|
|
|
"metrics": meta_item["metrics"]
|
|
|
}
|
|
|
- except Exception:
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"跳过损坏的经验块: {e}")
|
|
|
continue
|
|
|
|
|
|
# --- 阶段 2: 语义路由 (取 2*k) ---
|
|
|
@@ -173,32 +204,39 @@ async def _get_structured_experiences(query_text: str, top_k: int = 3):
|
|
|
print(f"[Experience System] 检索结束。\n")
|
|
|
return result
|
|
|
|
|
|
-async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]]):
|
|
|
+async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]], context: Optional[Any] = None):
|
|
|
"""
|
|
|
物理层:批量更新经验。
|
|
|
修正点:正确使用 new_sections 集合,确保文件结构的完整性与并发进化的同步。
|
|
|
"""
|
|
|
- if not os.path.exists(EXPERIENCES_PATH) or not update_map:
|
|
|
+ experiences_path = _get_experiences_path(context)
|
|
|
+
|
|
|
+ if not os.path.exists(experiences_path) or not update_map:
|
|
|
return 0
|
|
|
|
|
|
- with open(EXPERIENCES_PATH, "r", encoding="utf-8") as f:
|
|
|
+ with open(experiences_path, "r", encoding="utf-8") as f:
|
|
|
full_content = f.read()
|
|
|
|
|
|
- sections = full_content.split("---")
|
|
|
- # new_sections 用于存放最终要写回的所有块
|
|
|
- new_sections = [sections[0]]
|
|
|
-
|
|
|
+ # 使用正则表达式解析,避免误分割
|
|
|
+ pattern = r'---\n(.*?)\n---\n(.*?)(?=\n---\n|\Z)'
|
|
|
+ matches = re.findall(pattern, full_content, re.DOTALL)
|
|
|
+
|
|
|
+ new_entries = []
|
|
|
evolution_tasks = []
|
|
|
- # 记录哪些 new_sections 的索引需要填充进化后的 Body
|
|
|
- # 注意:这里的 index 指的是 new_sections 里的位置
|
|
|
- evolution_registry = {}
|
|
|
+ evolution_registry = {} # task_idx -> entry_idx
|
|
|
|
|
|
- # --- 第一阶段:处理所有块,填充 new_sections ---
|
|
|
- for i in range(1, len(sections), 2):
|
|
|
+ # --- 第一阶段:处理所有块 ---
|
|
|
+ for yaml_str, body in matches:
|
|
|
try:
|
|
|
- meta = yaml.safe_load(sections[i])
|
|
|
- body = sections[i+1]
|
|
|
+ meta = yaml.safe_load(yaml_str)
|
|
|
+ if not isinstance(meta, dict):
|
|
|
+ logger.error(f"跳过损坏的经验块: metadata 不是 dict")
|
|
|
+ continue
|
|
|
+
|
|
|
eid = meta.get("id")
|
|
|
+ if not eid:
|
|
|
+ logger.error("跳过损坏的经验块: 缺少 id")
|
|
|
+ continue
|
|
|
|
|
|
if eid in update_map:
|
|
|
instr = update_map[eid]
|
|
|
@@ -218,19 +256,16 @@ async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]]):
|
|
|
# 注册进化任务
|
|
|
task = _evolve_body_with_llm(body.strip(), feedback)
|
|
|
evolution_tasks.append(task)
|
|
|
- # 记录该任务对应 new_sections 列表中的位置
|
|
|
- # 此时 new_sections 已经存了 [0], 接下来要存 [meta, body]
|
|
|
- # 所以 meta 在 len(new_sections), body 在 len(new_sections) + 1
|
|
|
- evolution_registry[len(evolution_tasks) - 1] = len(new_sections) + 1
|
|
|
+ # 记录该任务对应的 entry 索引
|
|
|
+ evolution_registry[len(evolution_tasks) - 1] = len(new_entries)
|
|
|
meta["metrics"]["helpful"] += 1
|
|
|
|
|
|
meta["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
- # 无论是否更新,都将其序列化并加入 new_sections
|
|
|
- meta_str = "\n" + yaml.dump(meta, allow_unicode=True).strip() + "\n"
|
|
|
- new_sections.append(meta_str)
|
|
|
- new_sections.append(body) # 先放旧 Body,进化后再替换
|
|
|
-
|
|
|
+ # 序列化并加入 new_entries
|
|
|
+ meta_str = yaml.dump(meta, allow_unicode=True).strip()
|
|
|
+ new_entries.append((meta_str, body.strip()))
|
|
|
+
|
|
|
except Exception as e:
|
|
|
logger.error(f"跳过损坏的经验块: {e}")
|
|
|
continue
|
|
|
@@ -239,40 +274,49 @@ async def _batch_update_experiences(update_map: Dict[str, Dict[str, Any]]):
|
|
|
if evolution_tasks:
|
|
|
print(f"🧬 并发处理 {len(evolution_tasks)} 条经验进化...")
|
|
|
evolved_results = await asyncio.gather(*evolution_tasks)
|
|
|
-
|
|
|
- # 精准回填
|
|
|
- for task_idx, section_idx in evolution_registry.items():
|
|
|
- new_sections[section_idx] = f"\n{evolved_results[task_idx].strip()}\n"
|
|
|
+
|
|
|
+ # 精准回填:替换对应 entry 的 body
|
|
|
+ for task_idx, entry_idx in evolution_registry.items():
|
|
|
+ meta_str, _ = new_entries[entry_idx]
|
|
|
+ new_entries[entry_idx] = (meta_str, evolved_results[task_idx].strip())
|
|
|
|
|
|
# --- 第三阶段:原子化写回 ---
|
|
|
- # 使用 new_sections 构建最终文本
|
|
|
- final_content = "---".join(new_sections)
|
|
|
- with open(EXPERIENCES_PATH, "w", encoding="utf-8") as f:
|
|
|
+ final_parts = []
|
|
|
+ for meta_str, body in new_entries:
|
|
|
+ final_parts.append(f"---\n{meta_str}\n---\n{body}\n")
|
|
|
+
|
|
|
+ final_content = "\n".join(final_parts)
|
|
|
+ with open(experiences_path, "w", encoding="utf-8") as f:
|
|
|
f.write(final_content)
|
|
|
-
|
|
|
+
|
|
|
return len(update_map)
|
|
|
|
|
|
# ===== 经验库瘦身 =====
|
|
|
|
|
|
-async def slim_experiences(model: str = "anthropic/claude-sonnet-4.5") -> str:
|
|
|
+async def slim_experiences(model: str = "anthropic/claude-sonnet-4.5", context: Optional[Any] = None) -> str:
|
|
|
"""
|
|
|
经验库瘦身:调用顶级大模型,将经验库中语义相似的经验合并精简。
|
|
|
返回瘦身报告字符串。
|
|
|
"""
|
|
|
- if not os.path.exists(EXPERIENCES_PATH):
|
|
|
+ experiences_path = _get_experiences_path(context)
|
|
|
+
|
|
|
+ if not os.path.exists(experiences_path):
|
|
|
return "经验文件不存在,无需瘦身。"
|
|
|
|
|
|
- with open(EXPERIENCES_PATH, "r", encoding="utf-8") as f:
|
|
|
+ with open(experiences_path, "r", encoding="utf-8") as f:
|
|
|
file_content = f.read()
|
|
|
|
|
|
- # 解析所有经验条目
|
|
|
- entries = file_content.split("---")
|
|
|
+ # 使用正则表达式解析,避免误分割
|
|
|
+ pattern = r'---\n(.*?)\n---\n(.*?)(?=\n---\n|\Z)'
|
|
|
+ matches = re.findall(pattern, file_content, re.DOTALL)
|
|
|
+
|
|
|
parsed = []
|
|
|
- for i in range(1, len(entries), 2):
|
|
|
+ for yaml_str, body in matches:
|
|
|
try:
|
|
|
- meta = yaml.safe_load(entries[i])
|
|
|
- body = entries[i + 1].strip()
|
|
|
- parsed.append({"meta": meta, "body": body})
|
|
|
+ meta = yaml.safe_load(yaml_str)
|
|
|
+ if not isinstance(meta, dict):
|
|
|
+ continue
|
|
|
+ parsed.append({"meta": meta, "body": body.strip()})
|
|
|
except Exception:
|
|
|
continue
|
|
|
|
|
|
@@ -372,7 +416,7 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
|
|
|
|
|
|
# 写回文件
|
|
|
final = "\n".join(new_entries)
|
|
|
- with open(EXPERIENCES_PATH, "w", encoding="utf-8") as f:
|
|
|
+ with open(experiences_path, "w", encoding="utf-8") as f:
|
|
|
f.write(final)
|
|
|
|
|
|
result = f"瘦身完成:{len(parsed)} → {len(new_entries)} 条经验。"
|
|
|
@@ -387,17 +431,18 @@ REPORT: 原有 X 条,合并后 Y 条,精简了 Z 条。
|
|
|
|
|
|
# ===== 对外 Tool 接口 =====
|
|
|
|
|
|
-from agent.tools import tool
|
|
|
+from agent.tools import tool, ToolContext
|
|
|
|
|
|
@tool(description="通过两阶段检索获取最相关的历史经验")
|
|
|
-async def get_experience(query: str, k: int = 3):
|
|
|
+async def get_experience(query: str, k: int = 3, context: Optional[ToolContext] = None):
|
|
|
"""
|
|
|
通过两阶段检索获取最相关的历史经验。
|
|
|
第一阶段语义匹配(2*k),第二阶段质量精排(k)。
|
|
|
"""
|
|
|
relevant_items = await _get_structured_experiences(
|
|
|
query_text=query,
|
|
|
- top_k=k
|
|
|
+ top_k=k,
|
|
|
+ context=context
|
|
|
)
|
|
|
|
|
|
if not relevant_items:
|
|
|
@@ -409,7 +454,7 @@ async def get_experience(query: str, k: int = 3):
|
|
|
}
|
|
|
|
|
|
@tool()
|
|
|
-async def update_experiences(feedback_list: List[Dict[str, Any]]):
|
|
|
+async def update_experiences(feedback_list: List[Dict[str, Any]], context: Optional[ToolContext] = None):
|
|
|
"""
|
|
|
批量反馈历史经验的有效性。
|
|
|
|
|
|
@@ -438,5 +483,5 @@ async def update_experiences(feedback_list: List[Dict[str, Any]]):
|
|
|
"feedback": comment
|
|
|
}
|
|
|
|
|
|
- count = await _batch_update_experiences(update_map)
|
|
|
+ count = await _batch_update_experiences(update_map, context)
|
|
|
return f"成功同步了 {count} 条经验的反馈。感谢你的评价!"
|