Simplify to memory system api
This commit is contained in:
@ -1,102 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
import memory_gateway.api_v2 as api_v2
|
||||
from memory_gateway.everos_client import EverOSClient
|
||||
from memory_gateway.openviking_client import OpenVikingClient
|
||||
from memory_gateway.repositories import InMemoryRepository
|
||||
from memory_gateway.schemas_v2 import BackendRefStatus, BackendType, IngestRequest, IngestResponse, OperationStatus
|
||||
from memory_gateway.server_auth import verify_api_key_compat
|
||||
from memory_gateway.services_v2 import MemoryGatewayV2Service
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("RUN_REAL_BACKEND_TESTS") != "1",
|
||||
reason="real backend ingest test is opt-in; set RUN_REAL_BACKEND_TESTS=1",
|
||||
)
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
pytest.skip(f"{name} is required for real backend ingest test")
|
||||
return value
|
||||
|
||||
|
||||
def test_real_openviking_and_everos_ingest_writes_memory_refs():
|
||||
openviking_base_url = _env("OPENVIKING_BASE_URL")
|
||||
everos_base_url = _env("EVEROS_BASE_URL")
|
||||
openviking_api_key = os.environ.get("OPENVIKING_API_KEY", "")
|
||||
everos_api_key = os.environ.get("EVEROS_API_KEY", "")
|
||||
openviking_ingest_path = os.environ.get("OPENVIKING_INGEST_PATH")
|
||||
everos_ingest_path = os.environ.get("EVEROS_INGEST_PATH")
|
||||
|
||||
async def openviking_factory():
|
||||
return OpenVikingClient(
|
||||
mode="real",
|
||||
base_url=openviking_base_url,
|
||||
api_key=openviking_api_key,
|
||||
ingest_path=openviking_ingest_path,
|
||||
)
|
||||
|
||||
repo = InMemoryRepository()
|
||||
service = MemoryGatewayV2Service(
|
||||
repo=repo,
|
||||
openviking_client_factory=openviking_factory,
|
||||
everos_client=EverOSClient(
|
||||
mode="real",
|
||||
base_url=everos_base_url,
|
||||
api_key=everos_api_key,
|
||||
ingest_path=everos_ingest_path,
|
||||
),
|
||||
)
|
||||
run_id = uuid4().hex[:12]
|
||||
|
||||
response = asyncio.run(post_ingest(service, run_id))
|
||||
|
||||
refs = repo.list_memory_refs(session_id=f"real_ingest_sess_{run_id}", limit=10)
|
||||
assert {ref.backend_type for ref in refs} == {BackendType.OPENVIKING, BackendType.EVEROS}
|
||||
assert all(ref.content_hash for ref in refs)
|
||||
openviking_ref = next(ref for ref in refs if ref.backend_type == BackendType.OPENVIKING)
|
||||
everos_ref = next(ref for ref in refs if ref.backend_type == BackendType.EVEROS)
|
||||
|
||||
assert openviking_ref.status == BackendRefStatus.SUCCESS
|
||||
if everos_ref.status == BackendRefStatus.SUCCESS:
|
||||
assert response.status == OperationStatus.SUCCESS
|
||||
assert everos_ref.native_id
|
||||
assert everos_ref.native_uri
|
||||
else:
|
||||
assert everos_ref.status == BackendRefStatus.FAILED
|
||||
assert response.status == OperationStatus.PARTIAL_SUCCESS
|
||||
assert everos_ref.error_message
|
||||
|
||||
|
||||
async def post_ingest(service: MemoryGatewayV2Service, run_id: str):
|
||||
api_v2.v2_service = service
|
||||
app = FastAPI()
|
||||
app.dependency_overrides[verify_api_key_compat] = lambda: None
|
||||
app.include_router(api_v2.router)
|
||||
request = IngestRequest(
|
||||
workspace_id=os.environ.get("REAL_BACKEND_WORKSPACE_ID", "ws_real_ingest"),
|
||||
user_id=os.environ.get("REAL_BACKEND_USER_ID", "user_real_ingest"),
|
||||
agent_id=os.environ.get("REAL_BACKEND_AGENT_ID", "agent_real_ingest"),
|
||||
session_id=f"real_ingest_sess_{run_id}",
|
||||
turn_id=f"real_ingest_turn_{run_id}",
|
||||
request_id=f"real_ingest_req_{run_id}",
|
||||
idempotency_key=f"real_ingest_idem_{run_id}",
|
||||
namespace=os.environ.get("REAL_BACKEND_NAMESPACE", "workspace/ws_real_ingest/user/user_real_ingest"),
|
||||
source_type="integration_test",
|
||||
source_event_id=f"real_ingest_evt_{run_id}",
|
||||
role="user",
|
||||
content=f"Memory Gateway real ingest smoke test {run_id}",
|
||||
metadata={"source_channel": "integration_test"},
|
||||
)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/v2/conversations/ingest", json=request.model_dump(mode="json"))
|
||||
response.raise_for_status()
|
||||
return IngestResponse.model_validate(response.json())
|
||||
33
tests/test_memory_system_clients.py
Normal file
33
tests/test_memory_system_clients.py
Normal file
@ -0,0 +1,33 @@
|
||||
from memory_system_api.clients import EverOSMemorySystemClient
|
||||
|
||||
|
||||
def test_everos_assistant_payload_does_not_use_user_id_as_sender():
|
||||
client = EverOSMemorySystemClient()
|
||||
|
||||
payload = client.build_message_payload(
|
||||
user_id="tom",
|
||||
session_id="sess-1",
|
||||
role="assistant",
|
||||
content="我记住了",
|
||||
)
|
||||
|
||||
message = payload["messages"][0]
|
||||
assert message["role"] == "assistant"
|
||||
assert message["sender_id"] != "tom"
|
||||
assert message["sender_name"] != "tom"
|
||||
|
||||
|
||||
def test_everos_user_payload_uses_user_id_as_sender():
|
||||
client = EverOSMemorySystemClient()
|
||||
|
||||
payload = client.build_message_payload(
|
||||
user_id="tom",
|
||||
session_id="sess-1",
|
||||
role="user",
|
||||
content="我喜欢拿铁",
|
||||
)
|
||||
|
||||
message = payload["messages"][0]
|
||||
assert message["role"] == "user"
|
||||
assert message["sender_id"] == "tom"
|
||||
assert message["sender_name"] == "tom"
|
||||
7
tests/test_memory_system_server.py
Normal file
7
tests/test_memory_system_server.py
Normal file
@ -0,0 +1,7 @@
|
||||
def test_memory_system_server_exposes_routes():
|
||||
from memory_system_api.server import app
|
||||
|
||||
paths = {route.path for route in app.routes}
|
||||
assert "/memory-system/messages" in paths
|
||||
assert "/memory-system/search" in paths
|
||||
assert "/memory-system/users/{user_id}/profile" in paths
|
||||
138
tests/test_memory_system_service.py
Normal file
138
tests/test_memory_system_service.py
Normal file
@ -0,0 +1,138 @@
|
||||
import asyncio
|
||||
|
||||
from memory_system_api.schemas import MessageIngestRequest, SearchRequest
|
||||
from memory_system_api.service import MemorySystemService
|
||||
|
||||
|
||||
class FakeOpenViking:
|
||||
def __init__(self, fail_on_append: bool = False):
|
||||
self.fail_on_append = fail_on_append
|
||||
self.calls = []
|
||||
|
||||
async def ensure_user(self, user_id: str) -> str:
|
||||
self.calls.append(("ensure_user", user_id))
|
||||
return f"key-{user_id}"
|
||||
|
||||
async def ensure_session(self, user_key: str, session_id: str) -> dict:
|
||||
self.calls.append(("ensure_session", user_key, session_id))
|
||||
return {"session_id": session_id}
|
||||
|
||||
async def append_message(self, user_key: str, session_id: str, role: str, content: str) -> dict:
|
||||
self.calls.append(("append_message", user_key, session_id, role, content))
|
||||
if self.fail_on_append:
|
||||
raise RuntimeError("openviking append failed")
|
||||
return {"message_count": len([call for call in self.calls if call[0] == "append_message"])}
|
||||
|
||||
async def find(self, user_key: str, user_id: str, query: str, limit: int) -> dict:
|
||||
self.calls.append(("find", user_key, user_id, query, limit))
|
||||
await asyncio.sleep(0.01)
|
||||
return {"items": [{"source": "openviking-find"}]}
|
||||
|
||||
async def search(self, user_key: str, session_id: str | None, query: str, limit: int) -> dict:
|
||||
self.calls.append(("search", user_key, session_id, query, limit))
|
||||
await asyncio.sleep(0.01)
|
||||
return {"items": [{"source": "openviking-search"}]}
|
||||
|
||||
|
||||
class FakeEverOS:
|
||||
def __init__(self, fail_on_append: bool = False):
|
||||
self.fail_on_append = fail_on_append
|
||||
self.calls = []
|
||||
|
||||
async def append_message(self, user_id: str, session_id: str, role: str, content: str) -> dict:
|
||||
self.calls.append(("append_message", user_id, session_id, role, content))
|
||||
if self.fail_on_append:
|
||||
raise RuntimeError("everos append failed")
|
||||
return {"status": "accumulated"}
|
||||
|
||||
async def search(self, user_id: str, session_id: str | None, query: str, method: str, limit: int) -> dict:
|
||||
self.calls.append(("search", user_id, session_id, query, method, limit))
|
||||
await asyncio.sleep(0.01)
|
||||
return {"items": [{"source": f"everos-{method}"}]}
|
||||
|
||||
|
||||
def test_ingest_splits_user_and_assistant_messages():
|
||||
openviking = FakeOpenViking()
|
||||
everos = FakeEverOS()
|
||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
||||
|
||||
response = asyncio.run(service.ingest_messages(
|
||||
MessageIngestRequest(
|
||||
user_id="tom",
|
||||
session_id="sess-1",
|
||||
user_message="我喜欢拿铁",
|
||||
assistant_message="我记住了",
|
||||
)
|
||||
))
|
||||
|
||||
assert response.status == "success"
|
||||
assert response.message_count == 2
|
||||
assert openviking.calls == [
|
||||
("ensure_user", "tom"),
|
||||
("ensure_session", "key-tom", "sess-1"),
|
||||
("append_message", "key-tom", "sess-1", "user", "我喜欢拿铁"),
|
||||
("append_message", "key-tom", "sess-1", "assistant", "我记住了"),
|
||||
]
|
||||
assert everos.calls == [
|
||||
("append_message", "tom", "sess-1", "user", "我喜欢拿铁"),
|
||||
("append_message", "tom", "sess-1", "assistant", "我记住了"),
|
||||
]
|
||||
|
||||
|
||||
def test_ingest_requires_at_least_one_message():
|
||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOS())
|
||||
|
||||
try:
|
||||
asyncio.run(service.ingest_messages(MessageIngestRequest(user_id="tom", session_id="sess-1")))
|
||||
except ValueError as exc:
|
||||
assert "at least one message" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
def test_ingest_returns_partial_success_when_one_backend_fails():
|
||||
service = MemorySystemService(openviking=FakeOpenViking(fail_on_append=True), everos=FakeEverOS())
|
||||
|
||||
response = asyncio.run(service.ingest_messages(
|
||||
MessageIngestRequest(user_id="tom", session_id="sess-1", user_message="hello")
|
||||
))
|
||||
|
||||
assert response.status == "partial_success"
|
||||
assert response.backends["openviking"].status == "failed"
|
||||
assert response.backends["everos"].status == "success"
|
||||
|
||||
|
||||
def test_search_uses_find_and_hybrid_without_llm():
|
||||
openviking = FakeOpenViking()
|
||||
everos = FakeEverOS()
|
||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
||||
|
||||
response = asyncio.run(service.search(
|
||||
SearchRequest(user_id="tom", session_id="sess-1", query="咖啡偏好", use_llm=False, limit=5)
|
||||
))
|
||||
|
||||
assert response.status == "success"
|
||||
assert response.items == [
|
||||
{"source_backend": "openviking", "source": "openviking-find"},
|
||||
{"source_backend": "everos", "source": "everos-hybrid"},
|
||||
]
|
||||
assert ("find", "key-tom", "tom", "咖啡偏好", 5) in openviking.calls
|
||||
assert ("search", "tom", "sess-1", "咖啡偏好", "hybrid", 5) in everos.calls
|
||||
|
||||
|
||||
def test_search_uses_search_and_agentic_with_llm():
|
||||
openviking = FakeOpenViking()
|
||||
everos = FakeEverOS()
|
||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
||||
|
||||
response = asyncio.run(service.search(
|
||||
SearchRequest(user_id="tom", session_id="sess-1", query="咖啡偏好", use_llm=True, limit=5)
|
||||
))
|
||||
|
||||
assert response.status == "success"
|
||||
assert response.items == [
|
||||
{"source_backend": "openviking", "source": "openviking-search"},
|
||||
{"source_backend": "everos", "source": "everos-agentic"},
|
||||
]
|
||||
assert ("search", "key-tom", "sess-1", "咖啡偏好", 5) in openviking.calls
|
||||
assert ("search", "tom", "sess-1", "咖啡偏好", "agentic", 5) in everos.calls
|
||||
@ -1,254 +0,0 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
|
||||
def install_test_stubs() -> None:
|
||||
if "mcp.server" not in sys.modules:
|
||||
mcp_module = types.ModuleType("mcp")
|
||||
mcp_server_module = types.ModuleType("mcp.server")
|
||||
mcp_types_module = types.ModuleType("mcp.types")
|
||||
|
||||
class Server:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def list_tools(self):
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
def call_tool(self):
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
class Tool:
|
||||
def __init__(self, name, description, inputSchema):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.inputSchema = inputSchema
|
||||
|
||||
class TextContent:
|
||||
def __init__(self, type, text):
|
||||
self.type = type
|
||||
self.text = text
|
||||
|
||||
def model_dump(self):
|
||||
return {"type": self.type, "text": self.text}
|
||||
|
||||
mcp_server_module.Server = Server
|
||||
mcp_types_module.Tool = Tool
|
||||
mcp_types_module.TextContent = TextContent
|
||||
sys.modules["mcp"] = mcp_module
|
||||
sys.modules["mcp.server"] = mcp_server_module
|
||||
sys.modules["mcp.types"] = mcp_types_module
|
||||
|
||||
if "sse_starlette" not in sys.modules:
|
||||
sse_module = types.ModuleType("sse_starlette")
|
||||
|
||||
class EventSourceResponse(StreamingResponse):
|
||||
def __init__(self, content, *args, **kwargs):
|
||||
super().__init__(content, media_type="text/event-stream", *args, **kwargs)
|
||||
|
||||
sse_module.EventSourceResponse = EventSourceResponse
|
||||
sys.modules["sse_starlette"] = sse_module
|
||||
|
||||
|
||||
install_test_stubs()
|
||||
|
||||
import memory_gateway.server as server
|
||||
from memory_gateway.types import CommitSummaryRequest, Config, ObsidianConfig, SearchRequest, SearchResult, ServerConfig
|
||||
|
||||
|
||||
class FakeOVClient:
|
||||
async def health_check(self):
|
||||
return {"status": "ok", "backend": "fake"}
|
||||
|
||||
async def search(self, query, namespace=None, limit=None, uri=None):
|
||||
return SearchResult(
|
||||
results=[
|
||||
{
|
||||
"uri": "viking://memory-gateway/test",
|
||||
"abstract": query,
|
||||
"score": 1.0,
|
||||
"context_type": "memory",
|
||||
}
|
||||
],
|
||||
total=1,
|
||||
)
|
||||
|
||||
async def add_memory(self, content, namespace=None, memory_type="general"):
|
||||
return {
|
||||
"status": "ok",
|
||||
"content": content,
|
||||
"namespace": namespace,
|
||||
"memory_type": memory_type,
|
||||
}
|
||||
|
||||
async def add_resource(self, uri, content, resource_type="text"):
|
||||
return {
|
||||
"status": "ok",
|
||||
"uri": uri,
|
||||
"content": content,
|
||||
"resource_type": resource_type,
|
||||
}
|
||||
|
||||
async def list_memories(self, namespace=None, memory_type=None, limit=None):
|
||||
return []
|
||||
|
||||
async def list_resources(self, namespace=None, limit=None):
|
||||
return []
|
||||
|
||||
|
||||
async def fake_get_openviking_client():
|
||||
return FakeOVClient()
|
||||
|
||||
|
||||
async def fake_summarize_with_llm(content, **kwargs):
|
||||
return {
|
||||
"title": kwargs.get("title") or "Fake LLM title",
|
||||
"summary": f"LLM summary: {content[:80]}",
|
||||
"key_points": ["LLM key point", "Preserve IP 198.51.100.20"],
|
||||
"tags": kwargs.get("tags") or ["fake"],
|
||||
"llm": {"provider": "fake", "model": "fake-model"},
|
||||
}
|
||||
|
||||
|
||||
class FakeUploadFile:
|
||||
def __init__(self, filename: str, content: bytes) -> None:
|
||||
self.filename = filename
|
||||
self._content = content
|
||||
|
||||
async def read(self) -> bytes:
|
||||
return self._content
|
||||
|
||||
def test_health_requires_api_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_config",
|
||||
lambda: Config(server=ServerConfig(api_key="secret")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_openviking_client",
|
||||
fake_get_openviking_client,
|
||||
)
|
||||
monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm)
|
||||
monkeypatch.setattr("memory_gateway.server.v1_service.everos_health", lambda: {"status": "disabled"})
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
server.verify_api_key()
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
server.verify_api_key("secret")
|
||||
payload = asyncio.run(server.health_check())
|
||||
assert payload["openviking"]["status"] == "ok"
|
||||
|
||||
|
||||
def test_mcp_rpc_lists_tools_with_api_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_config",
|
||||
lambda: Config(server=ServerConfig(api_key="secret")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_openviking_client",
|
||||
fake_get_openviking_client,
|
||||
)
|
||||
|
||||
server.verify_api_key("secret")
|
||||
tools = asyncio.run(server.list_tools())
|
||||
assert len(tools) >= 7
|
||||
assert any(tool.name == "commit_summary" for tool in tools)
|
||||
assert any(tool.name == "memory_search" for tool in tools)
|
||||
|
||||
|
||||
def test_search_passes_through_gateway(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_config",
|
||||
lambda: Config(server=ServerConfig(api_key="")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_openviking_client",
|
||||
fake_get_openviking_client,
|
||||
)
|
||||
|
||||
payload = asyncio.run(server.api_search(SearchRequest(query="phishing")))
|
||||
assert payload["total"] == 1
|
||||
assert payload["results"][0]["abstract"] == "phishing"
|
||||
|
||||
|
||||
def test_summary_endpoint_builds_generic_artifact(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_config",
|
||||
lambda: Config(server=ServerConfig(api_key="")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_openviking_client",
|
||||
fake_get_openviking_client,
|
||||
)
|
||||
monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm)
|
||||
|
||||
payload = asyncio.run(
|
||||
server.api_commit_summary(
|
||||
CommitSummaryRequest(
|
||||
title="Demo investigation summary",
|
||||
content="结论:这是一次高价值沉淀。\n- 证据:命中历史 case。\n- 建议:后续复用该处置路径。",
|
||||
namespace="demo",
|
||||
memory_type="knowledge",
|
||||
tags=["demo", "summary"],
|
||||
persist_as="none",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["artifact"]["title"] == "Demo investigation summary"
|
||||
assert payload["artifact"]["namespace"] == "demo"
|
||||
assert payload["artifact"]["memory_type"] == "knowledge"
|
||||
assert payload["artifact"]["summary"].startswith("LLM summary:")
|
||||
assert payload["artifact"]["llm"]["provider"] == "fake"
|
||||
assert payload["memory_result"] is None
|
||||
assert payload["resource_result"] is None
|
||||
|
||||
|
||||
def test_knowledge_upload_converts_saves_and_commits(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.server.get_config",
|
||||
lambda: Config(
|
||||
server=ServerConfig(api_key=""),
|
||||
obsidian=ObsidianConfig(vault_path=str(tmp_path / "vault"), knowledge_dir="01_Knowledge/Uploaded"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("memory_gateway.server.get_openviking_client", fake_get_openviking_client)
|
||||
monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm)
|
||||
monkeypatch.setattr("memory_gateway.server.convert_file_to_markdown", lambda path: "# Uploaded Doc\n\nImportant uploaded knowledge.")
|
||||
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("memory_gateway.server.asyncio.to_thread", fake_to_thread)
|
||||
|
||||
upload = FakeUploadFile(filename="sample.txt", content=b"hello")
|
||||
payload = asyncio.run(
|
||||
server.api_upload_knowledge(
|
||||
file=upload,
|
||||
title="Uploaded Knowledge",
|
||||
namespace="demo",
|
||||
knowledge_type="playbook",
|
||||
tags="demo,upload",
|
||||
source=None,
|
||||
obsidian_dir=None,
|
||||
resource_uri=None,
|
||||
persist_as="resource",
|
||||
max_summary_chars=1000,
|
||||
)
|
||||
)
|
||||
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["artifact"]["schema_version"] == "memory-gateway.knowledge_upload.v1"
|
||||
assert payload["artifact"]["knowledge_type"] == "playbook"
|
||||
assert payload["artifact"]["markdown_content"].startswith("# Uploaded Doc")
|
||||
assert payload["resource_result"]["status"] == "ok"
|
||||
assert (tmp_path / "vault" / payload["artifact"]["obsidian_relative_path"]).exists()
|
||||
@ -1,29 +0,0 @@
|
||||
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"
|
||||
@ -1,183 +0,0 @@
|
||||
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, EverOSConfig, 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_disabled_does_not_use_local_fallback(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.services.get_config",
|
||||
lambda: Config(everos=EverOSConfig(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 result["promoted"] == []
|
||||
assert result["everos_backend"] == "disabled"
|
||||
|
||||
|
||||
def test_commit_session_uses_external_everos(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"memory_gateway.services.get_config",
|
||||
lambda: Config(everos=EverOSConfig(enabled=True)),
|
||||
)
|
||||
|
||||
class FakeEverOSClient:
|
||||
def consolidate_session(self, **kwargs):
|
||||
return {
|
||||
"episodes": 1,
|
||||
"candidates": [],
|
||||
"promoted": [
|
||||
{
|
||||
"content": "外部 EverOS 总结出的长期记忆",
|
||||
"summary": "外部 EverOS 长期记忆",
|
||||
"memory_type": "summary",
|
||||
"tags": ["external-everos"],
|
||||
}
|
||||
],
|
||||
"duplicates": [],
|
||||
"conflicts": [],
|
||||
"review_drafts": [],
|
||||
}
|
||||
|
||||
def health(self):
|
||||
return {"status": "ok"}
|
||||
|
||||
service = MemoryGatewayService(InMemoryRepository(), everos_client=FakeEverOSClient())
|
||||
service.append_episode(
|
||||
EpisodeAppendRequest(
|
||||
user_id="user_a",
|
||||
session_id="sess_external",
|
||||
content="这条 episode 应该交给外部 EverOS。",
|
||||
)
|
||||
)
|
||||
result = service.commit_session(
|
||||
"sess_external",
|
||||
CommitSessionRequest(user_id="user_a", session_id="sess_external"),
|
||||
)
|
||||
|
||||
assert result["everos_backend"] == "external"
|
||||
assert len(result["promoted"]) == 1
|
||||
search = service.search_memory(MemorySearchRequest(user_id="user_a", query="外部 EverOS"))
|
||||
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"]
|
||||
1972
tests/test_v2_api.py
1972
tests/test_v2_api.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user