Add memory system session context API

This commit is contained in:
2026-05-26 12:24:56 +08:00
parent d73f59f38d
commit a89807b174
10 changed files with 358 additions and 2 deletions

View File

@ -151,6 +151,7 @@ http://127.0.0.1:1934/memory-system
| `POST` | `/messages` | 写入一轮或半轮会话消息 | 需要 | | `POST` | `/messages` | 写入一轮或半轮会话消息 | 需要 |
| `POST` | `/sessions/{session_id}/commit` | 提交会话,触发 OpenViking commit 和 EverOS flush | 需要 | | `POST` | `/sessions/{session_id}/commit` | 提交会话,触发 OpenViking commit 和 EverOS flush | 需要 |
| `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 | | `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 |
| `GET/POST` | `/sessions/{session_id}/context` | 查询 OpenViking 会话上下文,并用同一 query 搜索 EverOS 记忆 | 需要 |
| `GET` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 | | `GET` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 |
| `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 | | `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 |
| `GET` | `/users/{user_id}/profile` | 查询 EverOS profile | 需要 | | `GET` | `/users/{user_id}/profile` | 查询 EverOS profile | 需要 |
@ -284,6 +285,59 @@ curl -sS -X POST http://127.0.0.1:1934/memory-system/sessions/sessionA1/extract
}' }'
``` ```
### `GET/POST /sessions/{session_id}/context`
查询一次会话的当前上下文。网关会调用 OpenViking
```http
GET /api/v1/sessions/{session_id}/context
Authorization: Bearer <user_key>
```
同时用同一个 `query` 调用 EverOS `/api/v1/memories/search`,返回相关 episodic/profile/raw message 记忆。适合在回答用户问题前,把“当前 session 工作记忆”和“EverOS 相关记忆”一起取回。
路径参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| `session_id` | string | 要查询的会话 ID |
请求体:
| 参数 | 类型 | 必需 | 说明 |
|---|---|---:|---|
| `user_id` | string | 是 | 用户 ID |
| `user_key` | string | 是 | `/users` 返回的 user key |
| `query` | string | 是 | 用于 EverOS search 的查询文本 |
| `limit` | int | 否 | EverOS 记忆返回条数,默认 10范围 1 到 100 |
POST 示例:
```bash
curl -sS -X POST http://127.0.0.1:1934/memory-system/sessions/sessionA1/context \
-H "Content-Type: application/json" \
-d '{
"user_id": "userA",
"user_key": "'"$USER_KEY"'",
"query": "我喜欢喝什么?",
"limit": 10
}'
```
GET 示例:
```bash
curl -sS "http://127.0.0.1:1934/memory-system/sessions/sessionA1/context?user_id=userA&user_key=$USER_KEY&query=我喜欢喝什么?&limit=10"
```
返回字段:
| 字段 | 含义 |
|---|---|
| `context` | OpenViking `result`,包含 `latest_archive_overview``pre_archive_abstracts``messages``stats` |
| `items` | EverOS 搜索命中的精简记忆结果,含 `source_backend: "everos"` |
| `backends` | 两个后端的精简诊断信息,不重复返回完整 OpenViking context 或 EverOS `original_data` |
### `GET /openviking/tasks/{task_id}` ### `GET /openviking/tasks/{task_id}`
查询 OpenViking 后台任务状态,例如 commit 返回的任务。 查询 OpenViking 后台任务状态,例如 commit 返回的任务。

View File

