feat: A_Memorix:加强严格模式、错误与删除语义

在 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)
This commit is contained in:
A-Dawn
2026-03-19 15:38:36 +08:00
parent 71b3a828c6
commit a1540d7e17
10 changed files with 135 additions and 37 deletions

1
.gitignore vendored
View File

@@ -361,3 +361,4 @@ packages/
## Claude Code and OMC data
.claude/
.omc/
/.venv312

View File

@@ -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: 启动时报向量维度不一致

View File

@@ -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

View File

@@ -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()

View File

@@ -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(

View File

@@ -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,

View File

@@ -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}]长期记忆查询被聊天过滤策略跳过")

View File

@@ -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(

View File

@@ -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}

View File

@@ -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,