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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -361,3 +361,4 @@ packages/
|
||||
## Claude Code and OMC data
|
||||
.claude/
|
||||
.omc/
|
||||
/.venv312
|
||||
|
||||
@@ -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: 启动时报向量维度不一致
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}]长期记忆查询被聊天过滤策略跳过")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user