@ -4,7 +4,13 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from .auth import verify_api_key from .auth import verify_api_key
from .schemas import MessageIngestRequest, SearchRequest, SessionUserRequest, UserCreateRequest from .schemas import (
MessageIngestRequest,
SearchRequest,
SessionContextRequest,
SessionUserRequest,
UserCreateRequest,
)
from .service import MemorySystemService from .service import MemorySystemService
@ -70,6 +76,34 @@ async def extract_session(
raise user_auth_error(exc) from exc raise user_auth_error(exc) from exc
@router.post("/sessions/{session_id}/context")
async def get_session_context(
session_id: str,
request: SessionContextRequest,
service: MemorySystemService = Depends(get_service),
):
try:
return await service.get_session_context(session_id, request)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.get("/sessions/{session_id}/context")
async def get_session_context_from_query(
session_id: str,
user_id: str = Query(min_length=1),
user_key: str = Query(min_length=1),
query: str = Query(min_length=1),
limit: int = Query(default=10, ge=1, le=100),
service: MemorySystemService = Depends(get_service),
):
try:
request = SessionContextRequest(user_id=user_id, user_key=user_key, query=query, limit=limit)
return await service.get_session_context(session_id, request)
except PermissionError as exc:
raise user_auth_error(exc) from exc
@router.get("/openviking/tasks/{task_id}") @router.get("/openviking/tasks/{task_id}")
async def get_openviking_task( async def get_openviking_task(
task_id: str, task_id: str,

View File

@ -180,6 +180,12 @@ class OpenVikingMemorySystemClient:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def get_session_context(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]:
async with self._credential_client(credential) as client:
response = await client.get(f"/api/v1/sessions/{session_id}/context")
response.raise_for_status()
return response.json()
def _credential_client(self, credential: OpenVikingCredential | str) -> httpx.AsyncClient: def _credential_client(self, credential: OpenVikingCredential | str) -> httpx.AsyncClient:
if isinstance(credential, str): if isinstance(credential, str):
return self._client(credential) return self._client(credential)

View File

@ -33,6 +33,13 @@ class SearchRequest(BaseModel):
limit: int = Field(default=10, ge=1, le=100) limit: int = Field(default=10, ge=1, le=100)
class SessionContextRequest(BaseModel):
user_id: str = Field(min_length=1)
user_key: str = Field(min_length=1)
query: str = Field(min_length=1)
limit: int = Field(default=10, ge=1, le=100)
class BackendStatus(BaseModel): class BackendStatus(BaseModel):
status: OperationStatus status: OperationStatus
result: Any = None result: Any = None
@ -71,6 +78,13 @@ class SearchResponse(BaseModel):
backends: dict[str, BackendStatus] backends: dict[str, BackendStatus]
class SessionContextResponse(BaseModel):
status: OperationStatus
context: dict[str, Any] | None = None
items: list[dict[str, Any]] = Field(default_factory=list)
backends: dict[str, BackendStatus]
class ProfileResponse(BaseModel): class ProfileResponse(BaseModel):
status: OperationStatus status: OperationStatus
profile: Any = None profile: Any = None

View File

@ -15,6 +15,8 @@ from .schemas import (
ProfileResponse, ProfileResponse,
SearchRequest, SearchRequest,
SearchResponse, SearchResponse,
SessionContextRequest,
SessionContextResponse,
) )
@ -124,6 +126,40 @@ class MemorySystemService:
backends=compact_backends, backends=compact_backends,
) )
async def get_session_context(self, session_id: str, request: SessionContextRequest) -> SessionContextResponse:
credential = self.openviking.credential_for_user(
request.user_id,
request.user_key,
agent_id=session_id,
)
async def read_openviking_context() -> dict[str, Any]:
return await self.openviking.get_session_context(credential, session_id)
async def search_everos() -> dict[str, Any]:
return await self.everos.search(
request.user_id,
session_id,
request.query,
"hybrid",
request.limit,
)
backends = await self._run_backends(openviking=read_openviking_context, everos=search_everos)
backends = self._remove_vectors_from_backends(backends)
context = self._context_from_openviking_result(backends["openviking"].result)
items = (
self._items_from_backend_result("everos", backends["everos"].result)[: request.limit]
if backends["everos"].status == "success"
else []
)
return SessionContextResponse(
status=self._aggregate_status(backends),
context=context,
items=items,
backends=self._compact_session_context_backends(backends),
)
async def get_profile(self, user_id: str) -> ProfileResponse: async def get_profile(self, user_id: str) -> ProfileResponse:
backends = {"everos": await self._capture(lambda: self.everos.get_profile(user_id))} backends = {"everos": await self._capture(lambda: self.everos.get_profile(user_id))}
profile = backends["everos"].result if backends["everos"].status == "success" else None profile = backends["everos"].result if backends["everos"].status == "success" else None
@ -260,6 +296,33 @@ class MemorySystemService:
return result return result
def _context_from_openviking_result(self, result: Any) -> dict[str, Any] | None:
if not isinstance(result, dict):
return None
data = result.get("result") if isinstance(result.get("result"), dict) else result
return data if isinstance(data, dict) else None
def _compact_session_context_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]:
return {
name: backend.model_copy(update={"result": self._compact_session_context_backend_result(name, backend.result)})
for name, backend in backends.items()
}
def _compact_session_context_backend_result(self, backend_name: str, result: Any) -> Any:
if backend_name == "openviking":
data = self._context_from_openviking_result(result)
if data is None:
return result
compact = {
"status": result.get("status") if isinstance(result, dict) else None,
"estimatedTokens": data.get("estimatedTokens"),
"stats": data.get("stats"),
"has_latest_archive_overview": bool(data.get("latest_archive_overview")),
"message_count": len(data.get("messages") or []) if isinstance(data.get("messages"), list) else 0,
}
return {key: value for key, value in compact.items() if value is not None}
return self._compact_backend_result(backend_name, result)
def _remove_vectors_from_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]: def _remove_vectors_from_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]:
return { return {
name: backend.model_copy(update={"result": self._remove_vectors(backend.result)}) name: backend.model_copy(update={"result": self._remove_vectors(backend.result)})

View File

@ -59,6 +59,7 @@ Base path: `/memory-system`
| `POST` | `/messages` | Write user/assistant messages to backends | Yes | | `POST` | `/messages` | Write user/assistant messages to backends | Yes |
| `POST` | `/sessions/{session_id}/commit` | Commit OpenViking session and flush EverOS | Yes | | `POST` | `/sessions/{session_id}/commit` | Commit OpenViking session and flush EverOS | Yes |
| `POST` | `/sessions/{session_id}/extract` | Trigger OpenViking extract only | Yes | | `POST` | `/sessions/{session_id}/extract` | Trigger OpenViking extract only | Yes |
| `GET/POST` | `/sessions/{session_id}/context` | Read OpenViking session context plus EverOS recall | Yes |
| `GET` | `/openviking/tasks/{task_id}` | Poll OpenViking task status | Yes | | `GET` | `/openviking/tasks/{task_id}` | Poll OpenViking task status | Yes |
| `POST` | `/search` | Search OpenViking and EverOS | Yes | | `POST` | `/search` | Search OpenViking and EverOS | Yes |
| `GET` | `/users/{user_id}/profile` | Read EverOS profile | Yes | | `GET` | `/users/{user_id}/profile` | Read EverOS profile | Yes |
@ -127,6 +128,25 @@ curl -sS -X POST "$BASE/memory-system/search" \
}' }'
``` ```
Session context:
```bash
curl -sS -X POST "$BASE/memory-system/sessions/sessionA1/context" \
-H "Content-Type: application/json" \
-d '{
"user_id": "userA",
"user_key": "'"$USER_KEY"'",
"query": "我喜欢喝什么?",
"limit": 10
}'
```
GET with query parameters is also supported:
```bash
curl -sS "$BASE/memory-system/sessions/sessionA1/context?user_id=userA&user_key=$USER_KEY&query=我喜欢喝什么?&limit=10"
```
## Response Handling ## Response Handling
Top-level `status` is one of: Top-level `status` is one of:
@ -137,6 +157,8 @@ Top-level `status` is one of:
Search responses include merged `items` and compact backend diagnostics under `backends`. Keep `source_backend` when using results. Fields named `vector` are stripped from returned payloads, and the raw EverOS `original_data` blob is not returned by search anymore. Search responses include merged `items` and compact backend diagnostics under `backends`. Keep `source_backend` when using results. Fields named `vector` are stripped from returned payloads, and the raw EverOS `original_data` blob is not returned by search anymore.
Session context responses include OpenViking context under `context`, EverOS recall under `items`, and compact backend diagnostics under `backends`.
## Common Mistakes ## Common Mistakes
- Calling `/messages` before `/users`. - Calling `/messages` before `/users`.

View File

@ -72,6 +72,68 @@ curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/ext
-d '{"user_id": "<USER_ID>", "user_key": "<USER_KEY>"}' -d '{"user_id": "<USER_ID>", "user_key": "<USER_KEY>"}'
``` ```
## Session Context
Use this when the caller needs the OpenViking working-memory context for one session plus related EverOS recall for the same user/session.
```bash
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/context \
-H "Content-Type: application/json" \
-d '{
"user_id": "<USER_ID>",
"user_key": "<USER_KEY>",
"query": "我喜欢喝什么?",
"limit": 10
}'
```
Equivalent GET form:
```bash
curl -s "<MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/context?user_id=<USER_ID>&user_key=<USER_KEY>&query=我喜欢喝什么?&limit=10"
```
Response shape:
```json
{
"status": "success",
"context": {
"latest_archive_overview": "# Working Memory\n...",
"pre_archive_abstracts": [],
"messages": [],
"estimatedTokens": 342,
"stats": {"totalArchives": 3}
},
"items": [
{
"source_backend": "everos",
"memory_type": "episode",
"id": "episode-1",
"summary": "userB 在对话中表示自己喜欢拿铁。",
"score": 0.72
}
],
"backends": {
"openviking": {
"status": "success",
"result": {
"status": "ok",
"estimatedTokens": 342,
"has_latest_archive_overview": true,
"message_count": 0
}
},
"everos": {
"status": "success",
"result": {
"counts": {"episodes": 1, "profiles": 0, "raw_messages": 0}
}
}
}
}
```
## Search ## Search
Without LLM planning: Without LLM planning:

View File

@ -255,6 +255,49 @@ def test_openviking_search_uses_session_target_uri():
] ]
def test_openviking_get_session_context_uses_user_key_auth():
client = OpenVikingMemorySystemClient(store=FakeStore())
calls = []
responses = [
FakeResponse(
200,
{
"status": "ok",
"result": {
"latest_archive_overview": "# Working Memory",
"messages": [],
},
},
)
]
client._client = lambda api_key, extra_headers=None: FakeAsyncClient( # type: ignore[method-assign]
calls,
responses,
api_key,
extra_headers or {},
)
credential = client.user_credential("tom-key", "tom", agent_id="sess-1")
result = asyncio.run(client.get_session_context(credential, "sess-1"))
assert result == {
"status": "ok",
"result": {
"latest_archive_overview": "# Working Memory",
"messages": [],
},
}
assert calls == [
(
"get",
"tom-key",
{},
"/api/v1/sessions/sess-1/context",
None,
)
]
def test_openviking_commit_keeps_no_recent_live_messages(): def test_openviking_commit_keeps_no_recent_live_messages():
client = OpenVikingMemorySystemClient(store=FakeStore()) client = OpenVikingMemorySystemClient(store=FakeStore())
calls = [] calls = []

