From a1540d7e1703bef48566906ec7a3bebc904deec7 Mon Sep 17 00:00:00 2001 From: A-Dawn <67786671+A-Dawn@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:38:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20A=5FMemorix=EF=BC=9A=E5=8A=A0=E5=BC=BA?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E6=A8=A1=E5=BC=8F=E3=80=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=B8=8E=E5=88=A0=E9=99=A4=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 A_Memorix 中强制更严格的检索语义,并改进错误传播与删除结果报告。 强制/校验受支持的搜索模式(search/time/hybrid/episode/aggregate);移除 semantic 模式,并对不支持的模式返回明确错误。将 kernel 和 plugin 构造函数中的默认值从 hybrid 改为 search。(plugins/A_memorix/core/runtime/sdk_memory_kernel.py, plugins/A_memorix/plugin.py) 对 time/hybrid 模式要求必须提供 time_start/time_end,并在文档、快速开始和 README 中体现该语义。(plugins/A_memorix/QUICK_START.md, plugins/A_memorix/README.md) 改进删除预览/执行语义:跟踪“请求的来源”与“匹配的来源”,基于匹配/删除项计算成功状态,并返回详细计数(requested_source_count、matched_source_count、deleted_paragraph_count、error)。修复来源删除逻辑,使其基于匹配到的来源执行删除。(plugins/A_memorix/core/runtime/sdk_memory_kernel.py) 在搜索执行中移除遗留的 semantic 映射,并规范化 query_type 处理。(plugins/A_memorix/core/utils/search_execution_service.py) 向调用方传播后端搜索错误:为 MemorySearchResult 增加 success/error 字段,兼容多种运行时响应封装,并在异常时返回失败结果。更新调用方以处理并报告搜索失败。(src/services/memory_service.py, src/plugin_runtime/capabilities/data.py, src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py, src/memory_system/retrieval_tools/query_long_term_memory.py) --- .gitignore | 1 + plugins/A_memorix/QUICK_START.md | 6 ++ plugins/A_memorix/README.md | 14 +++ .../core/runtime/sdk_memory_kernel.py | 88 +++++++++++++------ .../core/utils/search_execution_service.py | 7 +- plugins/A_memorix/plugin.py | 2 +- .../brain_chat/PFC/pfc_KnowledgeFetcher.py | 10 +++ .../retrieval_tools/query_long_term_memory.py | 5 +- src/plugin_runtime/capabilities/data.py | 2 + src/services/memory_service.py | 37 +++++++- 10 files changed, 135 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 36853b8a..16ee78ca 100644 --- a/.gitignore +++ b/.gitignore @@ -361,3 +361,4 @@ packages/ ## Claude Code and OMC data .claude/ .omc/ +/.venv312 diff --git a/plugins/A_memorix/QUICK_START.md b/plugins/A_memorix/QUICK_START.md index 76750453..7159a35b 100644 --- a/plugins/A_memorix/QUICK_START.md +++ b/plugins/A_memorix/QUICK_START.md @@ -109,6 +109,11 @@ python plugins/A_memorix/scripts/migrate_person_memory_points.py --help `mode` 支持:`search/time/hybrid/episode/aggregate` +严格语义说明: + +- `semantic` 模式已移除,传入会返回参数错误。 +- `time/hybrid` 模式必须提供 `time_start` 或 `time_end`,否则返回错误(不会再当作“未命中”)。 + ### 5.2 写入摘要 ```json @@ -190,6 +195,7 @@ python plugins/A_memorix/scripts/migrate_person_memory_points.py --help 1. 先看 `memory_stats` 是否有段落/关系 2. 检查 `chat_id`、`person_id` 过滤条件是否过严 3. 运行 `runtime_self_check.py --json` 确认 embedding 维度无误 +4. 若返回包含 `error` 字段,优先按错误提示修正 mode/时间参数 ### Q2: 启动时报向量维度不一致 diff --git a/plugins/A_memorix/README.md b/plugins/A_memorix/README.md index 1afb1b5f..2c59629a 100644 --- a/plugins/A_memorix/README.md +++ b/plugins/A_memorix/README.md @@ -59,6 +59,20 @@ A_Memorix 是面向 MaiBot SDK 的 `memory_provider` 插件。 | `memory_v5_admin` | `status/recycle_bin/restore/reinforce/weaken/remember_forever/forget` | | `memory_delete_admin` | `preview/execute/restore/get_operation/list_operations/purge` | +### 检索模式语义(严格) + +- `search_memory.mode` 仅支持:`search/time/hybrid/episode/aggregate`。 +- `semantic` 模式已移除,传入将返回参数错误。 +- `time/hybrid` 模式必须提供 `time_start` 或 `time_end`,否则返回错误,不再静默按“未命中”处理。 + +### 删除返回语义(source 模式) + +- `requested_source_count`:请求删除的 source 数。 +- `matched_source_count`:实际命中的 source 数(存在活跃段落)。 +- `deleted_paragraph_count`:实际删除段落数。 +- `deleted_count`:与实际删除对象一致;在 `source` 模式下等于 `deleted_paragraph_count`。 +- `success`:基于实际命中与实际删除判定,未命中 source 时返回 `false`。 + ## 调用示例 ```json diff --git a/plugins/A_memorix/core/runtime/sdk_memory_kernel.py b/plugins/A_memorix/core/runtime/sdk_memory_kernel.py index 439afd3d..93c11bf7 100644 --- a/plugins/A_memorix/core/runtime/sdk_memory_kernel.py +++ b/plugins/A_memorix/core/runtime/sdk_memory_kernel.py @@ -36,7 +36,7 @@ logger = get_logger("A_Memorix.SDKMemoryKernel") class KernelSearchRequest: query: str = "" limit: int = 5 - mode: str = "hybrid" + mode: str = "search" chat_id: str = "" person_id: str = "" time_start: Optional[str | float] = None @@ -722,9 +722,19 @@ class SDKMemoryKernel: assert self.episode_retriever is not None assert self.aggregate_query_service is not None - mode = str(request.mode or "hybrid").strip().lower() or "hybrid" + mode = str(request.mode or "search").strip().lower() or "search" query = str(request.query or "").strip() limit = max(1, int(request.limit or 5)) + supported_modes = {"search", "time", "hybrid", "episode", "aggregate"} + if mode not in supported_modes: + return { + "summary": "", + "hits": [], + "error": ( + f"不支持的检索模式: {mode}(仅支持 search/time/hybrid/episode/aggregate," + "semantic 已移除)" + ), + } try: time_window = self._normalize_search_time_window(request.time_start, request.time_end) except ValueError as exc: @@ -760,7 +770,7 @@ class SDKMemoryKernel: filtered = self._filter_hits(hits, request.person_id) return {"summary": self._summary(filtered), "hits": filtered} - query_type = "search" if mode in {"search", "semantic"} else mode + query_type = mode runtime_config = self._build_runtime_config() result = await SearchExecutionService.execute( retriever=self.retriever, @@ -2691,7 +2701,13 @@ class SDKMemoryKernel: counts = {"relations": 0, "paragraphs": 0, "entities": 0, "sources": 0} vector_ids: List[str] = [] sources: List[str] = [] - target_hashes: Dict[str, List[str]] = {"relations": [], "paragraphs": [], "entities": [], "sources": []} + target_hashes: Dict[str, List[str]] = { + "relations": [], + "paragraphs": [], + "entities": [], + "sources": [], + "matched_sources": [], + } if act_mode == "relation": relation_rows = [row for row in (self.metadata_store.get_relation(hash_value) for hash_value in self._resolve_relation_hashes(str(normalized_selector.get("query", "") or ""))) if row] @@ -2721,21 +2737,26 @@ class SDKMemoryKernel: if act_mode == "source": source_tokens = self._resolve_source_targets(normalized_selector) target_hashes["sources"] = source_tokens - counts["sources"] = len(source_tokens) + counts["requested_sources"] = len(source_tokens) + matched_source_tokens: List[str] = [] for source in source_tokens: - sources.append(source) - paragraph_rows.extend( - self.metadata_store.query( - """ - SELECT * - FROM paragraphs - WHERE source = ? - AND (is_deleted IS NULL OR is_deleted = 0) - ORDER BY created_at ASC - """, - (source,), - ) + source_rows = self.metadata_store.query( + """ + SELECT * + FROM paragraphs + WHERE source = ? + AND (is_deleted IS NULL OR is_deleted = 0) + ORDER BY created_at ASC + """, + (source,), ) + if source_rows: + matched_source_tokens.append(source) + sources.append(source) + paragraph_rows.extend(source_rows) + target_hashes["matched_sources"] = matched_source_tokens + counts["sources"] = len(matched_source_tokens) + counts["matched_sources"] = len(matched_source_tokens) else: paragraph_rows = self._resolve_paragraph_targets(normalized_selector, include_deleted=False) paragraph_hashes = self._tokens([row.get("hash", "") for row in paragraph_rows]) @@ -2797,9 +2818,14 @@ class SDKMemoryKernel: sources = self._tokens(sources) vector_ids = self._tokens(vector_ids) - primary_count = counts.get(f"{act_mode}s", 0) if act_mode != "source" else counts.get("sources", 0) + primary_count = counts.get(f"{act_mode}s", 0) if act_mode != "source" else counts.get("matched_sources", 0) + success = ( + primary_count > 0 or counts.get("paragraphs", 0) > 0 or counts.get("relations", 0) > 0 + if act_mode != "source" + else (counts.get("matched_sources", 0) > 0 and counts.get("paragraphs", 0) > 0) + ) return { - "success": primary_count > 0 or counts.get("paragraphs", 0) > 0 or counts.get("relations", 0) > 0, + "success": success, "mode": act_mode, "selector": normalized_selector, "items": items, @@ -2807,7 +2833,9 @@ class SDKMemoryKernel: "vector_ids": vector_ids, "sources": sources, "target_hashes": target_hashes, - "error": "" if (primary_count > 0 or counts.get("paragraphs", 0) > 0 or counts.get("relations", 0) > 0) else "未命中可删除内容", + "requested_source_count": counts.get("requested_sources", 0) if act_mode == "source" else 0, + "matched_source_count": counts.get("matched_sources", 0) if act_mode == "source" else 0, + "error": "" if success else "未命中可删除内容", } async def _preview_delete_action(self, *, mode: str, selector: Any) -> Dict[str, Any]: @@ -2826,6 +2854,8 @@ class SDKMemoryKernel: "mode": plan.get("mode"), "selector": plan.get("selector"), "counts": plan.get("counts", {}), + "requested_source_count": int(plan.get("requested_source_count", 0) or 0), + "matched_source_count": int(plan.get("matched_source_count", 0) or 0), "sources": plan.get("sources", []), "vector_ids": plan.get("vector_ids", []), "items": preview_items, @@ -2852,7 +2882,8 @@ class SDKMemoryKernel: paragraph_hashes = self._tokens((plan.get("target_hashes") or {}).get("paragraphs")) entity_hashes = self._tokens((plan.get("target_hashes") or {}).get("entities")) relation_hashes = self._tokens((plan.get("target_hashes") or {}).get("relations")) - source_tokens = self._tokens((plan.get("target_hashes") or {}).get("sources")) + requested_source_tokens = self._tokens((plan.get("target_hashes") or {}).get("sources")) + matched_source_tokens = self._tokens((plan.get("target_hashes") or {}).get("matched_sources")) try: if paragraph_hashes: @@ -2866,8 +2897,8 @@ class SDKMemoryKernel: tuple(paragraph_hashes), ) self.metadata_store.delete_external_memory_refs_by_paragraphs(paragraph_hashes) - if act_mode == "source" and source_tokens: - for source in source_tokens: + if act_mode == "source" and matched_source_tokens: + for source in matched_source_tokens: self.metadata_store.replace_episodes_for_source(source, []) if entity_hashes: @@ -2903,7 +2934,7 @@ class SDKMemoryKernel: self._rebuild_graph_from_metadata() self._persist() deleted_count = ( - len(source_tokens) + len(paragraph_hashes) if act_mode == "source" else len(paragraph_hashes) if act_mode == "paragraph" @@ -2911,8 +2942,9 @@ class SDKMemoryKernel: if act_mode == "entity" else len(relation_hashes) ) + success = bool(deleted_count > 0) result = { - "success": True, + "success": success, "mode": act_mode, "operation_id": operation.get("operation_id", ""), "counts": plan.get("counts", {}), @@ -2922,8 +2954,12 @@ class SDKMemoryKernel: "deleted_relation_count": len(relation_hashes), } if act_mode == "source": - result["deleted_source_count"] = len(source_tokens) + result["requested_source_count"] = len(requested_source_tokens) + result["matched_source_count"] = len(matched_source_tokens) + result["deleted_source_count"] = len(matched_source_tokens) result["deleted_paragraph_count"] = len(paragraph_hashes) + if not success: + result["error"] = "未命中可删除内容" return result except Exception as exc: conn.rollback() diff --git a/plugins/A_memorix/core/utils/search_execution_service.py b/plugins/A_memorix/core/utils/search_execution_service.py index efb2093f..7df243af 100644 --- a/plugins/A_memorix/core/utils/search_execution_service.py +++ b/plugins/A_memorix/core/utils/search_execution_service.py @@ -48,7 +48,7 @@ class SearchExecutionRequest: stream_id: Optional[str] = None group_id: Optional[str] = None user_id: Optional[str] = None - query_type: str = "search" # search|semantic|time|hybrid + query_type: str = "search" # search|time|hybrid query: str = "" top_k: Optional[int] = None time_from: Optional[str] = None @@ -100,10 +100,7 @@ class SearchExecutionService: @staticmethod def _normalize_query_type(raw_query_type: str) -> str: - query_type = _sanitize_text(raw_query_type).lower() or "search" - if query_type == "semantic": - return "search" - return query_type + return _sanitize_text(raw_query_type).lower() or "search" @staticmethod def _resolve_runtime_component( diff --git a/plugins/A_memorix/plugin.py b/plugins/A_memorix/plugin.py index 390515f5..841106a4 100644 --- a/plugins/A_memorix/plugin.py +++ b/plugins/A_memorix/plugin.py @@ -75,7 +75,7 @@ class AMemorixPlugin(MaiBotPlugin): self, query: str = "", limit: int = 5, - mode: str = "hybrid", + mode: str = "search", chat_id: str = "", person_id: str = "", time_start: str | float | None = None, diff --git a/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py b/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py index 4d47f609..3136f8be 100644 --- a/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py +++ b/src/chat/brain_chat/PFC/pfc_KnowledgeFetcher.py @@ -71,11 +71,21 @@ class KnowledgeFetcher: "respect_filter": True, } result = await memory_service.search(query, **search_kwargs) + if not result.success: + logger.warning( + f"[私聊][{self.private_name}]长期记忆查询失败: {result.error or '未知错误'}" + ) + return f"长期记忆检索失败:{result.error or '未知错误'}" if not result.filtered and not result.hits and search_kwargs["person_id"]: fallback_kwargs = dict(search_kwargs) fallback_kwargs["person_id"] = "" logger.debug(f"[私聊][{self.private_name}]人物过滤未命中,退回仅按会话检索长期记忆") result = await memory_service.search(query, **fallback_kwargs) + if not result.success: + logger.warning( + f"[私聊][{self.private_name}]长期记忆回退查询失败: {result.error or '未知错误'}" + ) + return f"长期记忆检索失败:{result.error or '未知错误'}" knowledge_info = result.to_text(limit=5) if result.filtered: logger.debug(f"[私聊][{self.private_name}]长期记忆查询被聊天过滤策略跳过") diff --git a/src/memory_system/retrieval_tools/query_long_term_memory.py b/src/memory_system/retrieval_tools/query_long_term_memory.py index 57202f34..bf39c0cd 100644 --- a/src/memory_system/retrieval_tools/query_long_term_memory.py +++ b/src/memory_system/retrieval_tools/query_long_term_memory.py @@ -169,6 +169,9 @@ def _format_tool_result( query: str, time_range_text: str = "", ) -> str: + if not result.success: + return f"长期记忆查询失败:{result.error or '未知错误'}" + if not result.hits: if mode == "time": return f"在指定时间范围内未找到相关的长期记忆{time_range_text}" @@ -225,7 +228,7 @@ async def query_long_term_memory( return str(exc) time_range_text = f"(时间范围:{time_start_text} 至 {time_end_text})" - backend_mode = "hybrid" if normalized_mode == "search" else normalized_mode + backend_mode = normalized_mode try: result = await memory_service.search( diff --git a/src/plugin_runtime/capabilities/data.py b/src/plugin_runtime/capabilities/data.py index 06ddf5de..c8139c16 100644 --- a/src/plugin_runtime/capabilities/data.py +++ b/src/plugin_runtime/capabilities/data.py @@ -675,6 +675,8 @@ class RuntimeDataCapabilityMixin: from src.services.memory_service import memory_service result = await memory_service.search(query, limit=limit_value) + if not result.success: + return {"success": False, "error": result.error or "长期记忆检索失败"} knowledge_info = result.to_text(limit=limit_value) content = f"你知道这些知识: {knowledge_info}" if knowledge_info else f"你不太了解有关{query}的知识" return {"success": True, "content": content} diff --git a/src/services/memory_service.py b/src/services/memory_service.py index 6cbecd63..04f08ff6 100644 --- a/src/services/memory_service.py +++ b/src/services/memory_service.py @@ -41,6 +41,8 @@ class MemorySearchResult: summary: str = "" hits: List[MemoryHit] = field(default_factory=list) filtered: bool = False + success: bool = True + error: str = "" def to_text(self, limit: int = 5) -> str: if not self.hits: @@ -55,6 +57,8 @@ class MemorySearchResult: def to_dict(self) -> Dict[str, Any]: return { + "success": self.success, + "error": self.error, "summary": self.summary, "hits": [item.to_dict() for item in self.hits], "filtered": self.filtered, @@ -92,13 +96,33 @@ class MemoryService: runtime = get_plugin_runtime_manager() if not runtime.is_running: raise RuntimeError("plugin_runtime 未启动") - return await runtime.invoke_plugin( + response = await runtime.invoke_plugin( method="plugin.invoke_tool", plugin_id=PLUGIN_ID, component_name=component_name, args=args or {}, timeout_ms=max(1000, int(timeout_ms or 30000)), ) + # 兼容新旧运行时返回: + # - 旧版: 直接返回工具结果(dict) + # - 新版: 返回 Envelope,工具结果在 payload.result 中 + if isinstance(response, dict): + return response + payload = getattr(response, "payload", None) + if isinstance(payload, dict): + if isinstance(payload.get("result"), dict): + return payload["result"] + return payload + model_dump = getattr(response, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict): + inner_payload = dumped.get("payload") + if isinstance(inner_payload, dict): + if isinstance(inner_payload.get("result"), dict): + return inner_payload["result"] + return inner_payload + return response async def _invoke_admin( self, @@ -134,7 +158,7 @@ class MemoryService: @staticmethod def _coerce_search_result(payload: Any) -> MemorySearchResult: if not isinstance(payload, dict): - return MemorySearchResult() + return MemorySearchResult(success=False, error="invalid_payload") hits: List[MemoryHit] = [] for item in payload.get("hits", []) or []: if not isinstance(item, dict): @@ -158,10 +182,15 @@ class MemoryService: title=str(item.get("title", "") or ""), ) ) + success_raw = payload.get("success") + error = str(payload.get("error", "") or "") + success = (not bool(error)) if success_raw is None else bool(success_raw) return MemorySearchResult( summary=str(payload.get("summary", "") or ""), hits=hits, filtered=bool(payload.get("filtered", False)), + success=success, + error=error, ) @staticmethod @@ -179,7 +208,7 @@ class MemoryService: query: str, *, limit: int = 5, - mode: str = "hybrid", + mode: str = "search", chat_id: str = "", person_id: str = "", time_start: str | float | None = None, @@ -212,7 +241,7 @@ class MemoryService: return self._coerce_search_result(payload) except Exception as exc: logger.warning("长期记忆搜索失败: %s", exc) - return MemorySearchResult() + return MemorySearchResult(success=False, error=str(exc)) async def ingest_summary( self,