Add generic memory gateway v1
This commit is contained in:
53
tests/test_evermemos_service.py
Normal file
53
tests/test_evermemos_service.py
Normal file
@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
|
||||
from memory_gateway.evermemos_service import ConsolidateRequest, consolidate_session
|
||||
|
||||
|
||||
def test_evermemos_service_consolidates_session(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.obsidian_review.get_config",
|
||||
lambda: type(
|
||||
"Config",
|
||||
(),
|
||||
{
|
||||
"obsidian": type(
|
||||
"Obsidian",
|
||||
(),
|
||||
{"vault_path": str(tmp_path / "vault"), "review_dir": "Reviews/Queue"},
|
||||
)()
|
||||
},
|
||||
)(),
|
||||
)
|
||||
payload = {
|
||||
"session_id": "sess_service",
|
||||
"context": {"user_id": "user_a", "agent_id": "agent_a", "workspace_id": "ws_a", "session_id": "sess_service"},
|
||||
"episodes": [
|
||||
{
|
||||
"user_id": "user_a",
|
||||
"agent_id": "agent_a",
|
||||
"workspace_id": "ws_a",
|
||||
"session_id": "sess_service",
|
||||
"namespace": "session/sess_service/episodic",
|
||||
"content": "结论:EverMemOS 本地服务负责整理稳定长期记忆。",
|
||||
"tags": ["decision"],
|
||||
},
|
||||
{
|
||||
"user_id": "user_a",
|
||||
"agent_id": "agent_a",
|
||||
"workspace_id": "ws_a",
|
||||
"session_id": "sess_service",
|
||||
"namespace": "session/sess_service/episodic",
|
||||
"content": "重要:高价值记忆应该进入 Obsidian review queue。",
|
||||
"tags": ["review", "high-value"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
response = asyncio.run(consolidate_session(ConsolidateRequest.model_validate(payload)))
|
||||
|
||||
assert response["status"] == "ok"
|
||||
result = response["result"]
|
||||
assert result["episodes"] == 2
|
||||
assert len(result["candidates"]) == 2
|
||||
assert len(result["promoted"]) == 1
|
||||
assert len(result["review_drafts"]) == 1
|
||||
@ -160,8 +160,9 @@ def test_mcp_rpc_lists_tools_with_api_key(monkeypatch):
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
assert len(payload["result"]["tools"]) == 7
|
||||
assert len(payload["result"]["tools"]) >= 7
|
||||
assert any(tool["name"] == "commit_summary" for tool in payload["result"]["tools"])
|
||||
assert any(tool["name"] == "memory_search" for tool in payload["result"]["tools"])
|
||||
|
||||
|
||||
def test_search_passes_through_gateway(monkeypatch):
|
||||
|
||||
29
tests/test_v1_mcp.py
Normal file
29
tests/test_v1_mcp.py
Normal file
@ -0,0 +1,29 @@
|
||||
import asyncio
|
||||
|
||||
from memory_gateway.repositories import InMemoryRepository
|
||||
from memory_gateway.services import MemoryGatewayService
|
||||
|
||||
|
||||
def test_v1_mcp_tools_are_exposed_and_dispatch(monkeypatch):
|
||||
import memory_gateway.server as server
|
||||
|
||||
service = MemoryGatewayService(InMemoryRepository())
|
||||
monkeypatch.setattr(server, "v1_service", service)
|
||||
|
||||
tools = asyncio.run(server.list_tools())
|
||||
assert any(tool.name == "memory_search" for tool in tools)
|
||||
assert any(tool.name == "memory_commit_session" for tool in tools)
|
||||
|
||||
result = asyncio.run(
|
||||
server.call_v1_memory_tool(
|
||||
"memory_upsert",
|
||||
{
|
||||
"user_id": "user_a",
|
||||
"content": "MCP 写入的 v1 memory",
|
||||
"visibility": "private",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result["user_id"] == "user_a"
|
||||
assert result["namespace"] == "user/user_a/long_term"
|
||||
185
tests/test_v1_service.py
Normal file
185
tests/test_v1_service.py
Normal file
@ -0,0 +1,185 @@
|
||||
import asyncio
|
||||
|
||||
from memory_gateway.repositories import InMemoryRepository, SQLiteRepository
|
||||
from memory_gateway.schemas import (
|
||||
AccessContext,
|
||||
CommitSessionRequest,
|
||||
CreateUserRequest,
|
||||
EpisodeAppendRequest,
|
||||
MemorySearchRequest,
|
||||
MemoryUpsertRequest,
|
||||
Visibility,
|
||||
)
|
||||
from memory_gateway.services import MemoryGatewayService
|
||||
from memory_gateway.types import Config, EverMemOSConfig, ObsidianConfig
|
||||
|
||||
|
||||
def test_private_memory_is_isolated_by_user():
|
||||
service = MemoryGatewayService(InMemoryRepository())
|
||||
service.create_user(CreateUserRequest(user_id="user_a", display_name="A"))
|
||||
service.create_user(CreateUserRequest(user_id="user_b", display_name="B"))
|
||||
|
||||
memory = service.upsert_memory(
|
||||
MemoryUpsertRequest(
|
||||
user_id="user_a",
|
||||
content="用户 A 的私有偏好是中文输出",
|
||||
visibility=Visibility.PRIVATE,
|
||||
)
|
||||
)
|
||||
|
||||
own_results = service.search_memory(MemorySearchRequest(user_id="user_a", query="中文"))
|
||||
other_results = service.search_memory(MemorySearchRequest(user_id="user_b", query="中文"))
|
||||
|
||||
assert own_results["total"] == 1
|
||||
assert own_results["results"][0]["memory"].id == memory.id
|
||||
assert other_results["total"] == 0
|
||||
|
||||
|
||||
def test_workspace_memory_requires_matching_workspace():
|
||||
service = MemoryGatewayService(InMemoryRepository())
|
||||
memory = service.upsert_memory(
|
||||
MemoryUpsertRequest(
|
||||
user_id="user_a",
|
||||
workspace_id="ws_1",
|
||||
content="workspace 共享的项目决策",
|
||||
visibility=Visibility.WORKSPACE_SHARED,
|
||||
)
|
||||
)
|
||||
|
||||
visible = service.get_memory(memory.id, AccessContext(user_id="user_b", workspace_id="ws_1"))
|
||||
assert visible.id == memory.id
|
||||
|
||||
hidden = service.search_memory(MemorySearchRequest(user_id="user_b", workspace_id="ws_2", query="项目决策"))
|
||||
assert hidden["total"] == 0
|
||||
|
||||
|
||||
def test_sqlite_repository_persists_memory(tmp_path):
|
||||
db_path = tmp_path / "memory_gateway.sqlite3"
|
||||
repo = SQLiteRepository(db_path)
|
||||
service = MemoryGatewayService(repo)
|
||||
|
||||
service.create_user(CreateUserRequest(user_id="user_a", display_name="A"))
|
||||
memory = service.upsert_memory(MemoryUpsertRequest(user_id="user_a", content="持久化 SQLite memory"))
|
||||
|
||||
reloaded_service = MemoryGatewayService(SQLiteRepository(db_path))
|
||||
reloaded = reloaded_service.get_memory(memory.id, AccessContext(user_id="user_a"))
|
||||
|
||||
assert reloaded.content == "持久化 SQLite memory"
|
||||
|
||||
|
||||
def test_commit_session_promotes_dedupes_and_creates_review_draft(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.services.get_config",
|
||||
lambda: Config(evermemos=EverMemOSConfig(enabled=False)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.obsidian_review.get_config",
|
||||
lambda: Config(obsidian=ObsidianConfig(vault_path=str(tmp_path / "vault"), review_dir="Reviews/Queue")),
|
||||
)
|
||||
service = MemoryGatewayService(InMemoryRepository())
|
||||
service.append_episode(
|
||||
EpisodeAppendRequest(
|
||||
user_id="user_a",
|
||||
session_id="sess_1",
|
||||
content="结论:这个项目必须保留用户隔离和 namespace ACL。",
|
||||
tags=["decision"],
|
||||
)
|
||||
)
|
||||
service.append_episode(
|
||||
EpisodeAppendRequest(
|
||||
user_id="user_a",
|
||||
session_id="sess_1",
|
||||
content="重要:这条高价值记忆需要人工 review 后再进入长期记忆。",
|
||||
tags=["review", "high-value"],
|
||||
)
|
||||
)
|
||||
|
||||
result = service.commit_session(
|
||||
"sess_1",
|
||||
CommitSessionRequest(
|
||||
user_id="user_a",
|
||||
session_id="sess_1",
|
||||
min_importance=0.6,
|
||||
),
|
||||
)
|
||||
|
||||
assert len(result["promoted"]) == 1
|
||||
assert result["evermemos_backend"] == "local-disabled"
|
||||
assert len(result["review_drafts"]) == 1
|
||||
assert (tmp_path / "vault" / "Reviews" / "Queue").exists()
|
||||
|
||||
|
||||
def test_commit_session_uses_external_evermemos(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.services.get_config",
|
||||
lambda: Config(evermemos=EverMemOSConfig(enabled=True, fallback_to_local=False)),
|
||||
)
|
||||
|
||||
class FakeEverMemOSClient:
|
||||
def consolidate_session(self, **kwargs):
|
||||
return {
|
||||
"episodes": 1,
|
||||
"candidates": [],
|
||||
"promoted": [
|
||||
{
|
||||
"content": "外部 EverMemOS 总结出的长期记忆",
|
||||
"summary": "外部 EverMemOS 长期记忆",
|
||||
"memory_type": "summary",
|
||||
"tags": ["external-evermemos"],
|
||||
}
|
||||
],
|
||||
"duplicates": [],
|
||||
"conflicts": [],
|
||||
"review_drafts": [],
|
||||
}
|
||||
|
||||
def health(self):
|
||||
return {"status": "ok"}
|
||||
|
||||
service = MemoryGatewayService(InMemoryRepository(), evermemos_client=FakeEverMemOSClient())
|
||||
service.append_episode(
|
||||
EpisodeAppendRequest(
|
||||
user_id="user_a",
|
||||
session_id="sess_external",
|
||||
content="这条 episode 应该交给外部 EverMemOS。",
|
||||
)
|
||||
)
|
||||
result = service.commit_session(
|
||||
"sess_external",
|
||||
CommitSessionRequest(user_id="user_a", session_id="sess_external"),
|
||||
)
|
||||
|
||||
assert result["evermemos_backend"] == "external"
|
||||
assert len(result["promoted"]) == 1
|
||||
search = service.search_memory(MemorySearchRequest(user_id="user_a", query="外部 EverMemOS"))
|
||||
assert search["total"] == 1
|
||||
|
||||
|
||||
def test_search_fans_out_to_openviking_after_namespace_acl(monkeypatch):
|
||||
service = MemoryGatewayService(InMemoryRepository())
|
||||
|
||||
class FakeSearchResult:
|
||||
results = [{"uri": "viking://user/user_a/long_term/demo", "abstract": "OpenViking result", "score": 0.9}]
|
||||
|
||||
class FakeOpenVikingClient:
|
||||
async def search(self, query, namespace=None, limit=None, uri=None):
|
||||
assert namespace == "user/user_a/long_term"
|
||||
return FakeSearchResult()
|
||||
|
||||
async def fake_get_openviking_client():
|
||||
return FakeOpenVikingClient()
|
||||
|
||||
monkeypatch.setattr("memory_gateway.services.get_openviking_client", fake_get_openviking_client)
|
||||
|
||||
result = asyncio.run(
|
||||
service.search_memory_with_openviking(
|
||||
MemorySearchRequest(
|
||||
user_id="user_a",
|
||||
query="demo",
|
||||
namespaces=["user/user_a/long_term", "user/user_b/long_term"],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert result["openviking_total"] == 1
|
||||
assert result["searched_namespaces"] == ["user/user_a/long_term"]
|
||||
Reference in New Issue
Block a user