View File

@ -4,6 +4,14 @@ def test_memory_system_server_exposes_routes():
paths = {route.path for route in app.routes} paths = {route.path for route in app.routes}
assert "/memory-system/users" in paths assert "/memory-system/users" in paths
assert "/memory-system/messages" in paths assert "/memory-system/messages" in paths
assert "/memory-system/sessions/{session_id}/context" in paths
context_methods = {
method
for route in app.routes
if getattr(route, "path", "") == "/memory-system/sessions/{session_id}/context"
for method in getattr(route, "methods", set())
}
assert {"GET", "POST"} <= context_methods
assert "/memory-system/search" in paths assert "/memory-system/search" in paths
assert "/memory-system/users/{user_id}/profile" in paths assert "/memory-system/users/{user_id}/profile" in paths

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
from memory_system_api.schemas import MessageIngestRequest, SearchRequest from memory_system_api.schemas import MessageIngestRequest, SearchRequest, SessionContextRequest
from memory_system_api.service import MemorySystemService from memory_system_api.service import MemorySystemService
@ -44,6 +44,19 @@ class FakeOpenViking:
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
return {"items": [{"source": "openviking-search"}]} return {"items": [{"source": "openviking-search"}]}
async def get_session_context(self, user_key: str, session_id: str) -> dict:
self.calls.append(("get_session_context", user_key, session_id))
return {
"status": "ok",
"result": {
"latest_archive_overview": "# Working Memory\nUser likes coffee.",
"pre_archive_abstracts": [],
"messages": [],
"estimatedTokens": 42,
"stats": {"totalArchives": 1},
},
}
async def commit_session(self, user_key: str, session_id: str) -> dict: async def commit_session(self, user_key: str, session_id: str) -> dict:
self.calls.append(("commit_session", user_key, session_id)) self.calls.append(("commit_session", user_key, session_id))
return {"status": "ok", "result": {"task_id": "task-1", "archive_uri": "archive-1"}} return {"status": "ok", "result": {"task_id": "task-1", "archive_uri": "archive-1"}}
@ -203,6 +216,43 @@ def test_search_returns_compact_items_and_backend_diagnostics_without_duplicate_
assert not _has_key(response.backends["everos"].result, "original_data") assert not _has_key(response.backends["everos"].result, "original_data")
def test_session_context_combines_openviking_context_and_everos_search_items():
openviking = FakeOpenViking()
everos = FakeEverOSVerbose()
service = MemorySystemService(openviking=openviking, everos=everos)
response = asyncio.run(
service.get_session_context(
"sess-1",
SessionContextRequest(user_id="tom", user_key="tom-key", query="我喜欢喝什么?", limit=5),
)
)
assert response.status == "success"
assert response.context == {
"latest_archive_overview": "# Working Memory\nUser likes coffee.",
"pre_archive_abstracts": [],
"messages": [],
"estimatedTokens": 42,
"stats": {"totalArchives": 1},
}
assert response.items == [
{
"source_backend": "everos",
"memory_type": "episode",
"id": "episode-1",
"user_id": "tom",
"session_id": "sess-1",
"timestamp": "2026-05-22T07:50:51.750000Z",
"summary": "userB 在对话中表示自己喜欢拿铁。",
"score": 0.72,
}
]
assert ("credential_for_user", "tom", "tom-key", "sess-1") in openviking.calls
assert ("get_session_context", "key-tom", "sess-1") in openviking.calls
assert ("search", "tom", "sess-1", "我喜欢喝什么?", "hybrid", 5) in everos.calls
def _has_key(value, key: str) -> bool: def _has_key(value, key: str) -> bool:
if isinstance(value, dict): if isinstance(value, dict):
return key in value or any(_has_key(item, key) for item in value.values()) return key in value or any(_has_key(item, key) for item in value.values())