新增完整的长期记忆支持及测试:引入中文记忆检索提示词、query_long_term_memory 检索工具、记忆服务与记忆流程服务,以及 WebUI 的记忆路由。新增大规模测试套件(包括单元测试与基准/在线测试),覆盖聊天历史摘要、知识获取器、事件(episode)生成、写回机制以及用户画像检索等功能。 更新多个模块以集成记忆检索能力(包括 knowledge fetcher、chat summarizer、memory_retrieval、person_info、config/legacy 迁移以及 WebUI 路由),并移除遗留的 lpmm 知识模块。这些变更完成了记忆运行时的接入,同时为基准测试提供嵌入适配器的 mock,并支持新测试与工具所需的导入与 episode 处理流程。
282 lines
8.1 KiB
Python
282 lines
8.1 KiB
Python
import pytest
|
|
|
|
from src.services.memory_service import MemorySearchResult, MemoryService
|
|
|
|
|
|
def test_coerce_write_result_treats_skipped_payload_as_success():
|
|
result = MemoryService._coerce_write_result({"skipped_ids": ["p1"], "detail": "chat_filtered"})
|
|
|
|
assert result.success is True
|
|
assert result.stored_ids == []
|
|
assert result.skipped_ids == ["p1"]
|
|
assert result.detail == "chat_filtered"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_graph_admin_invokes_plugin(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args, kwargs))
|
|
return {"success": True, "nodes": [], "edges": []}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.graph_admin(action="get_graph", limit=12)
|
|
|
|
assert result["success"] is True
|
|
assert calls == [("memory_graph_admin", {"action": "get_graph", "limit": 12}, {"timeout_ms": 30000})]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_recycle_bin_uses_maintain_memory_tool(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args))
|
|
return {"success": True, "items": [{"hash": "abc"}], "count": 1}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.get_recycle_bin(limit=5)
|
|
|
|
assert result == {"success": True, "items": [{"hash": "abc"}], "count": 1}
|
|
assert calls == [("maintain_memory", {"action": "recycle_bin", "limit": 5})]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_respects_filter_by_default(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args))
|
|
return {"summary": "ok", "hits": [], "filtered": True}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.search(
|
|
"mai",
|
|
chat_id="stream-1",
|
|
person_id="person-1",
|
|
user_id="user-1",
|
|
group_id="",
|
|
)
|
|
|
|
assert isinstance(result, MemorySearchResult)
|
|
assert result.filtered is True
|
|
assert calls == [
|
|
(
|
|
"search_memory",
|
|
{
|
|
"query": "mai",
|
|
"limit": 5,
|
|
"mode": "hybrid",
|
|
"chat_id": "stream-1",
|
|
"person_id": "person-1",
|
|
"time_start": None,
|
|
"time_end": None,
|
|
"respect_filter": True,
|
|
"user_id": "user-1",
|
|
"group_id": "",
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ingest_summary_can_bypass_filter(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args))
|
|
return {"success": True, "stored_ids": ["p1"], "detail": ""}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.ingest_summary(
|
|
external_id="chat_history:1",
|
|
chat_id="stream-1",
|
|
text="summary",
|
|
respect_filter=False,
|
|
user_id="user-1",
|
|
)
|
|
|
|
assert result.success is True
|
|
assert calls == [
|
|
(
|
|
"ingest_summary",
|
|
{
|
|
"external_id": "chat_history:1",
|
|
"chat_id": "stream-1",
|
|
"text": "summary",
|
|
"participants": [],
|
|
"time_start": None,
|
|
"time_end": None,
|
|
"tags": [],
|
|
"metadata": {},
|
|
"respect_filter": False,
|
|
"user_id": "user-1",
|
|
"group_id": "",
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_v5_admin_invokes_plugin(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args, kwargs))
|
|
return {"success": True, "count": 1}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.v5_admin(action="status", target="mai", limit=5)
|
|
|
|
assert result["success"] is True
|
|
assert calls == [("memory_v5_admin", {"action": "status", "target": "mai", "limit": 5}, {"timeout_ms": 30000})]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_admin_uses_long_timeout(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args, kwargs))
|
|
return {"success": True, "operation_id": "del-1"}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.delete_admin(action="execute", mode="relation", selector={"query": "mai"})
|
|
|
|
assert result["success"] is True
|
|
assert calls == [
|
|
(
|
|
"memory_delete_admin",
|
|
{"action": "execute", "mode": "relation", "selector": {"query": "mai"}},
|
|
{"timeout_ms": 120000},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_returns_empty_when_query_and_time_missing_async():
|
|
service = MemoryService()
|
|
|
|
result = await service.search("", time_start=None, time_end=None)
|
|
|
|
assert isinstance(result, MemorySearchResult)
|
|
assert result.summary == ""
|
|
assert result.hits == []
|
|
assert result.filtered is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_accepts_string_time_bounds(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args))
|
|
return {"summary": "ok", "hits": [], "filtered": False}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.search(
|
|
"广播站",
|
|
mode="time",
|
|
time_start="2026/03/18",
|
|
time_end="2026/03/18 09:30",
|
|
)
|
|
|
|
assert isinstance(result, MemorySearchResult)
|
|
assert calls == [
|
|
(
|
|
"search_memory",
|
|
{
|
|
"query": "广播站",
|
|
"limit": 5,
|
|
"mode": "time",
|
|
"chat_id": "",
|
|
"person_id": "",
|
|
"time_start": "2026/03/18",
|
|
"time_end": "2026/03/18 09:30",
|
|
"respect_filter": True,
|
|
"user_id": "",
|
|
"group_id": "",
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_coerce_search_result_preserves_aggregate_source_branches():
|
|
result = MemoryService._coerce_search_result(
|
|
{
|
|
"hits": [
|
|
{
|
|
"content": "广播站值夜班",
|
|
"type": "paragraph",
|
|
"metadata": {"event_time_start": 1.0},
|
|
"source_branches": ["search", "time"],
|
|
"rank": 1,
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
assert result.hits[0].metadata["source_branches"] == ["search", "time"]
|
|
assert result.hits[0].metadata["rank"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_admin_uses_long_timeout(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args, kwargs))
|
|
return {"success": True, "task_id": "import-1"}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.import_admin(action="create_lpmm_openie", alias="lpmm")
|
|
|
|
assert result["success"] is True
|
|
assert calls == [
|
|
(
|
|
"memory_import_admin",
|
|
{"action": "create_lpmm_openie", "alias": "lpmm"},
|
|
{"timeout_ms": 120000},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tuning_admin_uses_long_timeout(monkeypatch):
|
|
service = MemoryService()
|
|
calls = []
|
|
|
|
async def fake_invoke(component_name, args=None, **kwargs):
|
|
calls.append((component_name, args, kwargs))
|
|
return {"success": True, "task_id": "tuning-1"}
|
|
|
|
monkeypatch.setattr(service, "_invoke", fake_invoke)
|
|
|
|
result = await service.tuning_admin(action="create_task", payload={"query": "mai"})
|
|
|
|
assert result["success"] is True
|
|
assert calls == [
|
|
(
|
|
"memory_tuning_admin",
|
|
{"action": "create_task", "payload": {"query": "mai"}},
|
|
{"timeout_ms": 120000},
|
|
)
|
|
]
|