|
|
@@ -12,20 +12,18 @@ from .base import AdapterUnavailable, MemoryAgent
|
|
|
|
|
|
|
|
|
class ReMeAgent(MemoryAgent):
|
|
|
+ """用 Markdown 文件保存记忆,并提供文件与向量搜索。"""
|
|
|
+
|
|
|
id = "reme"
|
|
|
_PASSIVE_JOB_OVERRIDES = {
|
|
|
- # ReMe starts these as background/cron jobs by default. This local lab
|
|
|
- # keeps memory work request-driven to avoid silent file changes or LLM
|
|
|
- # cost; chat() performs an explicit reindex before every retrieval.
|
|
|
+ # 关闭后台任务,改为每次对话显式更新索引,避免隐式改文件和模型调用。
|
|
|
"index_update_loop": {"backend": "base", "enable_serve": False},
|
|
|
"resource_watch_loop": {"backend": "base", "enable_serve": False},
|
|
|
"digest_watch_loop": {"backend": "base", "enable_serve": False},
|
|
|
"dream_cron": {"backend": "base", "enable_serve": False},
|
|
|
}
|
|
|
|
|
|
- # These lightweight expansions strengthen ReMe's BM25 branch. The project
|
|
|
- # intentionally leaves ReMe's internal embedding_store disabled and uses
|
|
|
- # pgvector as the separate Embedding retrieval signal when configured.
|
|
|
+ # 扩展常见工程词,补强 ReMe 的 BM25 召回;语义召回单独使用 pgvector。
|
|
|
_QUERY_EXPANSIONS = {
|
|
|
"部署": ("deploy", "发布", "上线", "测试环境", "索引构建", "批次"),
|
|
|
"上线": ("deploy", "发布", "测试环境", "索引构建"),
|
|
|
@@ -77,6 +75,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
)
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
+ """加载 ReMe 依赖并记录当前是否可用。"""
|
|
|
super().__init__()
|
|
|
self._service: Any | None = None
|
|
|
try:
|
|
|
@@ -102,11 +101,12 @@ class ReMeAgent(MemoryAgent):
|
|
|
)
|
|
|
|
|
|
async def memories(self, query: str | None = None) -> list[MemoryItem]:
|
|
|
- """Expose ReMe's Markdown files in the common inspector contract."""
|
|
|
+ """把 ReMe 的 Markdown 文件转换为统一记忆模型。"""
|
|
|
root = settings.data_dir / "reme"
|
|
|
if not root.exists():
|
|
|
return []
|
|
|
items: list[MemoryItem] = []
|
|
|
+ # 对外接口只认识 MemoryItem,因此需要把文件逐个转换。
|
|
|
for path in sorted(root.glob("daily/**/*.md")):
|
|
|
content = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
|
if not content:
|
|
|
@@ -130,6 +130,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
return items
|
|
|
|
|
|
async def delete_memory(self, memory_id: str) -> bool:
|
|
|
+ """按记忆 ID 删除文件,并同步清理索引和操作记录。"""
|
|
|
root = settings.data_dir / "reme"
|
|
|
deleted = False
|
|
|
for path in root.glob("daily/**/*.md"):
|
|
|
@@ -139,6 +140,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
path.unlink(missing_ok=True)
|
|
|
deleted = True
|
|
|
break
|
|
|
+ # 文件删掉后,对应向量和文件索引也要一起更新。
|
|
|
if deleted:
|
|
|
await self.repo.delete_embedding(self.id, memory_id)
|
|
|
if deleted and self._class and settings.reme_enabled:
|
|
|
@@ -149,6 +151,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
return deleted
|
|
|
|
|
|
async def _ensure(self) -> Any:
|
|
|
+ """按需启动 ReMe 服务,同一进程内重复使用。"""
|
|
|
if not self._class or not settings.reme_enabled:
|
|
|
raise AdapterUnavailable(self.descriptor.setup_hint or "ReMe 不可用")
|
|
|
if self._service is None:
|
|
|
@@ -167,6 +170,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
|
|
|
@classmethod
|
|
|
def _should_memorize(cls, message: str) -> bool:
|
|
|
+ """判断当前输入是否值得写入长期文件。"""
|
|
|
normalized = " ".join(message.strip().split())
|
|
|
if not normalized:
|
|
|
return False
|
|
|
@@ -178,20 +182,19 @@ class ReMeAgent(MemoryAgent):
|
|
|
|
|
|
@classmethod
|
|
|
def _heuristic_queries(cls, message: str) -> list[str]:
|
|
|
- """Build short queries for ReMe's file search."""
|
|
|
+ """为文件检索生成短查询和工程词扩展。"""
|
|
|
normalized = message.strip()
|
|
|
queries: list[str] = [normalized] if normalized else []
|
|
|
|
|
|
- # Preserve explicit Latin/domain tokens such as RAG, FastAPI and
|
|
|
- # PostgreSQL; ReMe's tokenizer can match these reliably.
|
|
|
+ # 保留 RAG、FastAPI、PostgreSQL 等可直接匹配的领域词。
|
|
|
queries.extend(re.findall(r"[A-Za-z][A-Za-z0-9_.:/-]{1,}", normalized))
|
|
|
|
|
|
for trigger, expansions in cls._QUERY_EXPANSIONS.items():
|
|
|
+ # 用户用词较少时,补上文件里可能出现的近义说法。
|
|
|
if trigger in normalized:
|
|
|
queries.extend((trigger, *expansions))
|
|
|
|
|
|
- # Add short Chinese chunks as a fallback, while excluding question
|
|
|
- # words that add noise to a lexical search.
|
|
|
+ # 中文短词作为兜底,过滤会干扰词面检索的疑问词。
|
|
|
for chunk in re.findall(r"[\u4e00-\u9fff]{2,}", normalized):
|
|
|
if chunk not in cls._QUERY_STOPWORDS:
|
|
|
queries.append(chunk)
|
|
|
@@ -206,6 +209,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
|
|
|
@staticmethod
|
|
|
def _dedupe_queries(queries: list[str]) -> list[str]:
|
|
|
+ """清理重复或空查询,并限制单次搜索数量。"""
|
|
|
unique: list[str] = []
|
|
|
seen: set[str] = set()
|
|
|
for query in queries:
|
|
|
@@ -220,7 +224,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
return unique[:12]
|
|
|
|
|
|
async def _rewrite_query(self, message: str) -> list[str]:
|
|
|
- """Combine deterministic expansions with optional LLM query rewrite."""
|
|
|
+ """合并规则扩展和可选的模型查询改写。"""
|
|
|
queries = self._heuristic_queries(message)
|
|
|
try:
|
|
|
rewritten = await self.llm.json(
|
|
|
@@ -236,14 +240,13 @@ class ReMeAgent(MemoryAgent):
|
|
|
if isinstance(generated, list):
|
|
|
queries.extend(str(item) for item in generated if str(item).strip())
|
|
|
except Exception:
|
|
|
- # ReMe remains usable when the optional rewrite call fails. The
|
|
|
- # deterministic expansions above cover the common engineering terms.
|
|
|
+ # 改写失败时继续使用规则查询,不影响基础检索。
|
|
|
pass
|
|
|
return self._dedupe_queries(queries)
|
|
|
|
|
|
@staticmethod
|
|
|
def _merge_search_results(results: list[str]) -> str:
|
|
|
- """Merge duplicate snippets returned by multiple ReMe search queries."""
|
|
|
+ """合并多个查询结果并按正文去重。"""
|
|
|
merged: list[str] = []
|
|
|
seen: set[str] = set()
|
|
|
for result in results:
|
|
|
@@ -263,6 +266,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
return "\n\n".join(merged)
|
|
|
|
|
|
async def _search_memory(self, service: Any, queries: list[str]) -> tuple[str, list[str]]:
|
|
|
+ """逐个查询 ReMe 文件,并返回有结果的查询词。"""
|
|
|
results: list[str] = []
|
|
|
used_queries: list[str] = []
|
|
|
for query in queries:
|
|
|
@@ -274,17 +278,19 @@ class ReMeAgent(MemoryAgent):
|
|
|
return self._merge_search_results(results), used_queries
|
|
|
|
|
|
async def _sync_embeddings(self) -> dict[str, Any]:
|
|
|
- """Embed changed ReMe files and remove vectors for deleted files."""
|
|
|
+ """更新已变化文件的向量,并清理已删除文件。"""
|
|
|
if not embedding_client.configured:
|
|
|
return {"enabled": False, "embedded": 0, "removed": 0}
|
|
|
|
|
|
items = await self.memories()
|
|
|
current_ids = {item.id for item in items}
|
|
|
previous_hashes = await self.repo.embedding_hashes(self.id)
|
|
|
+ # 文件不存在后,旧向量也要删除。
|
|
|
stale_ids = set(previous_hashes) - current_ids
|
|
|
for memory_id in stale_ids:
|
|
|
await self.repo.delete_embedding(self.id, memory_id)
|
|
|
|
|
|
+ # 只处理新增或内容有变化的文件。
|
|
|
pending = [
|
|
|
item
|
|
|
for item in items
|
|
|
@@ -306,12 +312,14 @@ class ReMeAgent(MemoryAgent):
|
|
|
return {"enabled": True, "embedded": len(pending), "removed": len(stale_ids)}
|
|
|
|
|
|
async def _semantic_search(self, message: str) -> tuple[str, int]:
|
|
|
+ """查找内容相近的文件,并过滤分数过低的结果。"""
|
|
|
if not embedding_client.configured:
|
|
|
return "", 0
|
|
|
try:
|
|
|
query_vector = (await embedding_client.embed([message]))[0]
|
|
|
matches = await self.repo.search_embeddings(self.id, query_vector, limit=5)
|
|
|
except Exception:
|
|
|
+ # 这一路失败时,主流程仍可使用 ReMe 文件搜索。
|
|
|
return "", 0
|
|
|
matches = [
|
|
|
match
|
|
|
@@ -328,6 +336,7 @@ class ReMeAgent(MemoryAgent):
|
|
|
return self._merge_search_results(blocks), len(matches)
|
|
|
|
|
|
async def _answer_from_memory(self, message: str, evidence: str) -> str:
|
|
|
+ """根据搜索结果生成回答,模型不可用时直接返回原文。"""
|
|
|
if not evidence:
|
|
|
return "没有检索到与这个问题相关的历史记忆。"
|
|
|
try:
|
|
|
@@ -340,12 +349,15 @@ class ReMeAgent(MemoryAgent):
|
|
|
f"用户问题:{message}\n\n记忆证据:\n{evidence}",
|
|
|
)
|
|
|
except Exception:
|
|
|
+ # 保留原始内容比吞掉已经找到的结果更方便排查。
|
|
|
return f"根据检索到的记忆:\n\n{evidence}"
|
|
|
|
|
|
async def chat(self, message: str) -> ChatResponse:
|
|
|
+ """完成文件写入、索引更新、搜索、回答和操作记录。"""
|
|
|
service = await self._ensure()
|
|
|
write_candidate = self._should_memorize(message)
|
|
|
memory_result: Any | None = None
|
|
|
+ # 问题和临时请求不写文件,只处理值得长期保存的陈述。
|
|
|
if write_candidate:
|
|
|
memory_result = await service.run_job(
|
|
|
"auto_memory",
|
|
|
@@ -356,18 +368,20 @@ class ReMeAgent(MemoryAgent):
|
|
|
"问题、寒暄和临时请求不应写入长期记忆。"
|
|
|
),
|
|
|
)
|
|
|
+ # 写入后立即更新索引,保证本轮搜索能看到刚保存的内容。
|
|
|
reindex_result = await service.run_job("reindex")
|
|
|
try:
|
|
|
embedding_sync = await self._sync_embeddings()
|
|
|
except Exception as exc:
|
|
|
- # A temporary Embedding outage should not take down file-memory
|
|
|
- # retrieval; ReMe's built-in file search remains available.
|
|
|
+ # 向量服务异常时仍保留 ReMe 自带的文件检索。
|
|
|
embedding_sync = {"enabled": True, "embedded": 0, "removed": 0, "error": str(exc)}
|
|
|
queries = await self._rewrite_query(message)
|
|
|
+ # 文件搜索和相近内容搜索各跑一次,最后合并并去重。
|
|
|
lexical_search, used_queries = await self._search_memory(service, queries)
|
|
|
semantic_search, semantic_count = await self._semantic_search(message)
|
|
|
search = self._merge_search_results([lexical_search, semantic_search])
|
|
|
answer = await self._answer_from_memory(message, search)
|
|
|
+ # 记录查询词和搜索摘要,出现误匹配时可回看整个过程。
|
|
|
await self._audit(
|
|
|
"REME/auto_memory+search",
|
|
|
details={
|
|
|
@@ -400,7 +414,9 @@ class ReMeAgent(MemoryAgent):
|
|
|
)
|
|
|
|
|
|
async def reset(self) -> None:
|
|
|
+ """清空公共数据,关闭服务并删除 ReMe 工作目录。"""
|
|
|
await super().reset()
|
|
|
+ # 先关闭服务,避免删除目录后仍有后台句柄占用文件。
|
|
|
if self._service is not None:
|
|
|
await self._service.close()
|
|
|
self._service = None
|