Add memory system session context API
This commit is contained in:
54
README.md
54
README.md
@ -151,6 +151,7 @@ http://127.0.0.1:1934/memory-system
|
||||
| `POST` | `/messages` | 写入一轮或半轮会话消息 | 需要 |
|
||||
| `POST` | `/sessions/{session_id}/commit` | 提交会话,触发 OpenViking commit 和 EverOS flush | 需要 |
|
||||
| `POST` | `/sessions/{session_id}/extract` | 立即触发 OpenViking extract | 需要 |
|
||||
| `GET/POST` | `/sessions/{session_id}/context` | 查询 OpenViking 会话上下文,并用同一 query 搜索 EverOS 记忆 | 需要 |
|
||||
| `GET` | `/openviking/tasks/{task_id}` | 查询 OpenViking 后台任务状态 | 需要 |
|
||||
| `POST` | `/search` | 同时搜索 OpenViking 和 EverOS 记忆 | 需要 |
|
||||
| `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}`
|
||||
|
||||
查询 OpenViking 后台任务状态,例如 commit 返回的任务。
|
||||
|
||||
@ -4,7 +4,13 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -70,6 +76,34 @@ async def extract_session(
|
||||
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}")
|
||||
async def get_openviking_task(
|
||||
task_id: str,
|
||||
|
||||
@ -180,6 +180,12 @@ class OpenVikingMemorySystemClient:
|
||||
response.raise_for_status()
|
||||
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:
|
||||
if isinstance(credential, str):
|
||||
return self._client(credential)
|
||||
|
||||
@ -33,6 +33,13 @@ class SearchRequest(BaseModel):
|
||||
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):
|
||||
status: OperationStatus
|
||||
result: Any = None
|
||||
@ -71,6 +78,13 @@ class SearchResponse(BaseModel):
|
||||
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):
|
||||
status: OperationStatus
|
||||
profile: Any = None
|
||||
|
||||
@ -15,6 +15,8 @@ from .schemas import (
|
||||
ProfileResponse,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
SessionContextRequest,
|
||||
SessionContextResponse,
|
||||
)
|
||||
|
||||
|
||||
@ -124,6 +126,40 @@ class MemorySystemService:
|
||||
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:
|
||||
backends = {"everos": await self._capture(lambda: self.everos.get_profile(user_id))}
|
||||
profile = backends["everos"].result if backends["everos"].status == "success" else None
|
||||
@ -260,6 +296,33 @@ class MemorySystemService:
|
||||
|
||||
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]:
|
||||
return {
|
||||
name: backend.model_copy(update={"result": self._remove_vectors(backend.result)})
|
||||
|
||||
@ -59,6 +59,7 @@ Base path: `/memory-system`
|
||||
| `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}/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 |
|
||||
| `POST` | `/search` | Search OpenViking and EverOS | 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
|
||||
|
||||
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.
|
||||
|
||||
Session context responses include OpenViking context under `context`, EverOS recall under `items`, and compact backend diagnostics under `backends`.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Calling `/messages` before `/users`.
|
||||
|
||||
@ -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>"}'
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
Without LLM planning:
|
||||
|
||||
@ -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():
|
||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
||||
calls = []
|
||||
|
||||
@ -4,6 +4,14 @@ def test_memory_system_server_exposes_routes():
|
||||
paths = {route.path for route in app.routes}
|
||||
assert "/memory-system/users" 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/users/{user_id}/profile" in paths
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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
|
||||
|
||||
|
||||
@ -44,6 +44,19 @@ class FakeOpenViking:
|
||||
await asyncio.sleep(0.01)
|
||||
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:
|
||||
self.calls.append(("commit_session", user_key, session_id))
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
if isinstance(value, dict):
|
||||
return key in value or any(_has_key(item, key) for item in value.values())
|
||||
|
||||
Reference in New Issue
Block a user