Simplify to memory system api

This commit is contained in:
2026-05-18 09:54:26 +08:00
parent b226749c61
commit e689b13e4a
134 changed files with 982 additions and 14575 deletions

View File

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

View 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"

View 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

View 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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff