text2mem.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. from __future__ import annotations
  2. import re
  3. from typing import Any
  4. from ..config import settings
  5. from ..db import utcnow
  6. from ..embeddings import embedding_client, embedding_fingerprint
  7. from ..llm import LLMUnavailable
  8. from ..schemas import ChatResponse, MemoryItem, SystemDescriptor
  9. from .base import MemoryAgent
  10. class Text2MemAgent(MemoryAgent):
  11. id = "text2mem"
  12. _EXPLICIT_WRITE_RE = re.compile(r"记住|记下|请记录|请保存")
  13. _DECLARATIVE_RE = re.compile(
  14. r"我(?:偏好|喜欢|习惯|要求|正在|在开发|决定|选择|使用)|"
  15. r"项目(?:目前|现在|已经|使用|采用|数据库|前端|后端)|"
  16. r"(?:前端|后端|数据库|对话模型|向量模型)(?:使用|采用|选择|是|改成)|"
  17. r"更新一下|以后|上次|之前|曾经|长期规则|代码要求"
  18. )
  19. _QUESTION_RE = re.compile(
  20. r"[??]$|(?:什么|哪些|哪个|怎么|如何|是否|有没有|多少|为什么).*(?:[??]|$)"
  21. )
  22. _STOPWORDS = frozenset(
  23. {
  24. "什么",
  25. "哪些",
  26. "哪个",
  27. "怎么",
  28. "如何",
  29. "是否",
  30. "有没有",
  31. "请问",
  32. "告诉我",
  33. "我的",
  34. "当前",
  35. "相关",
  36. "一下",
  37. "遇到",
  38. "遇到了",
  39. "的",
  40. "吗",
  41. "呢",
  42. }
  43. )
  44. def __init__(self) -> None:
  45. super().__init__()
  46. self.descriptor = SystemDescriptor(
  47. id="text2mem",
  48. name="Text2Mem",
  49. paradigm="IR 操作契约",
  50. description=(
  51. "当前实现 Encode / Retrieve、持久化、删除与审计;"
  52. "Update / Lock / Expire 等完整 IR 操作仍是后续项。"
  53. ),
  54. available=True,
  55. mode="native",
  56. status="ready",
  57. package="本项目内置教学实现",
  58. )
  59. @classmethod
  60. def _should_encode(cls, message: str) -> bool:
  61. normalized = " ".join(message.strip().split())
  62. if not normalized:
  63. return False
  64. if cls._EXPLICIT_WRITE_RE.search(normalized):
  65. return True
  66. if cls._QUESTION_RE.search(normalized):
  67. return False
  68. return bool(cls._DECLARATIVE_RE.search(normalized))
  69. @staticmethod
  70. def _memory_type(content: str) -> str:
  71. if re.search(r"上次|之前|曾经|遇到|发生|故障|失败", content):
  72. return "episodic"
  73. if re.search(r"流程|步骤|先.+再|以后.+按", content):
  74. return "procedural"
  75. if re.search(r"更新一下|进度|已完成|完成了|下一步|当前在做", content):
  76. return "task-state"
  77. return "semantic"
  78. @classmethod
  79. def _query_type(cls, message: str) -> str | None:
  80. if re.search(r"流程|步骤|怎么修复|如何修复|操作方法", message):
  81. return "procedural"
  82. if re.search(r"上次|之前|历史|曾经|遇到|发生|故障", message):
  83. return "episodic"
  84. if re.search(r"进度|完成|下一步|现在做到", message):
  85. return "task-state"
  86. if re.search(r"偏好|喜欢|技术栈|数据库|前端|后端|项目", message):
  87. return "semantic"
  88. return None
  89. @classmethod
  90. def _tokens(cls, text: str) -> set[str]:
  91. tokens = {item.casefold() for item in re.findall(r"[A-Za-z][A-Za-z0-9_.+-]{1,}", text)}
  92. for chunk in re.findall(r"[\u4e00-\u9fff]{2,}", text):
  93. if chunk not in cls._STOPWORDS:
  94. tokens.add(chunk)
  95. if len(chunk) > 2:
  96. tokens.update(
  97. chunk[index : index + 2]
  98. for index in range(len(chunk) - 1)
  99. if chunk[index : index + 2] not in cls._STOPWORDS
  100. )
  101. return tokens
  102. async def _encode(self, content: str) -> tuple[MemoryItem, bool]:
  103. normalized = " ".join(content.strip().split()).casefold()
  104. for existing in await self.repo.list_memories(self.id):
  105. if " ".join(existing.content.strip().split()).casefold() == normalized:
  106. await self._audit(
  107. "ENC/Encode",
  108. existing.id,
  109. status="deduplicated",
  110. details={"source": "user_direct"},
  111. )
  112. return existing, False
  113. now = utcnow()
  114. item = MemoryItem(
  115. id=self.repo.memory_id("t2m"),
  116. system=self.id,
  117. workspace_id=settings.workspace_id,
  118. content=content.strip(),
  119. memory_type=self._memory_type(content),
  120. source="user_direct",
  121. confidence=1.0,
  122. valid_from=now,
  123. created_at=now,
  124. updated_at=now,
  125. metadata={
  126. "ir": {"stage": "ENC", "op": "Encode"},
  127. "policy": {"confirmation": True, "locked": False},
  128. },
  129. )
  130. await self.repo.add_memory(item)
  131. await self._audit(
  132. "ENC/Encode",
  133. item.id,
  134. details={"source": "user_direct", "memory_type": item.memory_type},
  135. )
  136. return item, True
  137. async def _sync_embeddings(self, items: list[MemoryItem]) -> dict[str, Any]:
  138. if not embedding_client.configured:
  139. return {"enabled": False, "embedded": 0, "removed": 0}
  140. current_ids = {item.id for item in items}
  141. previous_hashes = await self.repo.embedding_hashes(self.id)
  142. stale_ids = set(previous_hashes) - current_ids
  143. for memory_id in stale_ids:
  144. await self.repo.delete_embedding(self.id, memory_id)
  145. pending = [
  146. item
  147. for item in items
  148. if previous_hashes.get(item.id) != embedding_fingerprint(item.content)
  149. ]
  150. if pending:
  151. vectors = await embedding_client.embed([item.content for item in pending])
  152. for item, vector in zip(pending, vectors):
  153. await self.repo.upsert_embedding(
  154. system=self.id,
  155. memory_id=item.id,
  156. content=item.content,
  157. source=item.source,
  158. content_hash=embedding_fingerprint(item.content),
  159. embedding=vector,
  160. )
  161. return {"enabled": True, "embedded": len(pending), "removed": len(stale_ids)}
  162. async def _retrieve(self, message: str, limit: int = 5) -> tuple[list[MemoryItem], dict[str, Any]]:
  163. items = await self.repo.list_memories(self.id)
  164. if not items:
  165. return [], {"strategy": "empty", "embedding": {"enabled": False}}
  166. query_tokens = self._tokens(message)
  167. expected_type = self._query_type(message)
  168. token_overlap: dict[str, float] = {}
  169. lexical_scores: dict[str, float] = {}
  170. for item in items:
  171. item_tokens = self._tokens(item.content)
  172. overlap = len(query_tokens & item_tokens) / max(1, len(query_tokens))
  173. token_overlap[item.id] = overlap
  174. type_bonus = 0.35 if expected_type and item.memory_type == expected_type else 0.0
  175. lexical_scores[item.id] = overlap + type_bonus
  176. semantic_scores: dict[str, float] = {}
  177. try:
  178. embedding_status = await self._sync_embeddings(items)
  179. if embedding_status["enabled"]:
  180. query_vector = (await embedding_client.embed([message]))[0]
  181. semantic_matches = await self.repo.search_embeddings(
  182. self.id,
  183. query_vector,
  184. limit=min(max(limit * 2, 5), 20),
  185. )
  186. semantic_scores = {
  187. str(match["memory_id"]): float(match["score"])
  188. for match in semantic_matches
  189. }
  190. except Exception as exc:
  191. embedding_status = {"enabled": True, "error": str(exc), "embedded": 0, "removed": 0}
  192. ranked: list[tuple[float, MemoryItem]] = []
  193. for item in items:
  194. lexical = lexical_scores[item.id]
  195. semantic = semantic_scores.get(item.id, 0.0)
  196. if (
  197. expected_type
  198. and item.memory_type != expected_type
  199. and token_overlap[item.id] < 0.25
  200. and semantic < 0.65
  201. ):
  202. continue
  203. if lexical <= 0 and semantic < settings.embedding_min_score:
  204. continue
  205. ranked.append((lexical + max(semantic, 0.0), item))
  206. if not ranked and expected_type:
  207. ranked = [
  208. (0.1, item)
  209. for item in items
  210. if item.memory_type == expected_type
  211. ]
  212. ranked.sort(key=lambda pair: (pair[0], pair[1].updated_at), reverse=True)
  213. selected = [item for _, item in ranked[:limit]]
  214. return selected, {
  215. "strategy": "lexical+type+embedding" if semantic_scores else "lexical+type",
  216. "expected_type": expected_type,
  217. "embedding": embedding_status,
  218. "matches": [
  219. {
  220. "memory_id": item.id,
  221. "lexical": round(lexical_scores[item.id], 4),
  222. "semantic": round(semantic_scores.get(item.id, 0.0), 4),
  223. }
  224. for item in selected
  225. ],
  226. }
  227. async def chat(self, message: str) -> ChatResponse:
  228. events: list[dict[str, Any]] = []
  229. if self._should_encode(message):
  230. item, created = await self._encode(message)
  231. events.append(
  232. {
  233. "event": "ENC/Encode",
  234. "memory_id": item.id,
  235. "content": item.content,
  236. "memory_type": item.memory_type,
  237. "status": "created" if created else "deduplicated",
  238. }
  239. )
  240. context, retrieval = await self._retrieve(message)
  241. system_prompt = (
  242. "你是一个 AI 编程助手。Text2Mem 只允许通过显式 IR 记忆操作读写记忆。"
  243. "回答时区分当前用户陈述、历史记忆和推断,不要把推断写成事实。"
  244. "只能依据给定的记忆证据回答历史问题;证据不足时要明确说明。"
  245. )
  246. context_text = "\n".join(
  247. f"- [{item.memory_type} | {item.source} | {item.updated_at.isoformat()}] {item.content}"
  248. for item in context
  249. ) or "(暂无命中记忆)"
  250. try:
  251. answer = await self.llm.chat(system_prompt, f"记忆上下文:\n{context_text}\n\n用户:{message}")
  252. mode = "llm"
  253. except LLMUnavailable:
  254. mode = "local-fallback"
  255. answer = (
  256. "Text2Mem 本地模式:我已按显式 IR 规则处理本轮输入。\n\n"
  257. f"当前命中记忆:{context_text}\n\n"
  258. "配置 LLM_API_KEY 后,可让模型基于这些记忆生成自然语言回答。"
  259. )
  260. except Exception as exc:
  261. mode = "local-fallback"
  262. retrieval["llm_error"] = str(exc)[:500]
  263. answer = (
  264. "模型调用失败,Text2Mem 已退回本地证据模式;记忆写入和检索结果不受影响。\n\n"
  265. f"当前命中记忆:{context_text}"
  266. )
  267. await self._audit(
  268. "RET/Retrieve",
  269. details={
  270. "query": message,
  271. "count": len(context),
  272. "memory_ids": [item.id for item in context],
  273. **retrieval,
  274. },
  275. )
  276. return ChatResponse(
  277. system="text2mem",
  278. answer=answer,
  279. mode=mode,
  280. memory_context=context,
  281. memory_events=events,
  282. audit_events=await self.audit(),
  283. )