reme.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. from __future__ import annotations
  2. import hashlib
  3. import re
  4. from datetime import datetime, timezone
  5. from typing import Any
  6. from ..embeddings import embedding_client, embedding_fingerprint
  7. from ..config import settings
  8. from ..schemas import ChatResponse, MemoryItem, SystemDescriptor
  9. from .base import AdapterUnavailable, MemoryAgent
  10. class ReMeAgent(MemoryAgent):
  11. id = "reme"
  12. _PASSIVE_JOB_OVERRIDES = {
  13. # ReMe starts these as background/cron jobs by default. This local lab
  14. # keeps memory work request-driven to avoid silent file changes or LLM
  15. # cost; chat() performs an explicit reindex before every retrieval.
  16. "index_update_loop": {"backend": "base", "enable_serve": False},
  17. "resource_watch_loop": {"backend": "base", "enable_serve": False},
  18. "digest_watch_loop": {"backend": "base", "enable_serve": False},
  19. "dream_cron": {"backend": "base", "enable_serve": False},
  20. }
  21. # These lightweight expansions strengthen ReMe's BM25 branch. The project
  22. # intentionally leaves ReMe's internal embedding_store disabled and uses
  23. # pgvector as the separate Embedding retrieval signal when configured.
  24. _QUERY_EXPANSIONS = {
  25. "部署": ("deploy", "发布", "上线", "测试环境", "索引构建", "批次"),
  26. "上线": ("deploy", "发布", "测试环境", "索引构建"),
  27. "问题": ("超时", "错误", "失败", "异常", "报错", "故障", "冲突"),
  28. "故障": ("超时", "错误", "失败", "异常", "报错", "冲突"),
  29. "错误": ("失败", "异常", "报错", "故障"),
  30. "超时": ("timeout", "索引", "构建", "分批", "批次"),
  31. "连接": ("connection", "connect", "API", "数据库", "服务"),
  32. "索引": ("index", "向量", "构建", "分批", "批次"),
  33. "检索": ("retrieval", "RAG", "向量", "关键词", "重排"),
  34. "知识库": ("RAG", "文档", "向量", "检索", "重排"),
  35. "上次": ("之前", "最近", "历史"),
  36. }
  37. _QUERY_STOPWORDS = frozenset(
  38. {
  39. "什么",
  40. "哪些",
  41. "哪个",
  42. "怎么",
  43. "如何",
  44. "是否",
  45. "有没有",
  46. "请问",
  47. "告诉我",
  48. "根据",
  49. "当前",
  50. "相关",
  51. "一下",
  52. "遇到",
  53. "遇到了",
  54. "发生",
  55. "发生了",
  56. "的",
  57. "吗",
  58. "呢",
  59. "?",
  60. "?",
  61. }
  62. )
  63. _EXPLICIT_WRITE_RE = re.compile(r"记住|记下|请记录|请保存")
  64. _DECLARATIVE_RE = re.compile(
  65. r"我(?:偏好|喜欢|习惯|要求|正在|在开发|决定|选择|使用)|"
  66. r"项目(?:目前|现在|已经|使用|采用|数据库|前端|后端)|"
  67. r"(?:前端|后端|数据库|对话模型|向量模型)(?:使用|采用|选择|是|改成)|"
  68. r"更新一下|以后|上次|之前|曾经|长期规则|代码要求"
  69. )
  70. _QUESTION_RE = re.compile(
  71. r"[??]$|(?:什么|哪些|哪个|怎么|如何|是否|有没有|多少|为什么).*(?:[??]|$)"
  72. )
  73. def __init__(self) -> None:
  74. super().__init__()
  75. self._service: Any | None = None
  76. try:
  77. from reme.reme import ReMe # type: ignore
  78. from reme.config import resolve_app_config # type: ignore
  79. self._class = ReMe
  80. self._resolve_config = resolve_app_config
  81. except Exception:
  82. self._class = None
  83. self._resolve_config = None
  84. ready = bool(self._class and settings.reme_enabled)
  85. self.descriptor = SystemDescriptor(
  86. id="reme",
  87. name="ReMe",
  88. paradigm="文件即记忆",
  89. description="把长期记忆组织为可读 Markdown 文件,并融合 ReMe 检索与 pgvector 召回。",
  90. available=ready,
  91. mode="real-sdk" if ready else "unavailable",
  92. status="ready" if ready else "not-configured",
  93. package="reme-ai[core]>=0.4,<0.5",
  94. setup_hint=None if ready else "安装 reme-ai[core],并设置 REME_ENABLED=true。",
  95. )
  96. async def memories(self, query: str | None = None) -> list[MemoryItem]:
  97. """Expose ReMe's Markdown files in the common inspector contract."""
  98. root = settings.data_dir / "reme"
  99. if not root.exists():
  100. return []
  101. items: list[MemoryItem] = []
  102. for path in sorted(root.glob("daily/**/*.md")):
  103. content = path.read_text(encoding="utf-8", errors="replace").strip()
  104. if not content:
  105. continue
  106. if query and query.lower() not in f"{path} {content}".lower():
  107. continue
  108. updated = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
  109. digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:16]
  110. items.append(MemoryItem(
  111. id=f"reme_{digest}",
  112. system=self.id,
  113. workspace_id=settings.workspace_id,
  114. content=content,
  115. memory_type="file-memory",
  116. source=str(path.relative_to(root)),
  117. confidence=1.0,
  118. created_at=updated,
  119. updated_at=updated,
  120. metadata={"path": str(path.relative_to(root)), "retrieval": "hybrid"},
  121. ))
  122. return items
  123. async def delete_memory(self, memory_id: str) -> bool:
  124. root = settings.data_dir / "reme"
  125. deleted = False
  126. for path in root.glob("daily/**/*.md"):
  127. digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:16]
  128. if f"reme_{digest}" != memory_id:
  129. continue
  130. path.unlink(missing_ok=True)
  131. deleted = True
  132. break
  133. if deleted:
  134. await self.repo.delete_embedding(self.id, memory_id)
  135. if deleted and self._class and settings.reme_enabled:
  136. service = await self._ensure()
  137. await service.run_job("reindex")
  138. if deleted:
  139. await self._audit("DELETE/FileMemory", target=memory_id)
  140. return deleted
  141. async def _ensure(self) -> Any:
  142. if not self._class or not settings.reme_enabled:
  143. raise AdapterUnavailable(self.descriptor.setup_hint or "ReMe 不可用")
  144. if self._service is None:
  145. working_dir = str(settings.data_dir / "reme")
  146. config = self._resolve_config(
  147. log_config=False,
  148. workspace_dir=working_dir,
  149. enable_logo=False,
  150. log_to_console=False,
  151. service={"backend": "http"},
  152. jobs=self._PASSIVE_JOB_OVERRIDES,
  153. )
  154. self._service = self._class(**config)
  155. await self._service.start()
  156. return self._service
  157. @classmethod
  158. def _should_memorize(cls, message: str) -> bool:
  159. normalized = " ".join(message.strip().split())
  160. if not normalized:
  161. return False
  162. if cls._EXPLICIT_WRITE_RE.search(normalized):
  163. return True
  164. if cls._QUESTION_RE.search(normalized):
  165. return False
  166. return bool(cls._DECLARATIVE_RE.search(normalized))
  167. @classmethod
  168. def _heuristic_queries(cls, message: str) -> list[str]:
  169. """Build short queries for ReMe's file search."""
  170. normalized = message.strip()
  171. queries: list[str] = [normalized] if normalized else []
  172. # Preserve explicit Latin/domain tokens such as RAG, FastAPI and
  173. # PostgreSQL; ReMe's tokenizer can match these reliably.
  174. queries.extend(re.findall(r"[A-Za-z][A-Za-z0-9_.:/-]{1,}", normalized))
  175. for trigger, expansions in cls._QUERY_EXPANSIONS.items():
  176. if trigger in normalized:
  177. queries.extend((trigger, *expansions))
  178. # Add short Chinese chunks as a fallback, while excluding question
  179. # words that add noise to a lexical search.
  180. for chunk in re.findall(r"[\u4e00-\u9fff]{2,}", normalized):
  181. if chunk not in cls._QUERY_STOPWORDS:
  182. queries.append(chunk)
  183. if len(chunk) > 2:
  184. queries.extend(
  185. chunk[index : index + 2]
  186. for index in range(len(chunk) - 1)
  187. if chunk[index : index + 2] not in cls._QUERY_STOPWORDS
  188. )
  189. return cls._dedupe_queries(queries)
  190. @staticmethod
  191. def _dedupe_queries(queries: list[str]) -> list[str]:
  192. unique: list[str] = []
  193. seen: set[str] = set()
  194. for query in queries:
  195. cleaned = " ".join(str(query).strip().split())
  196. if not cleaned:
  197. continue
  198. key = cleaned.casefold()
  199. if key in seen:
  200. continue
  201. seen.add(key)
  202. unique.append(cleaned)
  203. return unique[:12]
  204. async def _rewrite_query(self, message: str) -> list[str]:
  205. """Combine deterministic expansions with optional LLM query rewrite."""
  206. queries = self._heuristic_queries(message)
  207. try:
  208. rewritten = await self.llm.json(
  209. (
  210. "你是记忆检索查询改写器。只输出 JSON,不要回答用户问题。"
  211. "把用户问题改写成 2 到 5 个适合混合文件检索的短查询,"
  212. "保留专有名词,并补充可能出现在记忆里的中英文同义词。"
  213. '格式必须是 {"queries": ["..."]}。'
  214. ),
  215. f"用户问题:{message}",
  216. )
  217. generated = rewritten.get("queries", [])
  218. if isinstance(generated, list):
  219. queries.extend(str(item) for item in generated if str(item).strip())
  220. except Exception:
  221. # ReMe remains usable when the optional rewrite call fails. The
  222. # deterministic expansions above cover the common engineering terms.
  223. pass
  224. return self._dedupe_queries(queries)
  225. @staticmethod
  226. def _merge_search_results(results: list[str]) -> str:
  227. """Merge duplicate snippets returned by multiple ReMe search queries."""
  228. merged: list[str] = []
  229. seen: set[str] = set()
  230. for result in results:
  231. for block in re.split(r"(?=^========== )", result.strip(), flags=re.MULTILINE):
  232. block = block.strip()
  233. if not block:
  234. continue
  235. fingerprint_source = block
  236. if block.startswith("==========") and "\n" in block:
  237. fingerprint_source = block.split("\n", 1)[1]
  238. fingerprint = re.sub(r"\[(?:vector_)?score=[^\]]+\]", "", fingerprint_source)
  239. fingerprint = " ".join(fingerprint.split()).casefold()
  240. if fingerprint in seen:
  241. continue
  242. seen.add(fingerprint)
  243. merged.append(block)
  244. return "\n\n".join(merged)
  245. async def _search_memory(self, service: Any, queries: list[str]) -> tuple[str, list[str]]:
  246. results: list[str] = []
  247. used_queries: list[str] = []
  248. for query in queries:
  249. search_result = await service.run_job("search", query=query, limit=5)
  250. answer = str(getattr(search_result, "answer", "") or "").strip()
  251. if answer:
  252. results.append(answer)
  253. used_queries.append(query)
  254. return self._merge_search_results(results), used_queries
  255. async def _sync_embeddings(self) -> dict[str, Any]:
  256. """Embed changed ReMe files and remove vectors for deleted files."""
  257. if not embedding_client.configured:
  258. return {"enabled": False, "embedded": 0, "removed": 0}
  259. items = await self.memories()
  260. current_ids = {item.id for item in items}
  261. previous_hashes = await self.repo.embedding_hashes(self.id)
  262. stale_ids = set(previous_hashes) - current_ids
  263. for memory_id in stale_ids:
  264. await self.repo.delete_embedding(self.id, memory_id)
  265. pending = [
  266. item
  267. for item in items
  268. if previous_hashes.get(item.id) != embedding_fingerprint(item.content)
  269. ]
  270. if not pending:
  271. return {"enabled": True, "embedded": 0, "removed": len(stale_ids)}
  272. vectors = await embedding_client.embed([item.content for item in pending])
  273. for item, vector in zip(pending, vectors):
  274. await self.repo.upsert_embedding(
  275. system=self.id,
  276. memory_id=item.id,
  277. content=item.content,
  278. source=item.source,
  279. content_hash=embedding_fingerprint(item.content),
  280. embedding=vector,
  281. )
  282. return {"enabled": True, "embedded": len(pending), "removed": len(stale_ids)}
  283. async def _semantic_search(self, message: str) -> tuple[str, int]:
  284. if not embedding_client.configured:
  285. return "", 0
  286. try:
  287. query_vector = (await embedding_client.embed([message]))[0]
  288. matches = await self.repo.search_embeddings(self.id, query_vector, limit=5)
  289. except Exception:
  290. return "", 0
  291. matches = [
  292. match
  293. for match in matches
  294. if float(match.get("score", 0.0)) >= settings.embedding_min_score
  295. ]
  296. blocks = [
  297. (
  298. f"========== {match['source']} [vector_score={float(match['score']):.4f}] =========="
  299. f"\n{match['content']}"
  300. )
  301. for match in matches
  302. ]
  303. return self._merge_search_results(blocks), len(matches)
  304. async def _answer_from_memory(self, message: str, evidence: str) -> str:
  305. if not evidence:
  306. return "没有检索到与这个问题相关的历史记忆。"
  307. try:
  308. return await self.llm.chat(
  309. (
  310. "你是一个使用文件记忆的 AI 助手。"
  311. "只能依据提供的记忆证据回答,不要编造证据中没有的事实。"
  312. "如果证据不足,要明确说明不确定。直接回答用户问题,简洁自然。"
  313. ),
  314. f"用户问题:{message}\n\n记忆证据:\n{evidence}",
  315. )
  316. except Exception:
  317. return f"根据检索到的记忆:\n\n{evidence}"
  318. async def chat(self, message: str) -> ChatResponse:
  319. service = await self._ensure()
  320. write_candidate = self._should_memorize(message)
  321. memory_result: Any | None = None
  322. if write_candidate:
  323. memory_result = await service.run_job(
  324. "auto_memory",
  325. messages=[{"name": "user", "role": "user", "content": message}],
  326. session_id=settings.workspace_id,
  327. memory_hint=(
  328. "只记录稳定偏好、项目事实、进度变化、可复用的故障经验和长期流程规则。"
  329. "问题、寒暄和临时请求不应写入长期记忆。"
  330. ),
  331. )
  332. reindex_result = await service.run_job("reindex")
  333. try:
  334. embedding_sync = await self._sync_embeddings()
  335. except Exception as exc:
  336. # A temporary Embedding outage should not take down file-memory
  337. # retrieval; ReMe's built-in file search remains available.
  338. embedding_sync = {"enabled": True, "embedded": 0, "removed": 0, "error": str(exc)}
  339. queries = await self._rewrite_query(message)
  340. lexical_search, used_queries = await self._search_memory(service, queries)
  341. semantic_search, semantic_count = await self._semantic_search(message)
  342. search = self._merge_search_results([lexical_search, semantic_search])
  343. answer = await self._answer_from_memory(message, search)
  344. await self._audit(
  345. "REME/auto_memory+search",
  346. details={
  347. "write_candidate": write_candidate,
  348. "memory": str(getattr(memory_result, "answer", "skipped"))[:800],
  349. "reindex": str(reindex_result.metadata)[:800],
  350. "embedding_sync": embedding_sync,
  351. "queries": queries,
  352. "used_queries": used_queries,
  353. "semantic_results": semantic_count,
  354. "search": str(search)[:1000],
  355. },
  356. )
  357. return ChatResponse(
  358. system="reme",
  359. answer=answer,
  360. mode="real-sdk",
  361. memory_context=await self.memories(),
  362. memory_events=[
  363. {
  364. "event": "file-memory",
  365. "write_candidate": write_candidate,
  366. "queries": queries,
  367. "retrieval": "hybrid",
  368. "semantic_results": semantic_count,
  369. "result": str(search)[:2000],
  370. }
  371. ],
  372. audit_events=await self.audit(),
  373. )
  374. async def reset(self) -> None:
  375. await super().reset()
  376. if self._service is not None:
  377. await self._service.close()
  378. self._service = None
  379. working_dir = settings.data_dir / "reme"
  380. if working_dir.exists():
  381. import shutil
  382. shutil.rmtree(working_dir)