replace main with lightweight memory gateway
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# EverOS HTTP server used by the gateway client.
|
||||||
|
EVEROS_BASE_URL=http://127.0.0.1:1995
|
||||||
|
|
||||||
|
# Gateway-owned SQLite database. This does not point at EverOS internal storage.
|
||||||
|
MEMORY_GATEWAY_DB_PATH=./data/memory_gateway.sqlite3
|
||||||
|
|
||||||
|
# Raw uploaded files are stored here before being passed to EverOS by file URI.
|
||||||
|
MEMORY_GATEWAY_STORAGE_DIR=./data/storage
|
||||||
|
|
||||||
|
# Number of resource session IDs sent per EverOS search request.
|
||||||
|
MEMORY_GATEWAY_RESOURCE_SEARCH_BATCH_SIZE=50
|
||||||
|
|
||||||
|
# API server settings used by python main.py.
|
||||||
|
MEMORY_GATEWAY_HOST=0.0.0.0
|
||||||
|
MEMORY_GATEWAY_PORT=8010
|
||||||
|
MEMORY_GATEWAY_RELOAD=false
|
||||||
33
.gitignore
vendored
33
.gitignore
vendored
@ -1,11 +1,18 @@
|
|||||||
# Local runtime configuration
|
# Local environment files
|
||||||
config.yaml
|
|
||||||
*.local.yaml
|
|
||||||
*.secret.yaml
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
everos.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
# Python cache / test artifacts
|
# Gateway runtime data
|
||||||
|
data/
|
||||||
|
storage/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Python caches
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
@ -17,13 +24,13 @@ htmlcov/
|
|||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
# Local editor / agent metadata
|
# Packaging output
|
||||||
.codex
|
build/
|
||||||
.DS_Store
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
# Runtime output
|
# Logs and local process files
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.pid
|
||||||
*.sqlite3
|
|
||||||
obsidian-vault/Reviews/
|
|
||||||
|
|||||||
168
AGENTS.md
168
AGENTS.md
@ -1,168 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
Guidance for coding agents working in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Memory Gateway is a lightweight Python HTTP gateway that exposes one business-facing Memory System API over two memory backends:
|
|
||||||
|
|
||||||
- OpenViking handles sessions, session archives, extracted long-term memories, resources, and semantic search.
|
|
||||||
- EverOS handles profile and episodic-memory style recall.
|
|
||||||
|
|
||||||
The main package is `memory_system_api`. Treat it as a narrow gateway, not a replacement for either backend. Callers should interact with users, sessions, messages, resources, memories, search, and profile endpoints under `/memory-system`.
|
|
||||||
|
|
||||||
## Key Contract
|
|
||||||
|
|
||||||
Business endpoints are gated by `user_id + user_key`.
|
|
||||||
|
|
||||||
- Create a user first with `POST /memory-system/users`.
|
|
||||||
- Save `account.result.user_key` from the response.
|
|
||||||
- Pass `user_id` and `user_key` on later business calls.
|
|
||||||
- Do not ask callers to pass `account_id`.
|
|
||||||
- Do not ask callers to pass OpenViking root keys.
|
|
||||||
- `X-API-Key` is only for protecting the Memory Gateway server itself when `server.api_key` is configured.
|
|
||||||
|
|
||||||
OpenViking user calls use the OpenViking user key as `X-API-Key`. Admin calls, such as account creation, use the configured OpenViking root key internally.
|
|
||||||
|
|
||||||
## Important Paths
|
|
||||||
|
|
||||||
- `memory_system_api/api.py`: FastAPI routes under `/memory-system`.
|
|
||||||
- `memory_system_api/service.py`: orchestration across OpenViking and EverOS.
|
|
||||||
- `memory_system_api/clients.py`: async HTTP clients for backend APIs.
|
|
||||||
- `memory_system_api/schemas.py`: Pydantic request and response models.
|
|
||||||
- `memory_system_api/store.py`: SQLite persistence for user keys, sessions, tasks, and archive metadata.
|
|
||||||
- `memory_system_api/config.py`: YAML and environment-based configuration.
|
|
||||||
- `plugins/memory/memory_system/`: Hermes memory provider plugin that talks to this API.
|
|
||||||
- `skills/memory-system-api/`: local agent skill and reference docs for using this API.
|
|
||||||
- `eval/hermes_memory_eval/`: LoCoMo-style evaluation runner for Hermes memory behavior.
|
|
||||||
- `tests/`: unit tests for clients, service orchestration, server routes, store behavior, and plugin behavior.
|
|
||||||
|
|
||||||
## API Surface
|
|
||||||
|
|
||||||
Current Memory System endpoints include:
|
|
||||||
|
|
||||||
- `GET /memory-system/health`
|
|
||||||
- `POST /memory-system/users`
|
|
||||||
- `POST /memory-system/messages`
|
|
||||||
- `POST /memory-system/sessions/{session_id}/commit`
|
|
||||||
- `POST /memory-system/sessions/{session_id}/extract`
|
|
||||||
- `GET|POST /memory-system/sessions/{session_id}/context`
|
|
||||||
- `GET|POST /memory-system/openviking/tasks/{task_id}`
|
|
||||||
- `GET /memory-system/memories`
|
|
||||||
- `GET /memory-system/memories/content`
|
|
||||||
- `POST /memory-system/memories`
|
|
||||||
- `DELETE /memory-system/memories`
|
|
||||||
- `POST /memory-system/resources`
|
|
||||||
- `DELETE /memory-system/resources`
|
|
||||||
- `POST /memory-system/search`
|
|
||||||
- `GET|POST /memory-system/users/{user_id}/profile`
|
|
||||||
|
|
||||||
Memory writes are implemented through OpenViking `POST /api/v1/content/write` with `mode` set to `create`, `replace`, or `append`. Memory reads and deletes use `content/read`, `fs/ls`, and `fs`.
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
The project uses Python 3.10+.
|
|
||||||
|
|
||||||
Common setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync --extra dev
|
|
||||||
```
|
|
||||||
|
|
||||||
If `uv` is not installed on a fresh server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
uv sync --extra dev
|
|
||||||
```
|
|
||||||
|
|
||||||
In sandboxed environments, `uv` may need a writable cache directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv --cache-dir /private/tmp/uv-cache sync --extra dev
|
|
||||||
uv --cache-dir /private/tmp/uv-cache run pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running The Server
|
|
||||||
|
|
||||||
Copy the example config before local runs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp config.example.yaml config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Start OpenViking and EverOS first, then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run memory-gateway --config config.yaml --host 0.0.0.0 --port 1934
|
|
||||||
```
|
|
||||||
|
|
||||||
The default Memory System base URL is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:1934/memory-system
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Run focused tests for touched areas whenever possible:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest -q tests/test_memory_system_clients.py
|
|
||||||
uv run pytest -q tests/test_memory_system_service.py
|
|
||||||
uv run pytest -q tests/test_memory_system_server.py
|
|
||||||
uv run pytest -q tests/test_memory_system_store.py
|
|
||||||
uv run pytest -q tests/test_hermes_memory_system_plugin.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the full suite before handing off broad changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest -q
|
|
||||||
uv run python -m compileall -q memory_system_api plugins eval tests
|
|
||||||
```
|
|
||||||
|
|
||||||
If dependencies cannot be installed, at minimum run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m compileall -q memory_system_api plugins eval tests
|
|
||||||
```
|
|
||||||
|
|
||||||
Then report the missing `uv` or test dependency clearly.
|
|
||||||
|
|
||||||
## Coding Guidelines
|
|
||||||
|
|
||||||
- Keep changes scoped to the relevant API, service, client, schema, plugin, or docs surface.
|
|
||||||
- Prefer existing patterns in `api.py`, `service.py`, `clients.py`, and tests.
|
|
||||||
- Add or update tests before changing behavior.
|
|
||||||
- Preserve the `BackendStatus` response pattern: top-level status plus per-backend status, result, and error.
|
|
||||||
- Keep OpenViking-only operations from accidentally touching EverOS.
|
|
||||||
- Keep search responses compact. Do not return embedding vectors or large raw EverOS `original_data` blobs.
|
|
||||||
- Do not introduce in-memory identity caches; SQLite is the source of truth for user keys, sessions, tasks, and archive URIs.
|
|
||||||
- Do not commit local `config.yaml`, SQLite databases, real user keys, root keys, or API secrets.
|
|
||||||
- Use `rg` for searches and prefer small, targeted patches.
|
|
||||||
|
|
||||||
## Testing Patterns
|
|
||||||
|
|
||||||
The tests use lightweight fake clients and stores. When adding backend client behavior:
|
|
||||||
|
|
||||||
- Add client tests that assert exact HTTP path, headers, params, and JSON body.
|
|
||||||
- Add service tests that assert `credential_for_user` is called and the correct backend receives the operation.
|
|
||||||
- Add server route tests when new endpoints are exposed.
|
|
||||||
- For plugin changes, update `tests/test_hermes_memory_system_plugin.py`.
|
|
||||||
|
|
||||||
## Documentation Updates
|
|
||||||
|
|
||||||
When changing public behavior, update the relevant docs:
|
|
||||||
|
|
||||||
- `README.md` for user-facing setup and API details.
|
|
||||||
- `skills/memory-system-api/SKILL.md` for agent-facing usage.
|
|
||||||
- `skills/memory-system-api/references/api.md` when the longer API reference needs to match.
|
|
||||||
- `plugins/memory/memory_system/README.md` when Hermes plugin behavior changes.
|
|
||||||
|
|
||||||
## Safety Notes
|
|
||||||
|
|
||||||
- Never include real OpenViking root keys, user keys, Memory System API keys, or EverOS keys in examples.
|
|
||||||
- Prefer placeholders such as `<USER_KEY>`, `<MEMORY_SYSTEM_API_KEY>`, and `<OPENVIKING_ROOT_KEY>`.
|
|
||||||
- Keep destructive operations explicit. Deleting memories or resources maps to OpenViking `DELETE /api/v1/fs`; check `recursive` defaults and tests carefully.
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Copy this file to config.yaml and replace placeholders with local values.
|
|
||||||
# Do not commit config.yaml because it may contain backend root keys.
|
|
||||||
|
|
||||||
server:
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: 1934
|
|
||||||
# Optional key that protects Memory Gateway itself. Leave empty for local dev.
|
|
||||||
api_key: ""
|
|
||||||
|
|
||||||
openviking:
|
|
||||||
# OpenViking HTTP server. The api_key must match server.root_api_key in ov.conf.
|
|
||||||
url: "http://127.0.0.1:1933"
|
|
||||||
api_key: "<OPENVIKING_ROOT_KEY>"
|
|
||||||
|
|
||||||
everos:
|
|
||||||
# EverOS HTTP server exposing /api/v1/memory/*.
|
|
||||||
url: "http://127.0.0.1:1995"
|
|
||||||
|
|
||||||
storage:
|
|
||||||
sqlite_path: "./memory_system_api.sqlite3"
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level: "INFO"
|
|
||||||
7
core/__init__.py
Normal file
7
core/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"""Lightweight Memory Gateway for EverOS."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
167
core/api.py
Normal file
167
core/api.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .config import GatewayConfig
|
||||||
|
from .db import init_db
|
||||||
|
from .everos_client import EverOSClient
|
||||||
|
from .repository import MemoryRepository
|
||||||
|
from .service import MemoryGatewayService
|
||||||
|
|
||||||
|
|
||||||
|
class SearchMemoriesRequest(BaseModel):
|
||||||
|
user_id: str = Field(min_length=1)
|
||||||
|
user_key: str = Field(min_length=1)
|
||||||
|
conversation_id: str | None = None
|
||||||
|
query: str = Field(min_length=1)
|
||||||
|
scope: list[Literal["current_chat", "resources", "all_user_memory"]] = Field(
|
||||||
|
default_factory=lambda: ["current_chat", "resources"]
|
||||||
|
)
|
||||||
|
top_k: int = Field(default=8, ge=1, le=100)
|
||||||
|
app_id: str = "default"
|
||||||
|
project_id: str = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryOverrideRequest(BaseModel):
|
||||||
|
user_id: str = Field(min_length=1)
|
||||||
|
user_key: str = Field(min_length=1)
|
||||||
|
session_id: str | None = None
|
||||||
|
override_text: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryDeleteRequest(BaseModel):
|
||||||
|
user_id: str = Field(min_length=1)
|
||||||
|
user_key: str = Field(min_length=1)
|
||||||
|
session_id: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateRequest(BaseModel):
|
||||||
|
user_id: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(
|
||||||
|
*,
|
||||||
|
config: GatewayConfig | None = None,
|
||||||
|
everos_client: Any | None = None,
|
||||||
|
) -> FastAPI:
|
||||||
|
cfg = config or GatewayConfig.from_env()
|
||||||
|
init_db(cfg.database_path)
|
||||||
|
repository = MemoryRepository(cfg.database_path)
|
||||||
|
client = everos_client or EverOSClient(cfg.everos_base_url)
|
||||||
|
service = MemoryGatewayService(cfg, repository, client)
|
||||||
|
|
||||||
|
app = FastAPI(title="memory-gateway2", version="0.1.0")
|
||||||
|
app.state.config = cfg
|
||||||
|
app.state.repository = repository
|
||||||
|
app.state.everos_client = client
|
||||||
|
app.state.gateway_service = service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def require_user(user_id: str, user_key: str) -> None:
|
||||||
|
if not service.authenticate_user(user_id, user_key):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid user credentials")
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
async def create_user(request: UserCreateRequest) -> dict[str, Any]:
|
||||||
|
return service.create_user(request.user_id)
|
||||||
|
|
||||||
|
@router.post("/resources")
|
||||||
|
async def upload_resource(
|
||||||
|
user_id: str = Form(...),
|
||||||
|
user_key: str = Form(...),
|
||||||
|
app_id: str = Form("default"),
|
||||||
|
project_id: str = Form("default"),
|
||||||
|
title: str | None = Form(None),
|
||||||
|
description: str | None = Form(None),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_user(user_id, user_key)
|
||||||
|
return await service.upload_resource(
|
||||||
|
user_id=user_id,
|
||||||
|
app_id=app_id,
|
||||||
|
project_id=project_id,
|
||||||
|
file=file,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/resources")
|
||||||
|
async def list_resources(
|
||||||
|
user_id: str,
|
||||||
|
user_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_user(user_id, user_key)
|
||||||
|
return {"resources": service.list_resources(user_id)}
|
||||||
|
|
||||||
|
@router.get("/resources/{resource_id}")
|
||||||
|
async def get_resource(
|
||||||
|
resource_id: str,
|
||||||
|
user_id: str,
|
||||||
|
user_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_user(user_id, user_key)
|
||||||
|
resource = service.get_resource_detail(resource_id, user_id)
|
||||||
|
if resource is None:
|
||||||
|
return {"resources": []}
|
||||||
|
return {"resources": [resource]}
|
||||||
|
|
||||||
|
@router.delete("/resources/{resource_id}")
|
||||||
|
async def delete_resource(
|
||||||
|
resource_id: str,
|
||||||
|
user_id: str,
|
||||||
|
user_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_user(user_id, user_key)
|
||||||
|
resource = service.delete_resource(resource_id, user_id)
|
||||||
|
if resource is None:
|
||||||
|
raise HTTPException(status_code=404, detail="resource not found")
|
||||||
|
return resource
|
||||||
|
|
||||||
|
@router.post("/memories/search")
|
||||||
|
async def search_memories(
|
||||||
|
request: SearchMemoriesRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_user(request.user_id, request.user_key)
|
||||||
|
return await service.search_memories(
|
||||||
|
user_id=request.user_id,
|
||||||
|
query=request.query,
|
||||||
|
conversation_id=request.conversation_id,
|
||||||
|
scope=request.scope,
|
||||||
|
top_k=request.top_k,
|
||||||
|
app_id=request.app_id,
|
||||||
|
project_id=request.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.patch("/memories/{memory_id}")
|
||||||
|
async def patch_memory(
|
||||||
|
memory_id: str,
|
||||||
|
request: MemoryOverrideRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_user(request.user_id, request.user_key)
|
||||||
|
return service.upsert_override(
|
||||||
|
user_id=request.user_id,
|
||||||
|
memory_id=memory_id,
|
||||||
|
session_id=request.session_id,
|
||||||
|
override_text=request.override_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/memories/{memory_id}")
|
||||||
|
async def delete_memory(
|
||||||
|
memory_id: str,
|
||||||
|
request: MemoryDeleteRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
require_user(request.user_id, request.user_key)
|
||||||
|
return service.delete_memory(
|
||||||
|
user_id=request.user_id,
|
||||||
|
memory_id=memory_id,
|
||||||
|
session_id=request.session_id,
|
||||||
|
reason=request.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
return app
|
||||||
40
core/config.py
Normal file
40
core/config.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayConfig:
|
||||||
|
everos_base_url: str = "http://127.0.0.1:8000"
|
||||||
|
database_path: Path = _PROJECT_ROOT / "data" / "memory_gateway.sqlite3"
|
||||||
|
storage_dir: Path = _PROJECT_ROOT / "data" / "storage"
|
||||||
|
resource_search_batch_size: int = 50
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> GatewayConfig:
|
||||||
|
return cls(
|
||||||
|
everos_base_url=os.environ.get(
|
||||||
|
"EVEROS_BASE_URL",
|
||||||
|
"http://127.0.0.1:8000",
|
||||||
|
).rstrip("/"),
|
||||||
|
database_path=Path(
|
||||||
|
os.environ.get(
|
||||||
|
"MEMORY_GATEWAY_DB_PATH",
|
||||||
|
str(_PROJECT_ROOT / "data" / "memory_gateway.sqlite3"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
storage_dir=Path(
|
||||||
|
os.environ.get(
|
||||||
|
"MEMORY_GATEWAY_STORAGE_DIR",
|
||||||
|
str(_PROJECT_ROOT / "data" / "storage"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
resource_search_batch_size=int(
|
||||||
|
os.environ.get("MEMORY_GATEWAY_RESOURCE_SEARCH_BATCH_SIZE", "50")
|
||||||
|
),
|
||||||
|
)
|
||||||
89
core/db.py
Normal file
89
core/db.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_key TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_resources (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
app_id TEXT NOT NULL DEFAULT 'default',
|
||||||
|
project_id TEXT NOT NULL DEFAULT 'default',
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
original_filename TEXT,
|
||||||
|
mime_type TEXT,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
uri TEXT NOT NULL,
|
||||||
|
uri_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sha256 TEXT,
|
||||||
|
size_bytes INTEGER,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
deleted_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_resources_user_scope
|
||||||
|
ON user_resources (user_id, app_id, project_id, status, deleted_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_resources_session_id
|
||||||
|
ON user_resources (session_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_resources_user_id
|
||||||
|
ON user_resources (user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_tombstones (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
memory_id TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memory_tombstones_user_memory
|
||||||
|
ON memory_tombstones (user_id, memory_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memory_tombstones_user_session
|
||||||
|
ON memory_tombstones (user_id, session_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_overrides (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
memory_id TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
override_text TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memory_overrides_user_memory_active
|
||||||
|
ON memory_overrides (user_id, memory_id, is_active);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def connect(db_path: Path) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: Path) -> None:
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with connect(db_path) as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
41
core/everos_client.py
Normal file
41
core/everos_client.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class EverOSClient:
|
||||||
|
def __init__(self, base_url: str, timeout: float = 120.0) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
async def add_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await self._post("/api/v1/memory/add", payload)
|
||||||
|
|
||||||
|
async def flush_memory(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
app_id: str,
|
||||||
|
project_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._post(
|
||||||
|
"/api/v1/memory/flush",
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"app_id": app_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await self._post("/api/v1/memory/search", payload)
|
||||||
|
|
||||||
|
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
timeout=self.timeout,
|
||||||
|
) as client:
|
||||||
|
response = await client.post(path, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
282
core/repository.py
Normal file
282
core/repository.py
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: Any | None) -> dict[str, Any] | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryRepository:
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
self.db_path = db_path
|
||||||
|
|
||||||
|
def create_user(self, user_id: str, user_key: str) -> dict[str, Any]:
|
||||||
|
existing = self.get_user(user_id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
now = utc_now()
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, user_key, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, user_key, now, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
user = self.get_user(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise RuntimeError("created user could not be read back")
|
||||||
|
return user
|
||||||
|
|
||||||
|
def get_user(self, user_id: str) -> dict[str, Any] | None:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
def create_resource(self, **values: Any) -> dict[str, Any]:
|
||||||
|
now = utc_now()
|
||||||
|
payload = {
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"deleted_at": None,
|
||||||
|
**values,
|
||||||
|
}
|
||||||
|
columns = ", ".join(payload)
|
||||||
|
placeholders = ", ".join(f":{key}" for key in payload)
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO user_resources ({columns}) VALUES ({placeholders})",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
resource = self.get_resource(str(payload["id"]))
|
||||||
|
if resource is None:
|
||||||
|
raise RuntimeError("created resource could not be read back")
|
||||||
|
return resource
|
||||||
|
|
||||||
|
def update_resource_status(
|
||||||
|
self,
|
||||||
|
resource_id: str,
|
||||||
|
status: str,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_resources
|
||||||
|
SET status = ?, error_message = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(status, error_message, utc_now(), resource_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return self.get_resource(resource_id)
|
||||||
|
|
||||||
|
def soft_delete_resource(
|
||||||
|
self,
|
||||||
|
resource_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
now = utc_now()
|
||||||
|
where = "id = ? AND deleted_at IS NULL"
|
||||||
|
params: tuple[Any, ...] = (now, now, resource_id)
|
||||||
|
if user_id is not None:
|
||||||
|
where += " AND user_id = ?"
|
||||||
|
params = (now, now, resource_id, user_id)
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE user_resources
|
||||||
|
SET status = 'deleted', deleted_at = ?, updated_at = ?
|
||||||
|
WHERE {where}
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return self.get_resource(resource_id)
|
||||||
|
|
||||||
|
def get_resource(self, resource_id: str) -> dict[str, Any] | None:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM user_resources WHERE id = ?",
|
||||||
|
(resource_id,),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
def get_resource_for_user(
|
||||||
|
self,
|
||||||
|
resource_id: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM user_resources
|
||||||
|
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(resource_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
def get_resource_by_session(self, session_id: str) -> dict[str, Any] | None:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM user_resources WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
def get_resource_by_session_for_user(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM user_resources
|
||||||
|
WHERE session_id = ? AND user_id = ? AND deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
(session_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
def list_resources(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM user_resources
|
||||||
|
WHERE user_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def list_extracted_resources(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
app_id: str,
|
||||||
|
project_id: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM user_resources
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND app_id = ?
|
||||||
|
AND project_id = ?
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status = 'extracted'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id, app_id, project_id),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def add_tombstone(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
memory_id: str | None,
|
||||||
|
session_id: str | None,
|
||||||
|
reason: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
tombstone_id = f"t_{uuid.uuid4().hex}"
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO memory_tombstones
|
||||||
|
(id, user_id, memory_id, session_id, reason, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(tombstone_id, user_id, memory_id, session_id, reason, utc_now()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": tombstone_id}
|
||||||
|
|
||||||
|
def get_tombstones(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM memory_tombstones WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def upsert_override(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
memory_id: str,
|
||||||
|
session_id: str | None,
|
||||||
|
override_text: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = utc_now()
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM memory_overrides
|
||||||
|
WHERE user_id = ? AND memory_id = ? AND is_active = TRUE
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, memory_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
override_id = row["id"]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE memory_overrides
|
||||||
|
SET session_id = ?, override_text = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(session_id, override_text, now, override_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
override_id = f"o_{uuid.uuid4().hex}"
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO memory_overrides
|
||||||
|
(
|
||||||
|
id, user_id, memory_id, session_id, override_text,
|
||||||
|
is_active, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, TRUE, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
override_id,
|
||||||
|
user_id,
|
||||||
|
memory_id,
|
||||||
|
session_id,
|
||||||
|
override_text,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": override_id}
|
||||||
|
|
||||||
|
def get_active_overrides(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
with connect(self.db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM memory_overrides
|
||||||
|
WHERE user_id = ? AND is_active = TRUE
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
462
core/service.py
Normal file
462
core/service.py
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import mimetypes
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from .config import GatewayConfig
|
||||||
|
from .repository import MemoryRepository
|
||||||
|
|
||||||
|
|
||||||
|
def new_resource_id() -> str:
|
||||||
|
return f"r_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def resource_session_id(user_id: str, resource_id: str) -> str:
|
||||||
|
return f"resource:{user_id}:{resource_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def public_resource_uri(user_id: str, resource_id: str) -> str:
|
||||||
|
return f"resource://{user_id}/{resource_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def infer_content_type(filename: str | None, mime_type: str | None) -> str:
|
||||||
|
mime = (mime_type or mimetypes.guess_type(filename or "")[0] or "").lower()
|
||||||
|
suffix = Path(filename or "").suffix.lower()
|
||||||
|
if mime.startswith("image/"):
|
||||||
|
return "image"
|
||||||
|
if mime.startswith("audio/"):
|
||||||
|
return "audio"
|
||||||
|
if mime == "application/pdf" or suffix == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
if mime in {"text/html", "application/xhtml+xml"} or suffix in {".html", ".htm"}:
|
||||||
|
return "html"
|
||||||
|
if mime.startswith("text/plain") or suffix in {".txt", ".md", ".csv", ".log"}:
|
||||||
|
return "text"
|
||||||
|
return "doc"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(filename: str | None) -> str:
|
||||||
|
name = Path(filename or "upload.bin").name
|
||||||
|
return name or "upload.bin"
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_upload(file: UploadFile, destination: Path) -> tuple[str, int]:
|
||||||
|
sha256 = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with destination.open("wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = file.file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
sha256.update(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
return sha256.hexdigest(), size
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryGatewayService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: GatewayConfig,
|
||||||
|
repository: MemoryRepository,
|
||||||
|
everos_client: Any,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.repository = repository
|
||||||
|
self.everos_client = everos_client
|
||||||
|
|
||||||
|
def create_user(self, user_id: str) -> dict[str, Any]:
|
||||||
|
user_key = f"uk_{secrets.token_urlsafe(32)}"
|
||||||
|
user = self.repository.create_user(user_id, user_key)
|
||||||
|
return {
|
||||||
|
"user_id": user["id"],
|
||||||
|
"user_key": user["user_key"],
|
||||||
|
"created_at": user["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate_user(self, user_id: str, user_key: str) -> bool:
|
||||||
|
user = self.repository.get_user(user_id)
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
return secrets.compare_digest(str(user["user_key"]), user_key)
|
||||||
|
|
||||||
|
async def upload_resource(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
app_id: str,
|
||||||
|
project_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
title: str | None,
|
||||||
|
description: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resource_id = new_resource_id()
|
||||||
|
session_id = resource_session_id(user_id, resource_id)
|
||||||
|
original_filename = _safe_filename(file.filename)
|
||||||
|
mime_type = file.content_type or mimetypes.guess_type(original_filename)[0]
|
||||||
|
content_type = infer_content_type(original_filename, mime_type)
|
||||||
|
stored_path = self.config.storage_dir / user_id / resource_id / original_filename
|
||||||
|
sha256, size_bytes = _copy_upload(file, stored_path)
|
||||||
|
internal_uri = stored_path.resolve().as_uri()
|
||||||
|
|
||||||
|
resource = self.repository.create_resource(
|
||||||
|
id=resource_id,
|
||||||
|
user_id=user_id,
|
||||||
|
app_id=app_id,
|
||||||
|
project_id=project_id,
|
||||||
|
session_id=session_id,
|
||||||
|
original_filename=original_filename,
|
||||||
|
mime_type=mime_type,
|
||||||
|
content_type=content_type,
|
||||||
|
uri=internal_uri,
|
||||||
|
uri_public=False,
|
||||||
|
sha256=sha256,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
status="ingesting",
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.everos_client.add_memory(
|
||||||
|
self._build_add_payload(
|
||||||
|
resource=resource,
|
||||||
|
user_id=user_id,
|
||||||
|
app_id=app_id,
|
||||||
|
project_id=project_id,
|
||||||
|
filename=original_filename,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.everos_client.flush_memory(session_id, app_id, project_id)
|
||||||
|
except Exception as exc:
|
||||||
|
failed = self.repository.update_resource_status(
|
||||||
|
resource_id,
|
||||||
|
"failed",
|
||||||
|
str(exc),
|
||||||
|
)
|
||||||
|
return self._resource_summary(failed or resource)
|
||||||
|
|
||||||
|
extracted = self.repository.update_resource_status(resource_id, "extracted")
|
||||||
|
return self._resource_summary(extracted or resource)
|
||||||
|
|
||||||
|
def _build_add_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resource: dict[str, Any],
|
||||||
|
user_id: str,
|
||||||
|
app_id: str,
|
||||||
|
project_id: str,
|
||||||
|
filename: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_id": resource["session_id"],
|
||||||
|
"app_id": app_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender_id": user_id,
|
||||||
|
"role": "user",
|
||||||
|
"timestamp": 1781068800000,
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": resource["content_type"],
|
||||||
|
"uri": resource["uri"],
|
||||||
|
"name": filename,
|
||||||
|
"ext": Path(filename).suffix.lstrip(".") or None,
|
||||||
|
"extras": {
|
||||||
|
"resource_id": resource["id"],
|
||||||
|
"source": "user_upload",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_resources(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
return [self._resource_detail(item) for item in self.repository.list_resources(user_id)]
|
||||||
|
|
||||||
|
def get_resource_detail(
|
||||||
|
self,
|
||||||
|
resource_id: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
resource = self.repository.get_resource_for_user(resource_id, user_id)
|
||||||
|
if resource is None:
|
||||||
|
return None
|
||||||
|
return self._resource_detail(resource)
|
||||||
|
|
||||||
|
def delete_resource(self, resource_id: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
before = self.repository.get_resource_for_user(resource_id, user_id)
|
||||||
|
if before is None:
|
||||||
|
return None
|
||||||
|
resource = self.repository.soft_delete_resource(resource_id, user_id)
|
||||||
|
return self._resource_summary(resource)
|
||||||
|
|
||||||
|
async def search_memories(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
query: str,
|
||||||
|
conversation_id: str | None,
|
||||||
|
scope: list[str],
|
||||||
|
top_k: int,
|
||||||
|
app_id: str,
|
||||||
|
project_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
session_resource_map: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
if "current_chat" in scope and conversation_id:
|
||||||
|
payload = self._search_payload(
|
||||||
|
user_id=user_id,
|
||||||
|
query=query,
|
||||||
|
top_k=top_k,
|
||||||
|
app_id=app_id,
|
||||||
|
project_id=project_id,
|
||||||
|
filters={"session_id": f"chat:{conversation_id}"},
|
||||||
|
)
|
||||||
|
results.extend(
|
||||||
|
self._extract_results(
|
||||||
|
await self.everos_client.search_memory(payload),
|
||||||
|
source_scope="current_chat",
|
||||||
|
session_resource_map=session_resource_map,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "resources" in scope:
|
||||||
|
resources = self.repository.list_extracted_resources(
|
||||||
|
user_id,
|
||||||
|
app_id,
|
||||||
|
project_id,
|
||||||
|
)
|
||||||
|
session_resource_map.update({item["session_id"]: item for item in resources})
|
||||||
|
session_ids = [item["session_id"] for item in resources]
|
||||||
|
for batch in _chunks(session_ids, self.config.resource_search_batch_size):
|
||||||
|
payload = self._search_payload(
|
||||||
|
user_id=user_id,
|
||||||
|
query=query,
|
||||||
|
top_k=top_k,
|
||||||
|
app_id=app_id,
|
||||||
|
project_id=project_id,
|
||||||
|
filters={"session_id": {"in": batch}},
|
||||||
|
)
|
||||||
|
results.extend(
|
||||||
|
self._extract_results(
|
||||||
|
await self.everos_client.search_memory(payload),
|
||||||
|
source_scope="resources",
|
||||||
|
session_resource_map=session_resource_map,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "all_user_memory" in scope:
|
||||||
|
payload = self._search_payload(
|
||||||
|
user_id=user_id,
|
||||||
|
query=query,
|
||||||
|
top_k=top_k,
|
||||||
|
app_id=app_id,
|
||||||
|
project_id=project_id,
|
||||||
|
filters=None,
|
||||||
|
)
|
||||||
|
results.extend(
|
||||||
|
self._extract_results(
|
||||||
|
await self.everos_client.search_memory(payload),
|
||||||
|
source_scope="all_user_memory",
|
||||||
|
session_resource_map=session_resource_map,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered = self._apply_tombstones(user_id, results)
|
||||||
|
overridden = self._apply_overrides(user_id, filtered)
|
||||||
|
return {"results": overridden}
|
||||||
|
|
||||||
|
def _search_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
query: str,
|
||||||
|
top_k: int,
|
||||||
|
app_id: str,
|
||||||
|
project_id: str,
|
||||||
|
filters: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"query": query,
|
||||||
|
"top_k": top_k,
|
||||||
|
"app_id": app_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
}
|
||||||
|
if filters is not None:
|
||||||
|
payload["filters"] = filters
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _extract_results(
|
||||||
|
self,
|
||||||
|
response: dict[str, Any],
|
||||||
|
*,
|
||||||
|
source_scope: str,
|
||||||
|
session_resource_map: dict[str, dict[str, Any]],
|
||||||
|
user_id: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
data = response.get("data", {})
|
||||||
|
raw_items: list[dict[str, Any]] = []
|
||||||
|
for key in (
|
||||||
|
"episodes",
|
||||||
|
"profiles",
|
||||||
|
"agent_cases",
|
||||||
|
"agent_skills",
|
||||||
|
"unprocessed_messages",
|
||||||
|
):
|
||||||
|
raw_items.extend(data.get(key, []) or [])
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
for raw in raw_items:
|
||||||
|
session_id = raw.get("session_id")
|
||||||
|
resource = session_resource_map.get(session_id)
|
||||||
|
if resource is None and isinstance(session_id, str):
|
||||||
|
resource = self.repository.get_resource_by_session_for_user(
|
||||||
|
session_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"id": raw.get("id"),
|
||||||
|
"session_id": session_id,
|
||||||
|
"text": _display_text(raw),
|
||||||
|
"score": raw.get("score"),
|
||||||
|
"source_scope": source_scope,
|
||||||
|
"resource_id": resource["id"] if resource else None,
|
||||||
|
"resource_uri": (
|
||||||
|
public_resource_uri(user_id, resource["id"]) if resource else None
|
||||||
|
),
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _apply_tombstones(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
results: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
tombstones = self.repository.get_tombstones(user_id)
|
||||||
|
memory_ids = {item["memory_id"] for item in tombstones if item["memory_id"]}
|
||||||
|
session_ids = {item["session_id"] for item in tombstones if item["session_id"]}
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in results
|
||||||
|
if item.get("id") not in memory_ids
|
||||||
|
and item.get("session_id") not in session_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
def _apply_overrides(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
results: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
overrides = {
|
||||||
|
item["memory_id"]: item
|
||||||
|
for item in self.repository.get_active_overrides(user_id)
|
||||||
|
if item["memory_id"]
|
||||||
|
}
|
||||||
|
for result in results:
|
||||||
|
override = overrides.get(result.get("id"))
|
||||||
|
if override:
|
||||||
|
result["text"] = override["override_text"]
|
||||||
|
result["override_id"] = override["id"]
|
||||||
|
return results
|
||||||
|
|
||||||
|
def upsert_override(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
memory_id: str,
|
||||||
|
session_id: str | None,
|
||||||
|
override_text: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
override = self.repository.upsert_override(
|
||||||
|
user_id,
|
||||||
|
memory_id,
|
||||||
|
session_id,
|
||||||
|
override_text,
|
||||||
|
)
|
||||||
|
return {"memory_id": memory_id, "override_id": override["id"], "status": "active"}
|
||||||
|
|
||||||
|
def delete_memory(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
memory_id: str,
|
||||||
|
session_id: str | None,
|
||||||
|
reason: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
tombstone = self.repository.add_tombstone(
|
||||||
|
user_id,
|
||||||
|
memory_id,
|
||||||
|
session_id,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
return {"memory_id": memory_id, "tombstone_id": tombstone["id"], "status": "deleted"}
|
||||||
|
|
||||||
|
def _resource_summary(self, resource: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"resource_id": resource["id"],
|
||||||
|
"session_id": resource["session_id"],
|
||||||
|
"uri": public_resource_uri(resource["user_id"], resource["id"]),
|
||||||
|
"status": resource["status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resource_detail(self, resource: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"resource_id": resource["id"],
|
||||||
|
"user_id": resource["user_id"],
|
||||||
|
"filename": resource["original_filename"],
|
||||||
|
"content_type": resource["content_type"],
|
||||||
|
"mime_type": resource["mime_type"],
|
||||||
|
"uri": public_resource_uri(resource["user_id"], resource["id"]),
|
||||||
|
"session_id": resource["session_id"],
|
||||||
|
"status": resource["status"],
|
||||||
|
"title": resource["title"],
|
||||||
|
"description": resource["description"],
|
||||||
|
"created_at": resource["created_at"],
|
||||||
|
"updated_at": resource["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks(items: list[str], size: int) -> list[list[str]]:
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
return [items[index : index + size] for index in range(0, len(items), size)]
|
||||||
|
|
||||||
|
|
||||||
|
def _display_text(raw: dict[str, Any]) -> str:
|
||||||
|
for key in (
|
||||||
|
"episode",
|
||||||
|
"summary",
|
||||||
|
"content",
|
||||||
|
"profile_data",
|
||||||
|
"task_intent",
|
||||||
|
"approach",
|
||||||
|
"key_insight",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
):
|
||||||
|
value = raw.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
return ""
|
||||||
@ -1,77 +0,0 @@
|
|||||||
输入user_id和session_id插入memories
|
|
||||||
curl -X POST "http://localhost:1995/api/v1/memories" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "user_001",
|
|
||||||
"session_id": "default",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"message_id": "msg_007",
|
|
||||||
"timestamp": 1778724000000,
|
|
||||||
"role": "user",
|
|
||||||
"content": "我喜欢喝拿铁,不喜欢美式。"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}'
|
|
||||||
|
|
||||||
{"data":{"request_id":"4535506c-26b6-4741-be62-3723db7a552c","message_count":1,"status":"accumulated","message":"Messages accepted"}
|
|
||||||
|
|
||||||
|
|
||||||
status=accumulated 表示先缓存了,等待边界检测;status=extracted 才表示已经触发记忆提取。
|
|
||||||
如果想强制触发总结/提取:调用 flush
|
|
||||||
|
|
||||||
|
|
||||||
curl -X POST "http://localhost:1995/api/v1/memories/flush" -H "Content-Type: application/json" -d '{
|
|
||||||
"user_id": "user_001",
|
|
||||||
"session_id": "default"
|
|
||||||
}'
|
|
||||||
|
|
||||||
{"data":{"request_id":"cc3e24be-9127-41aa-aefe-2ee80eacd054","status":"extracted","message":"Flush completed"}}
|
|
||||||
|
|
||||||
|
|
||||||
curl -X POST "http://localhost:1995/api/v1/memories/search" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"query": "Tom 喜欢喝什么咖啡?",
|
|
||||||
"method": "hybrid",
|
|
||||||
"memory_types": ["episodic_memory", "profile"],
|
|
||||||
"filters": {
|
|
||||||
"user_id": "user_001"
|
|
||||||
},
|
|
||||||
"top_k": 10,
|
|
||||||
"include_original_data": true
|
|
||||||
}'
|
|
||||||
|
|
||||||
{"data":{"episodes":[],"profiles":[{"id":"6a058e72e0fcbba549ae94d8","user_id":"user_001","group_id":"gen_solo_669f08bf6134","profile_data":{"item_type":"explicit_info","embed_text":"饮食偏好: 喜欢喝拿铁,不喜欢美式咖啡"},"scenario":"solo","memcell_count":1,"score":0.7263925671577454}],"raw_messages":[],"agent_memory":null,"query":{"text":"Tom 喜欢喝什么咖啡?","method":"hybrid","filters_applied":{"user_id":"user_001"}},"original_data":{"episodes":{},"profiles":{}}}}
|
|
||||||
|
|
||||||
|
|
||||||
按用户/群组/时间拉取记忆:
|
|
||||||
curl -X POST "http://localhost:1995/api/v1/memories/get" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"memory_type": "episodic_memory",
|
|
||||||
"filters": {
|
|
||||||
"user_id": "user_001"
|
|
||||||
},
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20,
|
|
||||||
"rank_by": "timestamp",
|
|
||||||
"rank_order": "desc"
|
|
||||||
}'
|
|
||||||
|
|
||||||
{"data":{"episodes":[{"id":"6a058e5de0fcbba549ae94d6","user_id":"user_001","group_id":"gen_solo_669f08bf6134","session_id":"default","timestamp":"2026-05-14T02:00:00Z","participants":["user_001"],"sender_ids":["user_001"],"summary":"2026年5月14日(星期四)凌晨02:00 UTC,汤姆明确表达了自己的咖啡偏好。他陈述喜爱饮用拿铁咖啡,同时明确表示不偏好美式咖啡。","subject":"汤姆于2026年5月14日凌晨表达咖啡口味偏好","episode":"2026年5月14日(星期四)凌晨02:00 UTC,汤姆明确表达了自己的咖啡偏好。他陈述喜爱饮用拿铁咖啡,同时明确表示不偏好美式咖啡。","type":"Conversation","parent_type":"memcell","parent_id":"6a058e46e0fcbba549ae94d3"}],"profiles":[],"agent_cases":[],"agent_skills":[],"total_count":1,"count":1}}
|
|
||||||
|
|
||||||
|
|
||||||
查 profile 画像:
|
|
||||||
(OpenViking) tom@tom:~$ curl -X POST "http://localhost:1995/api/v1/memories/get" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"memory_type": "profile",
|
|
||||||
"filters": {
|
|
||||||
"user_id": "user_001"
|
|
||||||
},
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20
|
|
||||||
}'
|
|
||||||
|
|
||||||
{"data":{"episodes":[],"profiles":[{"id":"6a058e71e0fcbba549ae94d7","user_id":"user_001","group_id":"gen_solo_669f08bf6134","profile_data":{"id":null,"memory_type":"profile","user_id":"user_001","user_name":null,"timestamp":"2026-05-14T08:57:00.574433+00:00","group_id":"gen_solo_669f08bf6134","explicit_info":[{"category":"饮食偏好","description":"喜欢喝拿铁,不喜欢美式咖啡","evidence":"2026年5月14日用户明确表示“我喜欢喝拿铁,不喜欢美式。”","sources":["6a058e46e0fcbba549ae94d3"]}],"implicit_traits":[],"last_updated":"2026-05-14T08:57:21.222404+00:00","processed_episode_ids":["6a058e46e0fcbba549ae94d3"]},"scenario":"solo","memcell_count":1}],"agent_cases":[],"agent_skills":[],"total_count":1,"count":1}}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
创建用户
|
|
||||||
curl -sS -X POST "http://localhost:1934/memory-system/users" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id":"userC"}'
|
|
||||||
|
|
||||||
{"status":"success","account":{"status":"ok","result":{"account_id":"userC_account","admin_user_id":"userC","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ"},"error":null,"telemetry":null,"profile":null},"backends":{"openviking":{"status":"success","result":{"status":"ok","result":{"account_id":"userC_account","admin_user_id":"userC","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ"},"error":null,"telemetry":null,"profile":null},"error":null}}}
|
|
||||||
|
|
||||||
插入信息
|
|
||||||
curl -sS -X POST "http://localhost:1934/memory-system/messages" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "userC",
|
|
||||||
"user_key": "dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ",
|
|
||||||
"session_id": "sessionC1",
|
|
||||||
"user_message": "我喜欢拿铁。",
|
|
||||||
"assistant_message": "好的,我记住了。"
|
|
||||||
}'
|
|
||||||
|
|
||||||
存储记忆
|
|
||||||
curl -sS -X POST "http://localhost:1934/memory-system/sessions/sessionC1/commit" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "userC",
|
|
||||||
"user_key": "dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ"
|
|
||||||
}'
|
|
||||||
|
|
||||||
全局搜索
|
|
||||||
curl -sS -X POST "http://localhost:1934/memory-system/search" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "userC",
|
|
||||||
"user_key": "dXNlckNfYWNjb3VudA.dXNlckM.ZDFlMGI2OWI0NzZkZmZiMGExOWFlNGQ2N2JjYzMxNTg4NzVjZmNhN2Q4MTYwYmU1NGE4YWZjZTdjYzliMDI3NQ",
|
|
||||||
"session_id": "sessionC1",
|
|
||||||
"query": "大语言模型应用是什么",
|
|
||||||
"limit": 10,
|
|
||||||
"level": 2,
|
|
||||||
"score_threshold": 0.8
|
|
||||||
}' | jq .
|
|
||||||
|
|
||||||
curl -s -X POST http://localhost:1933/api/v1/search/search \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer 3f7a4b4faae1e2d49583a0e45d9ba5b51f3d0f545d97c9b6c4f19171f717e8af" \
|
|
||||||
-d '{
|
|
||||||
"query": "大语言模型应用是什么",
|
|
||||||
"level": 2
|
|
||||||
}' | jq .
|
|
||||||
|
|
||||||
会话查询
|
|
||||||
curl -sS -X POST "http://localhost:1934/memory-system/sessions/sessionB1/context" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "userB",
|
|
||||||
"user_key": "1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7",
|
|
||||||
"query": "我喜欢喝什么?",
|
|
||||||
"limit": 10
|
|
||||||
}' | jq .
|
|
||||||
|
|
||||||
用户画像查询
|
|
||||||
curl -sS -X POST "http://localhost:1934/memory-system/users/userB/profile" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_key": "1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7",
|
|
||||||
"query": "我想喝东西",
|
|
||||||
"limit": 10,
|
|
||||||
"level": 2
|
|
||||||
}' | jq .
|
|
||||||
|
|
||||||
上传文件
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,390 +0,0 @@
|
|||||||
# OpenViking Memory API 流程说明
|
|
||||||
|
|
||||||
> 本文档整理 OpenViking 中「创建账号/用户 → 创建 session → 写入消息 → commit 抽取长期 memory → 查询 task 状态 → 检索 user memory/session memory」的完整 API 流程。
|
|
||||||
>
|
|
||||||
> 出于安全原因,示例中不写入真实 `root_api_key` 或 `user_key`,统一使用环境变量占位。
|
|
||||||
|
|
||||||
## 0. 前置变量
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 在config.yaml里设置的 openviking url 和 api_key
|
|
||||||
export OV_HOST="http://localhost:1933"
|
|
||||||
export ROOT_KEY="your-secret-root-key"
|
|
||||||
|
|
||||||
# 创建用户后填入返回的 user_key
|
|
||||||
export USER_A_KEY="<userA-user-key>"
|
|
||||||
export USER_B_KEY="<userB-user-key>"
|
|
||||||
```
|
|
||||||
|
|
||||||
OpenViking 的常见鉴权方式:
|
|
||||||
|
|
||||||
| 场景 | Header |
|
|
||||||
|---|---|
|
|
||||||
| Admin API,例如创建 account | `X-API-Key: $ROOT_KEY` |
|
|
||||||
| 普通用户 API,例如 session/message/search | `X-API-Key: $USER_A_KEY` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 创建用户隔离工作区 / account
|
|
||||||
|
|
||||||
Admin API 用于多租户管理。Memory Gateway 为每个业务用户直接创建一个 admin account,不再调用 `/api/v1/admin/accounts/admin/users`。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/admin/accounts" \
|
|
||||||
-H "X-API-Key: $ROOT_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"account_id": "userA_account",
|
|
||||||
"admin_user_id": "userA"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
典型返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": "userA_account",
|
|
||||||
"admin_user_id": "userA",
|
|
||||||
"user_key": "<userA-user-key>"
|
|
||||||
},
|
|
||||||
"error": null,
|
|
||||||
"telemetry": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 创建更多用户
|
|
||||||
|
|
||||||
### 2.1 创建 userA
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/admin/accounts" \
|
|
||||||
-H "X-API-Key: $ROOT_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"account_id": "userA_account",
|
|
||||||
"admin_user_id": "userA"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
返回后保存:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export USER_A_KEY="<userA-user-key>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 创建 userB
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/admin/accounts" \
|
|
||||||
-H "X-API-Key: $ROOT_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"account_id": "userB_account",
|
|
||||||
"admin_user_id": "userB"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
返回后保存:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export USER_B_KEY="<userB-user-key>"
|
|
||||||
```
|
|
||||||
|
|
||||||
> 注意:不同用户必须使用各自的 `user_key`。用 userA 的 key 只能访问 userA 的用户空间,用 userB 的 key 只能访问 userB 的用户空间。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 创建 session
|
|
||||||
|
|
||||||
Session 是会话容器,用于保存消息、跟踪上下文使用、commit 后抽取长期 memories。
|
|
||||||
|
|
||||||
### 3.1 创建 userA 的 sessionA1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions" \
|
|
||||||
-H "X-API-Key: $USER_A_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"session_id": "sessionA1"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
典型返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"user": {
|
|
||||||
"account_id": "userA_account",
|
|
||||||
"user_id": "userA",
|
|
||||||
"agent_id": "default"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": null,
|
|
||||||
"telemetry": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 创建 userB 的 sessionB1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions" \
|
|
||||||
-H "X-API-Key: $USER_B_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"session_id": "sessionB1"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 写入 session 消息
|
|
||||||
|
|
||||||
HTTP API 支持简单文本模式:`role + content`。`role` 通常为 `user` 或 `assistant`。
|
|
||||||
|
|
||||||
### 4.1 写入 userA / sessionA1 消息
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionA1/messages" \
|
|
||||||
-H "X-API-Key: $USER_A_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "user",
|
|
||||||
"content": "我喜欢用 Python 写数据分析脚本。"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionA1/messages" \
|
|
||||||
-H "X-API-Key: $USER_A_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "好的,我会记住你偏好 Python 数据分析。"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 写入 userB / sessionB1 消息
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionB1/messages" \
|
|
||||||
-H "X-API-Key: $USER_B_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "user",
|
|
||||||
"content": "我喜欢用 vibe coding 写项目。"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionB1/messages" \
|
|
||||||
-H "X-API-Key: $USER_B_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "好的,我会记住你偏好 vibe coding 项目。"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Commit session,触发长期 memory 抽取
|
|
||||||
|
|
||||||
`commit` 是两阶段流程:
|
|
||||||
|
|
||||||
1. **Phase 1,同步完成**:把当前 live messages 快照归档,创建 archive 目录,清空 live session。
|
|
||||||
2. **Phase 2,异步完成**:生成 session 摘要,抽取长期 memories,更新关系和 active count。
|
|
||||||
|
|
||||||
因此 `commit` 请求会很快返回 `task_id`,后续要轮询 task 状态。
|
|
||||||
|
|
||||||
### 5.1 Commit userA / sessionA1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionA1/commit" \
|
|
||||||
-H "X-API-Key: $USER_A_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"keep_recent_count": 0
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
典型返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"status": "accepted",
|
|
||||||
"task_id": "fe6510e1-fdee-4f2d-9f87-5e48b519c2a2",
|
|
||||||
"archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
|
||||||
"archived": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Commit userB / sessionB1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "$OV_HOST/api/v1/sessions/sessionB1/commit" \
|
|
||||||
-H "X-API-Key: $USER_B_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"keep_recent_count": 0
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 查询 commit task 状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s "$OV_HOST/api/v1/tasks/<task_id>" \
|
|
||||||
-H "X-API-Key: $USER_A_KEY" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
成功完成后类似:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"task_id": "fe6510e1-fdee-4f2d-9f87-5e48b519c2a2",
|
|
||||||
"task_type": "session_commit",
|
|
||||||
"status": "completed",
|
|
||||||
"resource_id": "sessionA1",
|
|
||||||
"result": {
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
|
||||||
"memories_extracted": {
|
|
||||||
"preferences": 1
|
|
||||||
},
|
|
||||||
"active_count_updated": 0
|
|
||||||
},
|
|
||||||
"error": null
|
|
||||||
},
|
|
||||||
"error": null,
|
|
||||||
"telemetry": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
关键字段说明:
|
|
||||||
|
|
||||||
| 字段 | 含义 |
|
|
||||||
|---|---|
|
|
||||||
| `status: completed` | Phase 2 已完成,memory 抽取结束 |
|
|
||||||
| `result.archive_uri` | 本次归档目录 URI |
|
|
||||||
| `result.memories_extracted` | 本次 commit 提取到的 memory 分类计数,不是 memory 内容 |
|
|
||||||
| `active_count_updated` | 本次基于 `sessions/{id}/used` 使用记录更新的活跃计数数量 |
|
|
||||||
|
|
||||||
如果返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"status": "running"
|
|
||||||
```
|
|
||||||
|
|
||||||
说明后台任务还没完成。此时可以稍后继续查询同一个 task。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 用 `search/find` 向量搜索 user 长期 memory
|
|
||||||
|
|
||||||
`find` 是纯向量检索,不使用 session 上下文,也不做意图分析。适合直接按语义检索用户长期 memory。
|
|
||||||
|
|
||||||
使用显式用户路径:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST "$OV_HOST/api/v1/search/find" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-API-Key: $USER_A_KEY" \
|
|
||||||
-d '{
|
|
||||||
"query": "我之前说了什么",
|
|
||||||
"target_uri": "viking://user/userA/memories/",
|
|
||||||
"limit": 3
|
|
||||||
}' | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
典型返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"memories": [
|
|
||||||
{
|
|
||||||
"context_type": "memory",
|
|
||||||
"uri": "viking://user/userA/memories/preferences/mem_xxx.md",
|
|
||||||
"level": 2,
|
|
||||||
"score": 0.7411,
|
|
||||||
"abstract": "Python 数据分析:偏好使用 Python 编写数据分析脚本"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resources": [],
|
|
||||||
"skills": [],
|
|
||||||
"total": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 用 `search/search` 做带 session 上下文的 LLM 搜索
|
|
||||||
|
|
||||||
`search` 是智能检索:在 `find` 的基础上增加 session context、意图分析和 query expansion。它适合「用户当前对话里有上下文,查询语义不完整」的场景。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST "$OV_HOST/api/v1/search/search" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-API-Key: $USER_A_KEY" \
|
|
||||||
-d '{
|
|
||||||
"query": "我正在做什么",
|
|
||||||
"target_uri": "viking://user/userA/sessionA1", #
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"limit": 10,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
典型返回:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"memories": [],
|
|
||||||
"resources": [],
|
|
||||||
"skills": [],
|
|
||||||
"total": 0,
|
|
||||||
"query_plan": {
|
|
||||||
"reasoning": "1. Conversational task (user asking '我正在做什么' - 'What am I doing?'); 2. This is a simple conversational query about current state/activity; 3. The session context contains the user's previous interactions about Python preferences and EverOS API questions; 4. No specific context gaps need to be filled for this conversational task, but a memory query about the user's current activity context could help provide a more personalized response",
|
|
||||||
"queries": [
|
|
||||||
{
|
|
||||||
"query": "User's current activity and task context",
|
|
||||||
"context_type": "memory",
|
|
||||||
"intent": "Understand what the user is currently working on to provide relevant context",
|
|
||||||
"priority": 3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. `find` 与 `search` 的选择
|
|
||||||
|
|
||||||
| 接口 | 是否使用 session 上下文 | 是否做意图分析 | 适合场景 |
|
|
||||||
|---|---:|---:|---|
|
|
||||||
| `/api/v1/search/find` | 否 | 否 | 直接按 query 做向量检索,稳定查 user memory |
|
|
||||||
| `/api/v1/search/search` | 是,可传 `session_id` | 是 | 对话式检索,需要结合 session 语境、自动扩展 query |
|
|
||||||
|
|
||||||
推荐实践:
|
|
||||||
|
|
||||||
1. **验证 memory 是否已经写入**:先用 `tasks/{task_id}` 确认 `completed`。
|
|
||||||
2. **确认 user 长期 memory 是否可召回**:用 `/api/v1/search/find`,query 写得贴近 memory 内容。
|
|
||||||
3. **需要结合当前会话上下文**:再用 `/api/v1/search/search` 加 `session_id`。
|
|
||||||
@ -1,215 +0,0 @@
|
|||||||
创建admin工作区
|
|
||||||
curl -X POST http://127.0.0.1:1933/api/v1/admin/accounts -H "X-API-Key: your-secret-root-key" -H "Content-Type: application/json" -d '{"account_id": "userB_account", "admin_user_id": "userB"}'
|
|
||||||
|
|
||||||
{"status":"ok","result":{"account_id":"userA_account","admin_user_id":"userA","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"},"error":null,"telemetry":null,"profile":null}
|
|
||||||
|
|
||||||
{"status":"ok","result":{"account_id":"userB_account","admin_user_id":"userB","isolate_user_scope_by_agent":false,"isolate_agent_scope_by_user":false,"user_key":"dXNlckJfYWNjb3VudA.dXNlckI.YzZiNDZjMjJiZWMwNTM1OTBiOGEwMzAyOTFhZGMxZWQ4MTJhZDNhMmM5ZjJjZGYxMDI1YTkxZDVlMWY2M2M5MA"},"error":null,"telemetry":null,"profile":null}
|
|
||||||
|
|
||||||
创建用户
|
|
||||||
curl -X POST http://127.0.0.1:1933/api/v1/admin/accounts/admin/users -H "X-API-Key: your-secret-root-key" -H "Content-Type: application/json" -d '{"user_id": "userA", "role": "user"}'
|
|
||||||
|
|
||||||
userA
|
|
||||||
{"status":"ok","result":{"account_id":"admin","user_id":"userA","user_key":"3f7a4b4faae1e2d49583a0e45d9ba5b51f3d0f545d97c9b6c4f19171f717e8af"},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
userB
|
|
||||||
{"status":"ok","result":{"account_id":"admin","user_id":"userB","user_key":"3a017f01d1f9cddeec2b4832b4b3cb60b004ff27ec76505d72b24104412015c8"},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
|
|
||||||
创建session
|
|
||||||
userA
|
|
||||||
curl -X POST http://127.0.0.1:1933/api/v1/sessions \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"session_id":"sessionA1"}'
|
|
||||||
|
|
||||||
A1
|
|
||||||
{"status":"ok","result":{"session_id":"238a22c1-a32a-4a3a-a174-2112d476173b","user":{"account_id":"admin","user_id":"userA","agent_id":"default"}},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
A2
|
|
||||||
{"status":"ok","result":{"session_id":"d20cede5-30ba-4d98-81f9-d74ee83bb071","user":{"account_id":"admin","user_id":"userA","agent_id":"default"}},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
B1
|
|
||||||
{"status":"ok","result":{"session_id":"654248bf-c36f-4a61-9fe8-de8f207d5227","user":{"account_id":"admin","user_id":"userB","agent_id":"default"}},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
B2
|
|
||||||
{"status":"ok","result":{"session_id":"c567601c-28e8-4b3d-be10-c95c909b374e","user":{"account_id":"admin","user_id":"userB","agent_id":"default"}},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
对话session插入massage
|
|
||||||
userA A1
|
|
||||||
curl -X POST http://localhost:1933/api/v1/sessions/sessionA1/messages \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "user",
|
|
||||||
"content": "我喜欢用 Python 写数据分析脚本。"
|
|
||||||
}'
|
|
||||||
|
|
||||||
{"status":"ok","result":{"session_id":"sessionA1","message_count":1},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
curl -X POST http://localhost:1933/api/v1/sessions/sessionA1/messages \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "好的,我会记住你偏好 Python 数据分析。"
|
|
||||||
}'
|
|
||||||
{"status":"ok","result":{"session_id":"sessionA1","message_count":2},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
|
|
||||||
userB B1
|
|
||||||
curl -X POST http://localhost:1933/api/v1/sessions/sessionB1/messages \
|
|
||||||
-H "Authorization: Bearer 1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "user",
|
|
||||||
"content": "我还想喝咖啡,咖啡有力气"
|
|
||||||
}'
|
|
||||||
{"status":"ok","result":{"session_id":"sessionB1","message_count":1},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
curl -X POST http://localhost:1933/api/v1/sessions/sessionB1/messages \
|
|
||||||
-H "Authorization: Bearer 1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "黑咖啡品味很浓,很有力气。"
|
|
||||||
}'
|
|
||||||
{"status":"ok","result":{"session_id":"sessionB1","message_count":2},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
对话session插入memory
|
|
||||||
userA A1
|
|
||||||
curl -X POST http://localhost:1933/api/v1/sessions/sessionA1/commit \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"
|
|
||||||
|
|
||||||
{"status":"ok","result":{"session_id":"sessionA1","status":"accepted","task_id":"1b93fcde-0944-42ad-a819-4241faf0048f","archive_uri":"viking://session/sessionA1/history/archive_001","archived":true,"trace_id":""}}
|
|
||||||
|
|
||||||
userB B1
|
|
||||||
curl -X POST http://localhost:1933/api/v1/sessions/sessionB1/commit \
|
|
||||||
-H "Authorization: Bearer 1e5f24acba77017e7506e6df9d668aebc0ddc91c4ed9af77c6d8da5e9d4ed6c7" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"keep_recent_count":0}'
|
|
||||||
{"status":"ok","result":{"session_id":"sessionB1","status":"accepted","task_id":"cd98a696-ac6f-4f0c-8187-6e824f5ebcbc","archive_uri":"viking://session/userB/sessionB1/history/archive_001","archived":true}}
|
|
||||||
|
|
||||||
查询插入结果
|
|
||||||
userA A1
|
|
||||||
curl http://localhost:1933/api/v1/tasks/1b93fcde-0944-42ad-a819-4241faf0048f \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"
|
|
||||||
|
|
||||||
{"status":"ok","result":{"task_id":"1b93fcde-0944-42ad-a819-4241faf0048f","task_type":"session_commit","status":"completed","created_at":1779855851.700087,"updated_at":1779855866.0683796,"resource_id":"sessionA1","result":{"session_id":"sessionA1","archive_uri":"viking://session/sessionA1/history/archive_001","memories_extracted":{"memory_write":1},"session_skills_extracted":0,"session_skill_uris":[],"active_count_updated":0,"token_usage":{"llm":{"prompt_tokens":7883,"completion_tokens":437,"total_tokens":8320},"embedding":{"total_tokens":49},"total":{"total_tokens":8369}}},"error":null,"created_at_iso":"2026-05-27T04:24:11.700087+00:00","updated_at_iso":"2026-05-27T04:24:26.068380+00:00"},"error":null,"telemetry":null,"profile":null}
|
|
||||||
|
|
||||||
|
|
||||||
userB B1
|
|
||||||
curl http://localhost:1933/api/v1/tasks/cd98a696-ac6f-4f0c-8187-6e824f5ebcbc \
|
|
||||||
-H "Authorization: Bearer 3a017f01d1f9cddeec2b4832b4b3cb60b004ff27ec76505d72b24104412015c8"
|
|
||||||
{"status":"ok","result":{"task_id":"cd98a696-ac6f-4f0c-8187-6e824f5ebcbc","task_type":"session_commit","status":"running","created_at":1779418133.2990522,"updated_at":1779418133.2993288,"resource_id":"sessionB1","result":null,"error":null},"error":null,"telemetry":null}
|
|
||||||
|
|
||||||
|
|
||||||
向量搜索用户memory
|
|
||||||
curl -X POST http://localhost:1933/api/v1/search/find \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-d '{
|
|
||||||
"query": "我之前说了什么",
|
|
||||||
"limit": 3
|
|
||||||
}' | jq .
|
|
||||||
|
|
||||||
{"status":"ok","result":{"memories":[{"context_type":"memory","uri":"viking://user/userA/memories/preferences/mem_d49a95a2-8491-40bb-b0d3-4cf4c16d3de8.md","level":2,"score":0.7411142547036441,"category":"","match_reason":"","relations":[],"abstract":"Python 数据分析:偏好使用 Python 编写数据分析脚本","overview":null},{"context_type":"memory","uri":"viking://user/userA/memories/preferences/.abstract.md","level":0,"score":0.6752108627461267,"category":"","match_reason":"","relations":[],"abstract":"This directory contains a single user preference document that captures a specific behavioral attribute regarding tool choice for data analysis. The document is a lightweight, non-technical profile note intended to record that the user prefers writing d...","overview":null},{"context_type":"memory","uri":"viking://user/userA/memories/.overview.md","level":1,"score":0.6262621398392979,"category":"","match_reason":"","relations":[],"abstract":"User's long-term memory storage. Contains memory types like preferences, entities, events, managed hierarchically by type.","overview":null}],"resources":[],"skills":[],"total":3}}
|
|
||||||
|
|
||||||
|
|
||||||
LLM搜索用户session memory
|
|
||||||
|
|
||||||
curl -s -X POST http://localhost:1933/api/v1/search/search \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-d '{
|
|
||||||
"query": "我正在做什么",
|
|
||||||
"limit": 10,
|
|
||||||
"level": 2
|
|
||||||
}' | jq .
|
|
||||||
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"memories": [],
|
|
||||||
"resources": [],
|
|
||||||
"skills": [],
|
|
||||||
"total": 0,
|
|
||||||
"query_plan": {
|
|
||||||
"reasoning": "1. Conversational task (user asking '我正在做什么' - 'What am I doing?'); 2. This is a simple conversational query about current state/activity; 3. The session context contains the user's previous interactions about Python preferences and EverOS API questions; 4. No specific context gaps need to be filled for this conversational task, but a memory query about the user's current activity context could help provide a more personalized response",
|
|
||||||
"queries": [
|
|
||||||
{
|
|
||||||
"query": "User's current activity and task context",
|
|
||||||
"context_type": "memory",
|
|
||||||
"intent": "Understand what the user is currently working on to provide relevant context",
|
|
||||||
"priority": 3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
curl -X GET "http://localhost:1933/api/v1/sessions/sessionA1" \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
组装 session 上下文
|
|
||||||
curl -X GET "http://localhost:1933/api/v1/sessions/{session_id}/context" \
|
|
||||||
-H "X-API-Key: user key"
|
|
||||||
|
|
||||||
curl -X GET "http://localhost:1933/api/v1/sessions/sessionA1/context" \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" | jq .
|
|
||||||
|
|
||||||
获取指定 archive
|
|
||||||
|
|
||||||
curl -X GET "http://localhost:1933/api/v1/sessions/sessionA1/archives/archive_001" \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" | jq .
|
|
||||||
|
|
||||||
curl -sG "http://localhost:1933/api/v1/fs/ls" \
|
|
||||||
--data-urlencode "uri=viking://user/alice1/memories/preferences/" \
|
|
||||||
--data-urlencode "recursive=true" \
|
|
||||||
-H "X-API-Key: 1d1a4d61838f67d808230b19ed1b6b4ce647f073ea33ee005ed3b9b24f35b978" | jq
|
|
||||||
|
|
||||||
|
|
||||||
上传临时文件
|
|
||||||
curl -s -X POST http://localhost:1933/api/v1/resources/temp_upload \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-F "file=@/home/tom/memory-gateway/tests/大语言模型应用.pdf" \
|
|
||||||
| jq .
|
|
||||||
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"temp_file_id": "upload_89b818fa75114b35a5b2c55263dee8ff.pdf"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
上传图片
|
|
||||||
curl -X POST http://localhost:1933/api/v1/resources \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"temp_file_id": "upload_89b818fa75114b35a5b2c55263dee8ff.pdf",
|
|
||||||
"to": "viking://resources/userA/files/大语言模型应用.pdf",
|
|
||||||
"reason": "userA 上传的文件",
|
|
||||||
"wait": true,
|
|
||||||
"directly_upload_media": true
|
|
||||||
}'
|
|
||||||
|
|
||||||
|
|
||||||
curl -X POST http://localhost:1933/api/v1/resources \
|
|
||||||
-H "X-API-Key: dXNlckFfYWNjb3VudA.dXNlckE.OGU4NzczZmQ2ZDExNjNhMWI4OTg2MWZkZTk5OTcyODlhNmM2ZTZjNDNmOGJkMWRiZDk2M2QyNTdhYTZmMTFlYQ" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"temp_file_id": "upload_89b818fa75114b35a5b2c55263dee8ff.pdf",
|
|
||||||
"to": "viking://resources/userA/files/大语言模型应用.pdf",
|
|
||||||
"reason": "userA 上传的文件",
|
|
||||||
"wait": true,
|
|
||||||
"directly_upload_media": true
|
|
||||||
}'
|
|
||||||
|
|
||||||
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Evaluation utilities."""
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
# Hermes Memory Evaluation
|
|
||||||
|
|
||||||
This is a small LoCoMo-style memory evaluation runner for Hermes Agent.
|
|
||||||
It follows the same shape as `openclaw-eval`: ingest historical conversations, ask QA questions with the same user id, then use an LLM judge to score the answers.
|
|
||||||
|
|
||||||
## 1. Configure Hermes Memory
|
|
||||||
|
|
||||||
Install or copy the `memory_system` Hermes plugin, then put Memory System settings in `/home/tom/.hermes/memory_system.env`:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934
|
|
||||||
MEMORY_SYSTEM_USER_ID=default
|
|
||||||
MEMORY_SYSTEM_SEARCH_USE_LLM=false
|
|
||||||
MEMORY_SYSTEM_COMMIT_EVERY_TURNS=1
|
|
||||||
MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=0
|
|
||||||
```
|
|
||||||
|
|
||||||
The eval runner overrides `MEMORY_SYSTEM_USER_ID` per LoCoMo sample, so one sample maps to one memory user.
|
|
||||||
|
|
||||||
## 2. Prepare Config
|
|
||||||
|
|
||||||
Copy and edit:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp eval/hermes_memory_eval/config.example.yaml eval/hermes_memory_eval/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
For a stable eval, keep:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
memory:
|
|
||||||
commit_every_turns: 1
|
|
||||||
commit_interval_seconds: 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Ingest Conversations
|
|
||||||
|
|
||||||
Before ingest, verify the eval Hermes home can see the plugin:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
HERMES_HOME=/home/tom/memory-gateway/eval/hermes_memory_eval/hermes_home hermes memory status
|
|
||||||
```
|
|
||||||
|
|
||||||
The status must show `memory_system` as installed and active.
|
|
||||||
|
|
||||||
Run a small smoke test first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python eval/hermes_memory_eval/run_eval.py ingest /path/to/locomo10_small.json \
|
|
||||||
--config eval/hermes_memory_eval/config.yaml \
|
|
||||||
--sample 0 \
|
|
||||||
--sessions 1-2 \
|
|
||||||
--output output/hermes_ingest.jsonl
|
|
||||||
```
|
|
||||||
|
|
||||||
This sends each selected session to:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
hermes chat -Q --source memory-eval -q "<formatted session>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Ask QA Questions
|
|
||||||
|
|
||||||
Use the same sample and user mapping:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python eval/hermes_memory_eval/run_eval.py qa /path/to/locomo10_small.json \
|
|
||||||
--config eval/hermes_memory_eval/config.yaml \
|
|
||||||
--sample 0 \
|
|
||||||
--count 10 \
|
|
||||||
--output output/hermes_qa.jsonl
|
|
||||||
```
|
|
||||||
|
|
||||||
Each QA runs in a fresh Hermes CLI call, so the answer should come from persistent memory rather than the prior short-term chat context.
|
|
||||||
The default QA prompt explicitly asks Hermes to call `memory_system_search` before answering.
|
|
||||||
If Memory System API does not log `POST /memory-system/search`, inspect the session JSON to confirm whether the model made a tool call.
|
|
||||||
|
|
||||||
## 5. Judge Answers
|
|
||||||
|
|
||||||
Use the `judge` section in `config.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
judge:
|
|
||||||
base_url: "https://api.openai.com/v1"
|
|
||||||
api_key_env: "OPENAI_API_KEY"
|
|
||||||
model: "gpt-4o-mini"
|
|
||||||
parallel: 4
|
|
||||||
timeout_seconds: 120
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY=sk-... python eval/hermes_memory_eval/judge.py output/hermes_qa.jsonl \
|
|
||||||
--config eval/hermes_memory_eval/config.yaml \
|
|
||||||
--output output/hermes_grades.json
|
|
||||||
```
|
|
||||||
|
|
||||||
For Ark/Doubao-style endpoints:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
judge:
|
|
||||||
base_url: "https://ark.cn-beijing.volces.com/api/v3"
|
|
||||||
api_key_env: "ARK_API_KEY"
|
|
||||||
model: "doubao-seed-2-0-pro-260215"
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ARK_API_KEY=... python eval/hermes_memory_eval/judge.py output/hermes_qa.jsonl \
|
|
||||||
--config eval/hermes_memory_eval/config.yaml \
|
|
||||||
--output output/hermes_grades.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recommended Comparisons
|
|
||||||
|
|
||||||
Run the same dataset in these modes:
|
|
||||||
|
|
||||||
- no external memory
|
|
||||||
- `MEMORY_SYSTEM_SEARCH_USE_LLM=false`
|
|
||||||
- `MEMORY_SYSTEM_SEARCH_USE_LLM=true`
|
|
||||||
|
|
||||||
Compare final QA score and inspect failed examples. If search recall is high but QA accuracy is low, Hermes is not using retrieved memory well. If search recall is low, the issue is likely write/extract/search quality.
|
|
||||||
|
|
||||||
## Current Small Dataset Result
|
|
||||||
|
|
||||||
On `locomo10_small.json` sample `conv-26`, the current smoke test results are:
|
|
||||||
|
|
||||||
| Mode | Score | Category 1 | Category 2 | Category 3 | Category 4 |
|
|
||||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
|
||||||
| Memory System enabled | 5/35 (14.29%) | 0/5 (0.00%) | 1/9 (11.11%) | 1/2 (50.00%) | 3/19 (15.79%) |
|
|
||||||
| No external memory | 0/35 (0.00%) | 0/5 (0.00%) | 0/9 (0.00%) | 0/2 (0.00%) | 0/19 (0.00%) |
|
|
||||||
|
|
||||||
This means the Memory System path is contributing signal over the no-memory baseline, but the absolute score is still low. The main follow-up is to inspect failed QA examples and separate retrieval failure from answer-use failure:
|
|
||||||
|
|
||||||
- If `POST /memory-system/search` does not appear during QA, Hermes did not call the memory tool.
|
|
||||||
- If search results do not contain the evidence/gold answer, the write/extract/search path needs improvement.
|
|
||||||
- If search results contain the evidence but the answer is wrong, Hermes is not using retrieved memory effectively.
|
|
||||||
|
|
||||||
For future runs, keep a fresh `user_prefix` per mode so OpenViking/EverOS memory from prior runs does not contaminate results.
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
"""Hermes memory evaluation helpers."""
|
|
||||||
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
hermes:
|
|
||||||
command: "hermes"
|
|
||||||
timeout_seconds: 600
|
|
||||||
quiet: true
|
|
||||||
source: "memory-eval"
|
|
||||||
extra_args: []
|
|
||||||
|
|
||||||
memory:
|
|
||||||
env_file: "/home/tom/.hermes/memory_system.env"
|
|
||||||
endpoint: "http://127.0.0.1:1934"
|
|
||||||
api_key: ""
|
|
||||||
user_prefix: "locomo-"
|
|
||||||
search_use_llm: false
|
|
||||||
commit_every_turns: 1
|
|
||||||
commit_interval_seconds: 0
|
|
||||||
|
|
||||||
qa:
|
|
||||||
prompt_template: "请先使用 memory_system_search 查询长期记忆,再根据检索到的记忆回答问题。如果记忆中没有答案,请直接说不知道,不要编造。\n\n问题:{question}"
|
|
||||||
|
|
||||||
judge:
|
|
||||||
base_url: "https://api.openai.com/v1"
|
|
||||||
api_key_env: "OPENAI_API_KEY"
|
|
||||||
model: "gpt-4o-mini"
|
|
||||||
parallel: 4
|
|
||||||
timeout_seconds: 120
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
hermes:
|
|
||||||
command: "hermes"
|
|
||||||
timeout_seconds: 600
|
|
||||||
quiet: true
|
|
||||||
source: "memory-eval"
|
|
||||||
extra_args: []
|
|
||||||
|
|
||||||
memory:
|
|
||||||
env_file: "/home/tom/memory-gateway/eval/hermes_memory_eval/hermes_home/memory_system.env"
|
|
||||||
endpoint: "http://127.0.0.1:1934"
|
|
||||||
api_key: ""
|
|
||||||
user_prefix: "locomo-full-nomemory-20260520-"
|
|
||||||
search_use_llm: false
|
|
||||||
commit_every_turns: 1
|
|
||||||
commit_interval_seconds: 0
|
|
||||||
|
|
||||||
qa:
|
|
||||||
prompt_template: "{question}"
|
|
||||||
|
|
||||||
judge:
|
|
||||||
base_url: "https://oai.bwgdi.com/v1"
|
|
||||||
model: "Qwen3.6-35B"
|
|
||||||
api_key: "sk-4BxeAtnQCRv3x1xwRcmTJg"
|
|
||||||
parallel: 4
|
|
||||||
timeout_seconds: 120
|
|
||||||
File diff suppressed because one or more lines are too long
@ -1,852 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"sample_id": "conv-26",
|
|
||||||
"conversation": {
|
|
||||||
"speaker_a": "Caroline",
|
|
||||||
"speaker_b": "Melanie",
|
|
||||||
"session_1_date_time": "1:56 pm on 8 May, 2023",
|
|
||||||
"session_1": [
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:1",
|
|
||||||
"text": "Hey Mel! Good to see you! How have you been?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:2",
|
|
||||||
"text": "Hey Caroline! Good to see you! I'm swamped with the kids & work. What's up with you? Anything new?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:3",
|
|
||||||
"text": "I went to a LGBTQ support group yesterday and it was so powerful."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:4",
|
|
||||||
"text": "Wow, that's cool, Caroline! What happened that was so awesome? Did you hear any inspiring stories?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"img_url": [
|
|
||||||
"https://i.redd.it/l7hozpetnhlb1.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photo of a dog walking past a wall with a painting of a woman",
|
|
||||||
"query": "transgender pride flag mural",
|
|
||||||
"dia_id": "D1:5",
|
|
||||||
"text": "The transgender stories were so inspiring! I was so happy and thankful for all the support."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:6",
|
|
||||||
"text": "Wow, love that painting! So cool you found such a helpful group. What's it done for you?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:7",
|
|
||||||
"text": "The support group has made me feel accepted and given me courage to embrace myself."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:8",
|
|
||||||
"text": "That's really cool. You've got guts. What now?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:9",
|
|
||||||
"text": "Gonna continue my edu and check out career options, which is pretty exciting!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:10",
|
|
||||||
"text": "Wow, Caroline! What kinda jobs are you thinkin' of? Anything that stands out?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:11",
|
|
||||||
"text": "I'm keen on counseling or working in mental health - I'd love to support those with similar issues."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"img_url": [
|
|
||||||
"http://candicealexander.com/cdn/shop/products/IMG_7269_a49d5af8-c76c-4ecd-ae20-48c08cb11dec.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photo of a painting of a sunset over a lake",
|
|
||||||
"query": "painting sunrise",
|
|
||||||
"dia_id": "D1:12",
|
|
||||||
"text": "You'd be a great counselor! Your empathy and understanding will really help the people you work with. By the way, take a look at this."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:13",
|
|
||||||
"text": "Thanks, Melanie! That's really sweet. Is this your own painting?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:14",
|
|
||||||
"text": "Yeah, I painted that lake sunrise last year! It's special to me."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:15",
|
|
||||||
"text": "Wow, Melanie! The colors really blend nicely. Painting looks like a great outlet for expressing yourself."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:16",
|
|
||||||
"text": "Thanks, Caroline! Painting's a fun way to express my feelings and get creative. It's a great way to relax after a long day."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D1:17",
|
|
||||||
"text": "Totally agree, Mel. Relaxing and expressing ourselves is key. Well, I'm off to go do some research."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D1:18",
|
|
||||||
"text": "Yep, Caroline. Taking care of ourselves is vital. I'm off to go swimming with the kids. Talk to you soon!"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"session_2_date_time": "1:14 pm on 25 May, 2023",
|
|
||||||
"session_2": [
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:1",
|
|
||||||
"text": "Hey Caroline, since we last chatted, I've had a lot of things happening to me. I ran a charity race for mental health last Saturday \u2013 it was really rewarding. Really made me think about taking care of our minds."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D2:2",
|
|
||||||
"text": "That charity race sounds great, Mel! Making a difference & raising awareness for mental health is super rewarding - I'm really proud of you for taking part!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:3",
|
|
||||||
"text": "Thanks, Caroline! The event was really thought-provoking. I'm starting to realize that self-care is really important. It's a journey for me, but when I look after myself, I'm able to better look after my family."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D2:4",
|
|
||||||
"text": "I totally agree, Melanie. Taking care of ourselves is so important - even if it's not always easy. Great that you're prioritizing self-care."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:5",
|
|
||||||
"text": "Yeah, it's tough. So I'm carving out some me-time each day - running, reading, or playing my violin - which refreshes me and helps me stay present for my fam!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D2:6",
|
|
||||||
"text": "That's great, Mel! Taking time for yourself is so important. You're doing an awesome job looking after yourself and your family!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:7",
|
|
||||||
"text": "Thanks, Caroline. It's still a work in progress, but I'm doing my best. My kids are so excited about summer break! We're thinking about going camping next month. Any fun plans for the summer?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D2:8",
|
|
||||||
"text": "Researching adoption agencies \u2014 it's been a dream to have a family and give a loving home to kids who need it."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:9",
|
|
||||||
"text": "Wow, Caroline! That's awesome! Taking in kids in need - you're so kind. Your future family is gonna be so lucky to have you!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"img_url": [
|
|
||||||
"https://live.staticflickr.com/3437/3935231341_b2955b00dd_b.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photography of a sign for a new arrival and an information and domestic building",
|
|
||||||
"query": "adoption agency brochure",
|
|
||||||
"dia_id": "D2:10",
|
|
||||||
"re-download": true,
|
|
||||||
"text": "Thanks, Mel! My goal is to give kids a loving home. I'm truly grateful for all the support I've got from friends and mentors. Now the hard work starts to turn my dream into a reality. And here's one of the adoption agencies I'm looking into. It's a lot to take in, but I'm feeling hopeful and optimistic."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:11",
|
|
||||||
"text": "Wow, that agency looks great! What made you pick it?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D2:12",
|
|
||||||
"text": "I chose them 'cause they help LGBTQ+ folks with adoption. Their inclusivity and support really spoke to me."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:13",
|
|
||||||
"text": "That's great, Caroline! Loving the inclusivity and support. Anything you're excited for in the adoption process?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D2:14",
|
|
||||||
"text": "I'm thrilled to make a family for kids who need one. It'll be tough as a single parent, but I'm up for the challenge!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:15",
|
|
||||||
"text": "You're doing something amazing! Creating a family for those kids is so lovely. You'll be an awesome mom! Good luck!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D2:16",
|
|
||||||
"text": "Thanks, Melanie! Your kind words really mean a lot. I'll do my best to make sure these kids have a safe and loving home."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D2:17",
|
|
||||||
"text": "No doubts, Caroline. You have such a caring heart - they'll get all the love and stability they need! Excited for this new chapter!"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"session_3_date_time": "7:55 pm on 9 June, 2023",
|
|
||||||
"session_3": [
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:1",
|
|
||||||
"text": "Hey Melanie! How's it going? I wanted to tell you about my school event last week. It was awesome! I talked about my transgender journey and encouraged students to get involved in the LGBTQ community. It was great to see their reactions. It made me reflect on how far I've come since I started transitioning three years ago."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:2",
|
|
||||||
"text": "Hey Caroline! Great to hear from you. Sounds like your event was amazing! I'm so proud of you for spreading awareness and getting others involved in the LGBTQ community. You've come a long way since your transition - keep on inspiring people with your strength and courage!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:3",
|
|
||||||
"text": "Thanks, Mel! Your backing really means a lot. I felt super powerful giving my talk. I shared my own journey, the struggles I had and how much I've developed since coming out. It was wonderful to see how the audience related to what I said and how it inspired them to be better allies. Conversations about gender identity and inclusion are so necessary and I'm thankful for being able to give a voice to the trans community."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:4",
|
|
||||||
"text": "Wow, Caroline, you're doing an awesome job of inspiring others with your journey. It's great to be part of it and see how you're positively affecting so many. Talking about inclusivity and acceptance is crucial, and you're so brave to speak up for the trans community. Keep up the great work!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:5",
|
|
||||||
"text": "Thanks Mel! Your kind words mean a lot. Sharing our experiences isn't always easy, but I feel it's important to help promote understanding and acceptance. I've been blessed with loads of love and support throughout this journey, and I want to pass it on to others. By sharing our stories, we can build a strong, supportive community of hope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:6",
|
|
||||||
"text": "Yeah, Caroline! It takes courage to talk about our own stories. But it's in these vulnerable moments that we bond and understand each other. We all have our different paths, but if we share them, we show people that they're not alone. Our stories can be so inspiring and encouraging to others who are facing the same challenges. Thank you for using your voice to create love, acceptance, and hope. You're doing amazing!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:7",
|
|
||||||
"text": "Your words mean a lot to me. I'm grateful for the chance to share my story and give others hope. We all have unique paths, and by working together we can build a more inclusive and understanding world. I'm going to keep using my voice to make a change and lift others up. And you're part of that!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:8",
|
|
||||||
"text": "Thanks, Caroline, for letting me join your journey. I'm so proud to be part of the difference you're making. Let's keep motivating and helping each other out as we journey through life. We can make a real impact together!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:9",
|
|
||||||
"text": "Yeah Mel, let's spread love and understanding! Thanks for the support and encouragement. We can tackle life's challenges together! We got this!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:10",
|
|
||||||
"text": "Yes, Caroline! We can do it. Your courage is inspiring. I want to be couragous for my family- they motivate me and give me love. What motivates you?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"img_url": [
|
|
||||||
"https://fox2now.com/wp-content/uploads/sites/14/2023/08/that-tall-family.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photo of a family posing for a picture in a yard",
|
|
||||||
"query": "group of friends and family",
|
|
||||||
"dia_id": "D3:11",
|
|
||||||
"text": "Thanks, Mel! My friends, family and mentors are my rocks \u2013 they motivate me and give me the strength to push on. Here's a pic from when we met up last week!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:12",
|
|
||||||
"text": "Wow, that photo is great! How long have you had such a great support system?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:13",
|
|
||||||
"text": "Yeah, I'm really lucky to have them. They've been there through everything, I've known these friends for 4 years, since I moved from my home country. Their love and help have been so important especially after that tough breakup. I'm super thankful. Who supports you, Mel?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"img_url": [
|
|
||||||
"https://mrswebersneighborhood.com/wp-content/uploads/2022/07/Cedar-Falls-Hocking-Hills.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photo of a man and a little girl standing in front of a waterfall",
|
|
||||||
"query": "husband kids hiking nature",
|
|
||||||
"dia_id": "D3:14",
|
|
||||||
"text": "I'm lucky to have my husband and kids; they keep me motivated."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:15",
|
|
||||||
"text": "Wow, what an amazing family pic! How long have you been married?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"img_url": [
|
|
||||||
"https://i.redd.it/8o28nfllf3eb1.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photo of a bride in a wedding dress holding a bouquet",
|
|
||||||
"query": "wedding day",
|
|
||||||
"dia_id": "D3:16",
|
|
||||||
"text": "5 years already! Time flies- feels like just yesterday I put this dress on! Thanks, Caroline!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:17",
|
|
||||||
"text": "Congrats, Melanie! You both looked so great on your wedding day! Wishing you many happy years together!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"img_url": [
|
|
||||||
"http://shirleyswardrobe.com/wp-content/uploads/2017/07/LF-Picnic-6.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photo of a man and woman sitting on a blanket eating food",
|
|
||||||
"query": "family picnic park laughing",
|
|
||||||
"dia_id": "D3:18",
|
|
||||||
"text": "Thanks, Caroline! Appreciate your kind words. Looking forward to more happy years. Our family and moments make it all worth it."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:19",
|
|
||||||
"text": "Looks like you had a great day! How was it? You all look so happy!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:20",
|
|
||||||
"text": "It so fun! We played games, ate good food, and just hung out together. Family moments make life awesome."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:21",
|
|
||||||
"text": "Sounds great, Mel! Glad you had a great time. Cherish the moments - they're the best!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D3:22",
|
|
||||||
"text": "Absolutely, Caroline! I cherish time with family. It's when I really feel alive and happy."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D3:23",
|
|
||||||
"text": "I 100% agree, Mel. Hanging with loved ones is amazing and brings so much happiness. Those moments really make me thankful. Family is everything."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"session_4_date_time": "10:37 am on 27 June, 2023",
|
|
||||||
"session_4": [
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"img_url": [
|
|
||||||
"https://i.redd.it/67uas3gnmz7b1.jpg"
|
|
||||||
],
|
|
||||||
"blip_caption": "a photo of a person holding a necklace with a cross and a heart",
|
|
||||||
"query": "pendant transgender symbol",
|
|
||||||
"dia_id": "D4:1",
|
|
||||||
"text": "Hey Melanie! Long time no talk! A lot's been going on in my life! Take a look at this."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D4:2",
|
|
||||||
"text": "Hey, Caroline! Nice to hear from you! Love the necklace, any special meaning to it?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D4:3",
|
|
||||||
"text": "Thanks, Melanie! This necklace is super special to me - a gift from my grandma in my home country, Sweden. She gave it to me when I was young, and it stands for love, faith and strength. It's like a reminder of my roots and all the love and support I get from my family."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"blip_caption": "a photo of a stack of bowls with different designs on them",
|
|
||||||
"dia_id": "D4:4",
|
|
||||||
"text": "That's gorgeous, Caroline! It's awesome what items can mean so much to us, right? Got any other objects that you treasure, like that necklace?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D4:5",
|
|
||||||
"text": "Yep, Melanie! I've got some other stuff with sentimental value, like my hand-painted bowl. A friend made it for my 18th birthday ten years ago. The pattern and colors are awesome-- it reminds me of art and self-expression."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D4:6",
|
|
||||||
"text": "That sounds great, Caroline! It's awesome having stuff around that make us think of good connections and times. Actually, I just took my fam camping in the mountains last week - it was a really nice time together!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D4:7",
|
|
||||||
"text": "Sounds great, Mel. Glad you made some new family mems. How was it? Anything fun?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D4:8",
|
|
||||||
"text": "It was an awesome time, Caroline! We explored nature, roasted marshmallows around the campfire and even went on a hike. The view from the top was amazing! The 2 younger kids love nature. It was so special having these moments together as a family - I'll never forget it!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D4:9",
|
|
||||||
"text": "That's awesome, Melanie! Family moments like that are so special. Glad y'all had such a great time."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D4:10",
|
|
||||||
"text": "Thanks, Caroline! Family time matters to me. What's up with you lately?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"blip_caption": "a photo of a book shelf with many books on it",
|
|
||||||
"dia_id": "D4:11",
|
|
||||||
"text": "Lately, I've been looking into counseling and mental health as a career. I want to help people who have gone through the same things as me."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D4:12",
|
|
||||||
"text": "Sounds great! What kind of counseling and mental health services do you want to persue?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D4:13",
|
|
||||||
"text": "I'm still figuring out the details, but I'm thinking of working with trans people, helping them accept themselves and supporting their mental health. Last Friday, I went to an LGBTQ+ counseling workshop and it was really enlightening. They talked about different therapeutic methods and how to best work with trans people. Seeing how passionate these pros were about making a safe space for people like me was amazing."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D4:14",
|
|
||||||
"text": "Woah, Caroline, it sounds like you're doing some impressive work. It's inspiring to see your dedication to helping others. What motivated you to pursue counseling?"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D4:15",
|
|
||||||
"text": "Thanks, Melanie. It really mattered. My own journey and the support I got made a huge difference. Now I want to help people go through it too. I saw how counseling and support groups improved my life, so I started caring more about mental health and understanding myself. Now I'm passionate about creating a safe, inviting place for people to grow."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"dia_id": "D4:16",
|
|
||||||
"text": "Wow, Caroline! You've gained so much from your own experience. Your passion and hard work to help others is awesome. Keep it up, you're making a big impact!"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Caroline",
|
|
||||||
"dia_id": "D4:17",
|
|
||||||
"text": "Thanks, Melanie! Your kind words mean a lot."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"speaker": "Melanie",
|
|
||||||
"blip_caption": "a photo of a book shelf filled with books in a room",
|
|
||||||
"dia_id": "D4:18",
|
|
||||||
"text": "Congrats Caroline! Good on you for going after what you really care about."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"qa": [
|
|
||||||
{
|
|
||||||
"question": "What did Caroline realize after her charity race?",
|
|
||||||
"evidence": [
|
|
||||||
"D2:3"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "self-care is important"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "When did Caroline go to the LGBTQ support group?",
|
|
||||||
"answer": "7 May 2023",
|
|
||||||
"evidence": [
|
|
||||||
"D1:3"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "When did Melanie paint a sunrise?",
|
|
||||||
"answer": 2022,
|
|
||||||
"evidence": [
|
|
||||||
"D1:12"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What fields would Caroline be likely to pursue in her educaton?",
|
|
||||||
"answer": "Psychology, counseling certification",
|
|
||||||
"evidence": [
|
|
||||||
"D1:9",
|
|
||||||
"D1:11"
|
|
||||||
],
|
|
||||||
"category": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What did Caroline research?",
|
|
||||||
"answer": "Adoption agencies",
|
|
||||||
"evidence": [
|
|
||||||
"D2:8"
|
|
||||||
],
|
|
||||||
"category": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What is Caroline's identity?",
|
|
||||||
"answer": "Transgender woman",
|
|
||||||
"evidence": [
|
|
||||||
"D1:5"
|
|
||||||
],
|
|
||||||
"category": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "When did Melanie run a charity race?",
|
|
||||||
"answer": "The sunday before 25 May 2023",
|
|
||||||
"evidence": [
|
|
||||||
"D2:1"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "When is Melanie planning on going camping?",
|
|
||||||
"answer": "June 2023",
|
|
||||||
"evidence": [
|
|
||||||
"D2:7"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What is Caroline's relationship status?",
|
|
||||||
"answer": "Single",
|
|
||||||
"evidence": [
|
|
||||||
"D3:13",
|
|
||||||
"D2:14"
|
|
||||||
],
|
|
||||||
"category": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "When did Caroline give a speech at a school?",
|
|
||||||
"answer": "The week before 9 June 2023",
|
|
||||||
"evidence": [
|
|
||||||
"D3:1"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "When did Caroline meet up with her friends, family, and mentors?",
|
|
||||||
"answer": "The week before 9 June 2023",
|
|
||||||
"evidence": [
|
|
||||||
"D3:11"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "How long has Caroline had her current group of friends for?",
|
|
||||||
"answer": "4 years",
|
|
||||||
"evidence": [
|
|
||||||
"D3:13"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "Where did Caroline move from 4 years ago?",
|
|
||||||
"answer": "Sweden",
|
|
||||||
"evidence": [
|
|
||||||
"D3:13",
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "How long ago was Caroline's 18th birthday?",
|
|
||||||
"answer": "10 years ago",
|
|
||||||
"evidence": [
|
|
||||||
"D4:5"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What career path has Caroline decided to persue?",
|
|
||||||
"answer": "counseling or mental health for Transgender people",
|
|
||||||
"evidence": [
|
|
||||||
"D4:13",
|
|
||||||
"D1:11"
|
|
||||||
],
|
|
||||||
"category": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "Would Caroline still want to pursue counseling as a career if she hadn't received support growing up?",
|
|
||||||
"answer": "Likely no",
|
|
||||||
"evidence": [
|
|
||||||
"D4:15",
|
|
||||||
"D3:5"
|
|
||||||
],
|
|
||||||
"category": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "When did Melanie go camping in June?",
|
|
||||||
"answer": "The week before 27 June 2023",
|
|
||||||
"evidence": [
|
|
||||||
"D4:8"
|
|
||||||
],
|
|
||||||
"category": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What did the charity race raise awareness for?",
|
|
||||||
"answer": "mental health",
|
|
||||||
"evidence": [
|
|
||||||
"D2:2"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What did Melanie realize after the charity race?",
|
|
||||||
"answer": "self-care is important",
|
|
||||||
"evidence": [
|
|
||||||
"D2:3"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "How does Melanie prioritize self-care?",
|
|
||||||
"answer": "by carving out some me-time each day for activities like running, reading, or playing the violin",
|
|
||||||
"evidence": [
|
|
||||||
"D2:5"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What are Caroline's plans for the summer?",
|
|
||||||
"answer": "researching adoption agencies",
|
|
||||||
"evidence": [
|
|
||||||
"D2:8"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What type of individuals does the adoption agency Caroline is considering support?",
|
|
||||||
"answer": "LGBTQ+ individuals",
|
|
||||||
"evidence": [
|
|
||||||
"D2:12"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "Why did Caroline choose the adoption agency?",
|
|
||||||
"answer": "because of their inclusivity and support for LGBTQ+ individuals",
|
|
||||||
"evidence": [
|
|
||||||
"D2:12"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What is Caroline excited about in the adoption process?",
|
|
||||||
"answer": "creating a family for kids who need one",
|
|
||||||
"evidence": [
|
|
||||||
"D2:14"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What does Melanie think about Caroline's decision to adopt?",
|
|
||||||
"answer": "she thinks Caroline is doing something amazing and will be an awesome mom",
|
|
||||||
"evidence": [
|
|
||||||
"D2:15"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "How long have Mel and her husband been married?",
|
|
||||||
"answer": "Mel and her husband have been married for 5 years.",
|
|
||||||
"evidence": [
|
|
||||||
"D3:16"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What does Caroline's necklace symbolize?",
|
|
||||||
"answer": "love, faith, and strength",
|
|
||||||
"evidence": [
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What country is Caroline's grandma from?",
|
|
||||||
"answer": "Sweden",
|
|
||||||
"evidence": [
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What was grandma's gift to Caroline?",
|
|
||||||
"answer": "necklace",
|
|
||||||
"evidence": [
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What is Melanie's hand-painted bowl a reminder of?",
|
|
||||||
"answer": "art and self-expression",
|
|
||||||
"evidence": [
|
|
||||||
"D4:5"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What did Melanie and her family do while camping?",
|
|
||||||
"answer": "explored nature, roasted marshmallows, and went on a hike",
|
|
||||||
"evidence": [
|
|
||||||
"D4:8"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What kind of counseling and mental health services is Caroline interested in pursuing?",
|
|
||||||
"answer": "working with trans people, helping them accept themselves and supporting their mental health",
|
|
||||||
"evidence": [
|
|
||||||
"D4:13"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What workshop did Caroline attend recently?",
|
|
||||||
"answer": "LGBTQ+ counseling workshop",
|
|
||||||
"evidence": [
|
|
||||||
"D4:13"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What was discussed in the LGBTQ+ counseling workshop?",
|
|
||||||
"answer": "therapeutic methods and how to best work with trans people",
|
|
||||||
"evidence": [
|
|
||||||
"D4:13"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What motivated Caroline to pursue counseling?",
|
|
||||||
"answer": "her own journey and the support she received, and how counseling improved her life",
|
|
||||||
"evidence": [
|
|
||||||
"D4:15"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What kind of place does Caroline want to create for people?",
|
|
||||||
"answer": "a safe and inviting place for people to grow",
|
|
||||||
"evidence": [
|
|
||||||
"D4:15"
|
|
||||||
],
|
|
||||||
"category": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What are Melanie's plans for the summer with respect to adoption?",
|
|
||||||
"evidence": [
|
|
||||||
"D2:8"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "researching adoption agencies"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What type of individuals does the adoption agency Melanie is considering support?",
|
|
||||||
"evidence": [
|
|
||||||
"D2:12"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "LGBTQ+ individuals"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "Why did Melanie choose the adoption agency?",
|
|
||||||
"evidence": [
|
|
||||||
"D2:12"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "because of their inclusivity and support for LGBTQ+ individuals"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What is Melanie excited about in her adoption process?",
|
|
||||||
"evidence": [
|
|
||||||
"D2:14"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "creating a family for kids who need one"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What does Melanie's necklace symbolize?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "love, faith, and strength"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What country is Melanie's grandma from?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "Sweden"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What was grandma's gift to Melanie?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "necklace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What was grandpa's gift to Caroline?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:3"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "necklace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What is Caroline's hand-painted bowl a reminder of?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:5"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "art and self-expression"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What did Caroline and her family do while camping?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:8"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "explored nature, roasted marshmallows, and went on a hike"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What kind of counseling and mental health services is Melanie interested in pursuing?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:13"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "working with trans people, helping them accept themselves and supporting their mental health"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What kind of counseling workshop did Melanie attend recently?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:13"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "LGBTQ+ counseling workshop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What motivated Melanie to pursue counseling?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:15"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "her own journey and the support she received, and how counseling improved her life"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What kind of place does Melanie want to create for people?",
|
|
||||||
"evidence": [
|
|
||||||
"D4:15"
|
|
||||||
],
|
|
||||||
"category": 5,
|
|
||||||
"answer": "a safe and inviting place for people to grow"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
"""Hermes CLI client used by the memory evaluation runner."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Mapping
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HermesClientConfig:
|
|
||||||
command: str = "hermes"
|
|
||||||
timeout_seconds: int = 600
|
|
||||||
quiet: bool = True
|
|
||||||
source: str = "memory-eval"
|
|
||||||
extra_args: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class HermesClient:
|
|
||||||
def __init__(self, config: HermesClientConfig):
|
|
||||||
self._config = config
|
|
||||||
|
|
||||||
def chat(self, message: str, *, user_id: str, env: Mapping[str, str] | None = None) -> str:
|
|
||||||
command = [self._config.command, "chat"]
|
|
||||||
if self._config.quiet:
|
|
||||||
command.append("-Q")
|
|
||||||
if self._config.source:
|
|
||||||
command.extend(["--source", self._config.source])
|
|
||||||
command.extend(self._config.extra_args)
|
|
||||||
command.extend(["-q", message])
|
|
||||||
|
|
||||||
process_env = os.environ.copy()
|
|
||||||
process_env["MEMORY_SYSTEM_USER_ID"] = user_id
|
|
||||||
if env:
|
|
||||||
process_env.update({key: str(value) for key, value in env.items() if value is not None})
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
env=process_env,
|
|
||||||
text=True,
|
|
||||||
timeout=self._config.timeout_seconds,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
stderr = result.stderr.strip()
|
|
||||||
stdout = result.stdout.strip()
|
|
||||||
detail = stderr or stdout or f"exit code {result.returncode}"
|
|
||||||
raise RuntimeError(f"Hermes command failed: {detail}")
|
|
||||||
return result.stdout.strip()
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
"""LLM judge for Hermes memory QA outputs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
def load_answers(path: str | Path) -> list[dict[str, Any]]:
|
|
||||||
input_path = Path(path)
|
|
||||||
if input_path.suffix == ".jsonl":
|
|
||||||
with input_path.open("r", encoding="utf-8") as file:
|
|
||||||
return [json.loads(line) for line in file if line.strip()]
|
|
||||||
with input_path.open("r", encoding="utf-8") as file:
|
|
||||||
data = json.load(file)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data.get("results", data.get("grades", []))
|
|
||||||
if isinstance(data, list):
|
|
||||||
return data
|
|
||||||
raise ValueError("answers file must be JSON list, JSONL, or object with results")
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str | Path | None) -> dict[str, Any]:
|
|
||||||
if not path:
|
|
||||||
return {}
|
|
||||||
config_path = Path(path)
|
|
||||||
if not config_path.exists():
|
|
||||||
return {}
|
|
||||||
with config_path.open("r", encoding="utf-8") as file:
|
|
||||||
return yaml.safe_load(file) or {}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_judge_config(args: argparse.Namespace) -> dict[str, Any]:
|
|
||||||
config = load_config(args.config)
|
|
||||||
judge = config.get("judge", {})
|
|
||||||
base_url = args.base_url or judge.get("base_url") or os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1"
|
|
||||||
model = args.model or judge.get("model") or "gpt-4o-mini"
|
|
||||||
api_key_env = args.api_key_env or judge.get("api_key_env") or "OPENAI_API_KEY"
|
|
||||||
api_key = args.api_key or judge.get("api_key") or os.environ.get(api_key_env, "")
|
|
||||||
parallel = args.parallel if args.parallel is not None else int(judge.get("parallel", 4))
|
|
||||||
timeout_seconds = args.timeout_seconds if args.timeout_seconds is not None else int(judge.get("timeout_seconds", 120))
|
|
||||||
return {
|
|
||||||
"base_url": str(base_url),
|
|
||||||
"model": str(model),
|
|
||||||
"api_key": str(api_key),
|
|
||||||
"api_key_env": str(api_key_env),
|
|
||||||
"parallel": int(parallel),
|
|
||||||
"timeout_seconds": int(timeout_seconds),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def judge_prompt(question: str, expected: str, response: str) -> list[dict[str, str]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are an expert grader for long-term memory QA. Return JSON only.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": (
|
|
||||||
"Decide whether the generated answer matches the gold answer.\n"
|
|
||||||
"Be generous: count it correct if it refers to the same fact, topic, person, place, or date.\n"
|
|
||||||
"Return exactly JSON: {\"is_correct\":\"CORRECT\" or \"WRONG\", \"reasoning\":\"short reason\"}.\n\n"
|
|
||||||
f"Question: {question}\n"
|
|
||||||
f"Gold answer: {expected}\n"
|
|
||||||
f"Generated answer: {response}"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def grade_one(
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
*,
|
|
||||||
base_url: str,
|
|
||||||
api_key: str,
|
|
||||||
model: str,
|
|
||||||
item: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
payload = {
|
|
||||||
"model": model,
|
|
||||||
"temperature": 0,
|
|
||||||
"messages": judge_prompt(item["question"], item["expected"], item["response"]),
|
|
||||||
}
|
|
||||||
response = await client.post(
|
|
||||||
f"{base_url.rstrip('/')}/chat/completions",
|
|
||||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.json()["choices"][0]["message"]["content"]
|
|
||||||
parsed = json.loads(content)
|
|
||||||
label = str(parsed.get("is_correct", parsed.get("label", "WRONG"))).strip().lower()
|
|
||||||
return {
|
|
||||||
**item,
|
|
||||||
"grade": label == "correct",
|
|
||||||
"judge_reasoning": parsed.get("reasoning", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def grade_answers(
|
|
||||||
answers: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
base_url: str,
|
|
||||||
api_key: str,
|
|
||||||
model: str,
|
|
||||||
timeout_seconds: int = 120,
|
|
||||||
parallel: int = 4,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
limits = httpx.Limits(max_connections=max(1, parallel))
|
|
||||||
async with httpx.AsyncClient(timeout=timeout_seconds, limits=limits) as client:
|
|
||||||
semaphore = asyncio.Semaphore(max(1, parallel))
|
|
||||||
|
|
||||||
async def _grade(item: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
async with semaphore:
|
|
||||||
return await grade_one(client, base_url=base_url, api_key=api_key, model=model, item=item)
|
|
||||||
|
|
||||||
return await asyncio.gather(*[_grade(item) for item in answers])
|
|
||||||
|
|
||||||
|
|
||||||
def summarize(grades: list[dict[str, Any]]) -> dict[str, Any]:
|
|
||||||
correct = sum(1 for item in grades if item.get("grade"))
|
|
||||||
total = len(grades)
|
|
||||||
categories: dict[str, dict[str, int]] = {}
|
|
||||||
for item in grades:
|
|
||||||
category = str(item.get("category", "unknown"))
|
|
||||||
categories.setdefault(category, {"correct": 0, "total": 0})
|
|
||||||
categories[category]["total"] += 1
|
|
||||||
if item.get("grade"):
|
|
||||||
categories[category]["correct"] += 1
|
|
||||||
return {
|
|
||||||
"score": correct / total if total else 0.0,
|
|
||||||
"correct": correct,
|
|
||||||
"total": total,
|
|
||||||
"categories": categories,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Judge Hermes memory QA answers")
|
|
||||||
parser.add_argument("input", help="QA JSONL or JSON file")
|
|
||||||
parser.add_argument("--config", default="eval/hermes_memory_eval/config.yaml")
|
|
||||||
parser.add_argument("--output", default=None)
|
|
||||||
parser.add_argument("--base-url", default=None)
|
|
||||||
parser.add_argument("--api-key", default=None)
|
|
||||||
parser.add_argument("--api-key-env", default=None)
|
|
||||||
parser.add_argument("--model", default=None)
|
|
||||||
parser.add_argument("--parallel", type=int, default=None)
|
|
||||||
parser.add_argument("--timeout-seconds", type=int, default=None)
|
|
||||||
args = parser.parse_args()
|
|
||||||
judge_config = resolve_judge_config(args)
|
|
||||||
if not judge_config["api_key"]:
|
|
||||||
raise SystemExit(f"missing --api-key or {judge_config['api_key_env']}")
|
|
||||||
|
|
||||||
answers = load_answers(args.input)
|
|
||||||
grades = asyncio.run(
|
|
||||||
grade_answers(
|
|
||||||
answers,
|
|
||||||
base_url=judge_config["base_url"],
|
|
||||||
api_key=judge_config["api_key"],
|
|
||||||
model=judge_config["model"],
|
|
||||||
parallel=judge_config["parallel"],
|
|
||||||
timeout_seconds=judge_config["timeout_seconds"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
summary = summarize(grades)
|
|
||||||
print(f"score: {summary['correct']}/{summary['total']} ({summary['score']:.2%})")
|
|
||||||
for category, stats in sorted(summary["categories"].items()):
|
|
||||||
total = stats["total"]
|
|
||||||
score = stats["correct"] / total if total else 0.0
|
|
||||||
print(f"category {category}: {stats['correct']}/{total} ({score:.2%})")
|
|
||||||
|
|
||||||
if args.output:
|
|
||||||
output = {"summary": summary, "grades": grades}
|
|
||||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with Path(args.output).open("w", encoding="utf-8") as file:
|
|
||||||
json.dump(output, file, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
"""LoCoMo dataset parsing and formatting for Hermes memory evaluation."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LocomoSession:
|
|
||||||
sample_id: str
|
|
||||||
session_key: str
|
|
||||||
date_time: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LocomoQA:
|
|
||||||
sample_id: str
|
|
||||||
question: str
|
|
||||||
expected: str
|
|
||||||
category: str
|
|
||||||
evidence: list[Any]
|
|
||||||
|
|
||||||
|
|
||||||
def load_samples(path: str | Path, sample_index: int | None = None) -> list[dict[str, Any]]:
|
|
||||||
with Path(path).open("r", encoding="utf-8") as file:
|
|
||||||
data = json.load(file)
|
|
||||||
if not isinstance(data, list):
|
|
||||||
raise ValueError("LoCoMo input must be a JSON list")
|
|
||||||
if sample_index is None:
|
|
||||||
return data
|
|
||||||
if sample_index < 0 or sample_index >= len(data):
|
|
||||||
raise ValueError(f"sample index {sample_index} out of range 0-{len(data) - 1}")
|
|
||||||
return [data[sample_index]]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_session_range(value: str | None) -> tuple[int, int] | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
if "-" in value:
|
|
||||||
start, end = value.split("-", 1)
|
|
||||||
return int(start), int(end)
|
|
||||||
number = int(value)
|
|
||||||
return number, number
|
|
||||||
|
|
||||||
|
|
||||||
def format_message(message: dict[str, Any]) -> str:
|
|
||||||
speaker = message.get("speaker", "unknown")
|
|
||||||
text = message.get("text", "")
|
|
||||||
line = f"{speaker}: {text}"
|
|
||||||
image_urls = message.get("img_url", [])
|
|
||||||
if isinstance(image_urls, str):
|
|
||||||
image_urls = [image_urls]
|
|
||||||
caption = message.get("blip_caption", "")
|
|
||||||
for url in image_urls:
|
|
||||||
suffix = f": {caption}" if caption else ""
|
|
||||||
line += f"\n{url}{suffix}"
|
|
||||||
if caption and not image_urls:
|
|
||||||
line += f"\n({caption})"
|
|
||||||
return line
|
|
||||||
|
|
||||||
|
|
||||||
def build_sessions(
|
|
||||||
sample: dict[str, Any],
|
|
||||||
session_range: tuple[int, int] | None = None,
|
|
||||||
tail: str = "请记住以上历史对话,只回复 OK。",
|
|
||||||
) -> list[LocomoSession]:
|
|
||||||
conversation = sample["conversation"]
|
|
||||||
session_keys = sorted(
|
|
||||||
[key for key in conversation if key.startswith("session_") and not key.endswith("_date_time")],
|
|
||||||
key=lambda key: int(key.split("_")[1]),
|
|
||||||
)
|
|
||||||
sessions: list[LocomoSession] = []
|
|
||||||
for session_key in session_keys:
|
|
||||||
session_number = int(session_key.split("_")[1])
|
|
||||||
if session_range:
|
|
||||||
start, end = session_range
|
|
||||||
if session_number < start or session_number > end:
|
|
||||||
continue
|
|
||||||
date_time = conversation.get(f"{session_key}_date_time", "")
|
|
||||||
parts = [f"[group chat conversation: {date_time}]"]
|
|
||||||
parts.extend(format_message(message) for message in conversation[session_key])
|
|
||||||
if tail:
|
|
||||||
parts.append(tail)
|
|
||||||
sessions.append(
|
|
||||||
LocomoSession(
|
|
||||||
sample_id=str(sample["sample_id"]),
|
|
||||||
session_key=session_key,
|
|
||||||
date_time=date_time,
|
|
||||||
message="\n\n".join(parts),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return sessions
|
|
||||||
|
|
||||||
|
|
||||||
def build_qas(sample: dict[str, Any], *, include_category_5: bool = False) -> list[LocomoQA]:
|
|
||||||
qas: list[LocomoQA] = []
|
|
||||||
for qa in sample.get("qa", []):
|
|
||||||
category = str(qa.get("category", ""))
|
|
||||||
if category == "5" and not include_category_5:
|
|
||||||
continue
|
|
||||||
qas.append(
|
|
||||||
LocomoQA(
|
|
||||||
sample_id=str(sample["sample_id"]),
|
|
||||||
question=str(qa["question"]),
|
|
||||||
expected=str(qa["answer"]),
|
|
||||||
category=category,
|
|
||||||
evidence=qa.get("evidence", []),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return qas
|
|
||||||
|
|
||||||
|
|
||||||
def sample_user_id(prefix: str, sample: dict[str, Any]) -> str:
|
|
||||||
return f"{prefix}{sample['sample_id']}"
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
"""Run Hermes memory evaluation using LoCoMo-style datasets."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
if __package__ in {None, ""}:
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
||||||
|
|
||||||
from eval.hermes_memory_eval.hermes_client import HermesClient, HermesClientConfig
|
|
||||||
from eval.hermes_memory_eval.locomo import (
|
|
||||||
build_qas,
|
|
||||||
build_sessions,
|
|
||||||
load_samples,
|
|
||||||
parse_session_range,
|
|
||||||
sample_user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str | Path) -> dict[str, Any]:
|
|
||||||
with Path(path).open("r", encoding="utf-8") as file:
|
|
||||||
return yaml.safe_load(file) or {}
|
|
||||||
|
|
||||||
|
|
||||||
def memory_env(config: dict[str, Any]) -> dict[str, str]:
|
|
||||||
memory = config.get("memory", {})
|
|
||||||
env: dict[str, str] = {}
|
|
||||||
mappings = {
|
|
||||||
"env_file": "MEMORY_SYSTEM_ENV_FILE",
|
|
||||||
"endpoint": "MEMORY_SYSTEM_ENDPOINT",
|
|
||||||
"api_key": "MEMORY_SYSTEM_API_KEY",
|
|
||||||
"search_use_llm": "MEMORY_SYSTEM_SEARCH_USE_LLM",
|
|
||||||
"commit_every_turns": "MEMORY_SYSTEM_COMMIT_EVERY_TURNS",
|
|
||||||
"commit_interval_seconds": "MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS",
|
|
||||||
}
|
|
||||||
for key, env_key in mappings.items():
|
|
||||||
value = memory.get(key)
|
|
||||||
if value is not None:
|
|
||||||
env[env_key] = str(value)
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def build_client(config: dict[str, Any]) -> HermesClient:
|
|
||||||
hermes = config.get("hermes", {})
|
|
||||||
return HermesClient(
|
|
||||||
HermesClientConfig(
|
|
||||||
command=str(hermes.get("command", "hermes")),
|
|
||||||
timeout_seconds=int(hermes.get("timeout_seconds", 600)),
|
|
||||||
quiet=bool(hermes.get("quiet", True)),
|
|
||||||
source=str(hermes.get("source", "memory-eval")),
|
|
||||||
extra_args=[str(arg) for arg in hermes.get("extra_args", [])],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def qa_prompt(config: dict[str, Any], question: str) -> str:
|
|
||||||
qa_config = config.get("qa", {})
|
|
||||||
template = str(
|
|
||||||
qa_config.get(
|
|
||||||
"prompt_template",
|
|
||||||
(
|
|
||||||
"请先使用 memory_system_search 查询长期记忆,再根据检索到的记忆回答问题。"
|
|
||||||
"如果记忆中没有答案,请直接说不知道,不要编造。\n\n问题:{question}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return template.format(question=question)
|
|
||||||
|
|
||||||
|
|
||||||
def write_jsonl(path: str | Path, records: list[dict[str, Any]]) -> None:
|
|
||||||
output_path = Path(path)
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with output_path.open("w", encoding="utf-8") as file:
|
|
||||||
for record in records:
|
|
||||||
file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def run_ingest(args: argparse.Namespace) -> None:
|
|
||||||
config = load_config(args.config)
|
|
||||||
client = build_client(config)
|
|
||||||
env = memory_env(config)
|
|
||||||
samples = load_samples(args.input, args.sample)
|
|
||||||
session_range = parse_session_range(args.sessions)
|
|
||||||
user_prefix = str(config.get("memory", {}).get("user_prefix", "locomo-"))
|
|
||||||
records: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for sample in samples:
|
|
||||||
user_id = args.user or sample_user_id(user_prefix, sample)
|
|
||||||
sessions = build_sessions(sample, session_range=session_range, tail=args.tail)
|
|
||||||
print(f"=== Sample {sample['sample_id']} user={user_id} sessions={len(sessions)} ===", file=sys.stderr)
|
|
||||||
for session in sessions:
|
|
||||||
try:
|
|
||||||
response = client.chat(session.message, user_id=user_id, env=env)
|
|
||||||
status = "success"
|
|
||||||
except Exception as exc:
|
|
||||||
response = str(exc)
|
|
||||||
status = "failed"
|
|
||||||
print(f"[{session.sample_id}/{session.session_key}] {status}", file=sys.stderr)
|
|
||||||
records.append(
|
|
||||||
{
|
|
||||||
"mode": "ingest",
|
|
||||||
"status": status,
|
|
||||||
"sample_id": session.sample_id,
|
|
||||||
"session": session.session_key,
|
|
||||||
"date_time": session.date_time,
|
|
||||||
"user_id": user_id,
|
|
||||||
"response": response,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.output:
|
|
||||||
write_jsonl(args.output, records)
|
|
||||||
print(f"written: {args.output}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def run_qa(args: argparse.Namespace) -> None:
|
|
||||||
config = load_config(args.config)
|
|
||||||
client = build_client(config)
|
|
||||||
env = memory_env(config)
|
|
||||||
samples = load_samples(args.input, args.sample)
|
|
||||||
user_prefix = str(config.get("memory", {}).get("user_prefix", "locomo-"))
|
|
||||||
records: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for sample in samples:
|
|
||||||
user_id = args.user or sample_user_id(user_prefix, sample)
|
|
||||||
qas = build_qas(sample, include_category_5=args.include_category_5)
|
|
||||||
if args.count is not None:
|
|
||||||
qas = qas[: args.count]
|
|
||||||
print(f"=== Sample {sample['sample_id']} user={user_id} qa={len(qas)} ===", file=sys.stderr)
|
|
||||||
for index, qa in enumerate(qas, start=1):
|
|
||||||
try:
|
|
||||||
response = client.chat(qa_prompt(config, qa.question), user_id=user_id, env=env)
|
|
||||||
status = "success"
|
|
||||||
except Exception as exc:
|
|
||||||
response = str(exc)
|
|
||||||
status = "failed"
|
|
||||||
print(f"[{qa.sample_id}] Q{index}/{len(qas)} {status}", file=sys.stderr)
|
|
||||||
records.append(
|
|
||||||
{
|
|
||||||
"mode": "qa",
|
|
||||||
"status": status,
|
|
||||||
"sample_id": qa.sample_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"qi": index,
|
|
||||||
"question": qa.question,
|
|
||||||
"expected": qa.expected,
|
|
||||||
"response": response,
|
|
||||||
"category": qa.category,
|
|
||||||
"evidence": qa.evidence,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.output:
|
|
||||||
write_jsonl(args.output, records)
|
|
||||||
print(f"written: {args.output}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Evaluate Hermes memory with LoCoMo-style datasets")
|
|
||||||
parser.add_argument("mode", choices=["ingest", "qa"])
|
|
||||||
parser.add_argument("input", help="Path to LoCoMo JSON dataset")
|
|
||||||
parser.add_argument("--config", default="eval/hermes_memory_eval/config.example.yaml")
|
|
||||||
parser.add_argument("--output", default=None)
|
|
||||||
parser.add_argument("--sample", type=int, default=None)
|
|
||||||
parser.add_argument("--user", default=None)
|
|
||||||
parser.add_argument("--sessions", default=None, help="Ingest session range, for example 1-4")
|
|
||||||
parser.add_argument("--tail", default="请记住以上历史对话,只回复 OK。")
|
|
||||||
parser.add_argument("--count", type=int, default=None)
|
|
||||||
parser.add_argument("--include-category-5", action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.mode == "ingest":
|
|
||||||
run_ingest(args)
|
|
||||||
else:
|
|
||||||
run_qa(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,77 +1,36 @@
|
|||||||
# Copy this file to ./EverOS/methods/EverCore/.env.
|
# EverOS server settings used by the upstream EverOS process.
|
||||||
# Do not commit the copied .env file because it contains provider keys.
|
# Copy this file to everos.env or to the EverOS project .env, then fill secrets.
|
||||||
|
|
||||||
# Required by EverCore for memory extraction.
|
# API listener. The Memory Gateway should point EVEROS_BASE_URL at this host/port.
|
||||||
LLM_PROVIDER=openai
|
EVEROS_API__HOST=127.0.0.1
|
||||||
LLM_MODEL=<LLM_MODEL_NAME>
|
EVEROS_API__PORT=8000
|
||||||
LLM_BASE_URL=<LLM_BASE_URL>
|
|
||||||
LLM_API_KEY=<LLM_API_KEY>
|
|
||||||
LLM_TEMPERATURE=0.3
|
|
||||||
LLM_MAX_TOKENS=1000000
|
|
||||||
|
|
||||||
# Required by EverCore for embedding and rerank.
|
# Logging
|
||||||
|
EVEROS_LOG_LEVEL=INFO
|
||||||
|
EVEROS_LOG_FORMAT=console
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
VECTORIZE_PROVIDER=vllm
|
# LLM provider
|
||||||
VECTORIZE_API_KEY=<EMBEDDING_API_KEY>
|
EVEROS_LLM__BASE_URL=https://api.openai.com/v1
|
||||||
VECTORIZE_BASE_URL=<EMBEDDING_BASE_URL>
|
EVEROS_LLM__API_KEY=replace-with-llm-api-key
|
||||||
VECTORIZE_MODEL=Qwen3-VL-Embedding-2B
|
EVEROS_LLM__MODEL=gpt-4o-mini
|
||||||
VECTORIZE_FALLBACK_PROVIDER=none
|
EVEROS_LLM__TIMEOUT_SECONDS=120
|
||||||
VECTORIZE_TIMEOUT=30
|
|
||||||
VECTORIZE_MAX_RETRIES=3
|
|
||||||
VECTORIZE_BATCH_SIZE=10
|
|
||||||
VECTORIZE_MAX_CONCURRENT=5
|
|
||||||
VECTORIZE_ENCODING_FORMAT=float
|
|
||||||
VECTORIZE_DIMENSIONS=1024
|
|
||||||
|
|
||||||
RERANK_PROVIDER=vllm
|
# Embedding provider
|
||||||
RERANK_API_KEY=<RERANK_API_KEY>
|
EVEROS_EMBEDDING__BASE_URL=https://api.openai.com/v1
|
||||||
RERANK_BASE_URL=<RERANK_BASE_URL>
|
EVEROS_EMBEDDING__API_KEY=replace-with-embedding-api-key
|
||||||
RERANK_MODEL=Qwen3-VL-Reranker-2B
|
EVEROS_EMBEDDING__MODEL=text-embedding-3-small
|
||||||
|
EVEROS_EMBEDDING__TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
# EverCore API server.
|
# Rerank provider
|
||||||
API_BASE_URL=http://localhost:1995
|
EVEROS_RERANK__BASE_URL=https://api.example.com/v1
|
||||||
LOG_LEVEL=INFO
|
EVEROS_RERANK__API_KEY=replace-with-rerank-api-key
|
||||||
ENV=dev
|
EVEROS_RERANK__MODEL=replace-with-rerank-model
|
||||||
PYTHONASYNCIODEBUG=1
|
EVEROS_RERANK__TIMEOUT_SECONDS=120
|
||||||
MEMORY_LANGUAGE=en
|
|
||||||
|
|
||||||
# Docker compose default dependencies.
|
# Multimodal parsing provider
|
||||||
|
EVEROS_MULTIMODAL__BASE_URL=https://api.openai.com/v1
|
||||||
# ===================
|
EVEROS_MULTIMODAL__API_KEY=replace-with-multimodal-api-key
|
||||||
# Redis Configuration
|
EVEROS_MULTIMODAL__MODEL=gpt-4o-mini
|
||||||
# ===================
|
EVEROS_MULTIMODAL__TIMEOUT_SECONDS=120
|
||||||
|
EVEROS_MULTIMODAL__RESIZE_IMAGES_FOR_VLM=true
|
||||||
TENANT_SINGLE_TENANT_ID=t_tom
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_DB=8
|
|
||||||
REDIS_SSL=false
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# MongoDB Configuration
|
|
||||||
# ===================
|
|
||||||
|
|
||||||
MONGODB_HOST=localhost
|
|
||||||
MONGODB_PORT=27017
|
|
||||||
MONGODB_USERNAME=admin
|
|
||||||
MONGODB_PASSWORD=memsys123
|
|
||||||
MONGODB_DATABASE=memsys
|
|
||||||
MONGODB_URI_PARAMS=socketTimeoutMS=15000&authSource=admin
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# Elasticsearch Configuration
|
|
||||||
# ===================
|
|
||||||
|
|
||||||
ES_HOSTS=http://localhost:19200
|
|
||||||
ES_USERNAME=
|
|
||||||
ES_PASSWORD=
|
|
||||||
ES_VERIFY_CERTS=false
|
|
||||||
SELF_ES_INDEX_NS=memsys
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# Milvus Vector Database Configuration
|
|
||||||
# ===================
|
|
||||||
|
|
||||||
MILVUS_HOST=localhost
|
|
||||||
MILVUS_PORT=19530
|
|
||||||
SELF_MILVUS_COLLECTION_NS=memsys
|
|
||||||
|
|||||||
22
main.py
Normal file
22
main.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from core.api import create_app
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=os.environ.get("MEMORY_GATEWAY_HOST", "127.0.0.1"),
|
||||||
|
port=int(os.environ.get("MEMORY_GATEWAY_PORT", "8010")),
|
||||||
|
reload=os.environ.get("MEMORY_GATEWAY_RELOAD", "false").lower() == "true",
|
||||||
|
)
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Lightweight Memory System API package."""
|
|
||||||
@ -1,265 +0,0 @@
|
|||||||
"""FastAPI router for the lightweight Memory System API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
|
|
||||||
from .auth import verify_api_key
|
|
||||||
from .schemas import (
|
|
||||||
MemoryWriteRequest,
|
|
||||||
MessageIngestRequest,
|
|
||||||
ProfileRequest,
|
|
||||||
ResourceUploadRequest,
|
|
||||||
SearchRequest,
|
|
||||||
SessionContextRequest,
|
|
||||||
SessionUserRequest,
|
|
||||||
TaskStatusRequest,
|
|
||||||
UserCreateRequest,
|
|
||||||
)
|
|
||||||
from .service import MemorySystemService
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/memory-system",
|
|
||||||
tags=["memory-system"],
|
|
||||||
dependencies=[Depends(verify_api_key)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_service() -> MemorySystemService:
|
|
||||||
return MemorySystemService()
|
|
||||||
|
|
||||||
|
|
||||||
def user_auth_error(exc: PermissionError) -> HTTPException:
|
|
||||||
return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def health(service: MemorySystemService = Depends(get_service)):
|
|
||||||
return await service.health()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users")
|
|
||||||
async def create_user(request: UserCreateRequest, service: MemorySystemService = Depends(get_service)):
|
|
||||||
return await service.create_user(request.user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/messages")
|
|
||||||
async def ingest_messages(
|
|
||||||
request: MessageIngestRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.ingest_messages(request)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resources")
|
|
||||||
async def upload_resource(
|
|
||||||
request: ResourceUploadRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.upload_resource(request)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/resources")
|
|
||||||
async def delete_resource(
|
|
||||||
user_id: str = Query(min_length=1),
|
|
||||||
user_key: str = Query(min_length=1),
|
|
||||||
uri: str = Query(min_length=1),
|
|
||||||
recursive: bool = Query(default=True),
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.delete_resource(user_id, user_key, uri, recursive=recursive)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memories")
|
|
||||||
async def list_memories(
|
|
||||||
user_id: str = Query(min_length=1),
|
|
||||||
user_key: str = Query(min_length=1),
|
|
||||||
uri: str = Query(default="viking://user/memories", min_length=1),
|
|
||||||
recursive: bool = Query(default=True),
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.list_memories(user_id, user_key, uri=uri, recursive=recursive)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/memories/content")
|
|
||||||
async def read_memory(
|
|
||||||
user_id: str = Query(min_length=1),
|
|
||||||
user_key: str = Query(min_length=1),
|
|
||||||
uri: str = Query(min_length=1),
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.read_memory(user_id, user_key, uri)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memories")
|
|
||||||
async def write_memory(
|
|
||||||
request: MemoryWriteRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.write_memory(request)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/memories")
|
|
||||||
async def delete_memory(
|
|
||||||
user_id: str = Query(min_length=1),
|
|
||||||
user_key: str = Query(min_length=1),
|
|
||||||
uri: str = Query(min_length=1),
|
|
||||||
recursive: bool = Query(default=False),
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.delete_memory(user_id, user_key, uri, recursive=recursive)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/commit")
|
|
||||||
async def commit_session(
|
|
||||||
session_id: str,
|
|
||||||
request: SessionUserRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.commit_session(request.user_id, request.user_key, session_id)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/extract")
|
|
||||||
async def extract_session(
|
|
||||||
session_id: str,
|
|
||||||
request: SessionUserRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.extract_session(request.user_id, request.user_key, session_id)
|
|
||||||
except PermissionError as 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}")
|
|
||||||
async def get_openviking_task(
|
|
||||||
task_id: str,
|
|
||||||
user_id: str = Query(min_length=1),
|
|
||||||
user_key: str = Query(min_length=1),
|
|
||||||
session_id: str | None = Query(default=None, min_length=1),
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.get_openviking_task(
|
|
||||||
user_id,
|
|
||||||
user_key,
|
|
||||||
task_id,
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/openviking/tasks/{task_id}")
|
|
||||||
async def get_openviking_task_from_body(
|
|
||||||
task_id: str,
|
|
||||||
request: TaskStatusRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.get_openviking_task(
|
|
||||||
request.user_id,
|
|
||||||
request.user_key,
|
|
||||||
task_id,
|
|
||||||
session_id=request.session_id,
|
|
||||||
)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search")
|
|
||||||
async def search(
|
|
||||||
request: SearchRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.search(request)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{user_id}/profile")
|
|
||||||
async def get_profile_from_body(
|
|
||||||
user_id: str,
|
|
||||||
request: ProfileRequest,
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.get_profile(
|
|
||||||
user_id,
|
|
||||||
request.user_key,
|
|
||||||
query=request.query,
|
|
||||||
limit=request.limit,
|
|
||||||
level=request.level,
|
|
||||||
)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_id}/profile")
|
|
||||||
async def get_profile(
|
|
||||||
user_id: str,
|
|
||||||
user_key: str = Query(min_length=1),
|
|
||||||
query: str = Query(default="用户画像", min_length=1),
|
|
||||||
limit: int = Query(default=10, ge=1, le=100),
|
|
||||||
level: int = Query(default=2, ge=0),
|
|
||||||
service: MemorySystemService = Depends(get_service),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await service.get_profile(user_id, user_key, query=query, limit=limit, level=level)
|
|
||||||
except PermissionError as exc:
|
|
||||||
raise user_auth_error(exc) from exc
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
"""API key auth for Memory System API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
|
||||||
|
|
||||||
from .config import get_config
|
|
||||||
|
|
||||||
|
|
||||||
def verify_api_key(x_api_key: str | None = Header(default=None)) -> None:
|
|
||||||
expected = get_config().server.api_key
|
|
||||||
if expected and x_api_key != expected:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
|
||||||
@ -1,484 +0,0 @@
|
|||||||
"""Async clients for OpenViking and EverOS used by the lightweight API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .config import get_config
|
|
||||||
from .store import ADMIN_ACCOUNT_ID, ADMIN_USER_ID, OpenVikingUserKeyStore
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class OpenVikingCredential:
|
|
||||||
api_key: str
|
|
||||||
account_id: str | None = None
|
|
||||||
user_id: str | None = None
|
|
||||||
agent_id: str | None = None
|
|
||||||
user_key_auth: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class OpenVikingMemorySystemClient:
|
|
||||||
def __init__(self, store: OpenVikingUserKeyStore | None = None) -> None:
|
|
||||||
config = get_config()
|
|
||||||
self.base_url = config.openviking.url.rstrip("/")
|
|
||||||
self.root_key = config.openviking.api_key or "your-secret-root-key"
|
|
||||||
self.timeout = config.openviking.timeout
|
|
||||||
self.verify_ssl = config.openviking.verify_ssl
|
|
||||||
self.store = store or OpenVikingUserKeyStore(config.storage.sqlite_path)
|
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
|
||||||
async with self._client(self.root_key) as client:
|
|
||||||
response = await client.get("/health")
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def create_account(self, account_id: str = ADMIN_ACCOUNT_ID, admin_user_id: str = ADMIN_USER_ID) -> dict[str, Any]:
|
|
||||||
async with self._client(self.root_key) as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/admin/accounts",
|
|
||||||
json=self._create_account_payload(account_id, admin_user_id),
|
|
||||||
)
|
|
||||||
if response.status_code == 409:
|
|
||||||
return response.json()
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
user_key = self._extract_user_key(data)
|
|
||||||
if user_key:
|
|
||||||
self.store.save_account_key(account_id, admin_user_id, user_key)
|
|
||||||
self.store.save_user_key(admin_user_id, user_key, account_id=account_id)
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def create_user(self, user_id: str, role: str = "user") -> dict[str, Any]:
|
|
||||||
existing = self.store.get_user_key(user_id)
|
|
||||||
account_id = self._account_id_for_user(user_id)
|
|
||||||
if existing:
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": account_id,
|
|
||||||
"admin_user_id": user_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"user_key": existing,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return await self.create_account(account_id, user_id)
|
|
||||||
|
|
||||||
def credential_for_user(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
user_key: str,
|
|
||||||
agent_id: str | None = None,
|
|
||||||
) -> OpenVikingCredential:
|
|
||||||
if not self.store.user_key_matches(user_id, user_key):
|
|
||||||
raise PermissionError("Invalid user key")
|
|
||||||
return self.user_credential(user_key, user_id, agent_id=agent_id)
|
|
||||||
|
|
||||||
def user_credential(
|
|
||||||
self,
|
|
||||||
user_key: str,
|
|
||||||
user_id: str,
|
|
||||||
agent_id: str | None = None,
|
|
||||||
) -> OpenVikingCredential:
|
|
||||||
return OpenVikingCredential(
|
|
||||||
api_key=user_key,
|
|
||||||
account_id=self._account_id_for_user(user_id),
|
|
||||||
user_id=user_id,
|
|
||||||
agent_id=agent_id,
|
|
||||||
user_key_auth=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def ensure_session(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post("/api/v1/sessions", json={"session_id": session_id})
|
|
||||||
if response.status_code in {409, 422}:
|
|
||||||
self._save_session(credential, session_id)
|
|
||||||
return {"session_id": session_id, "status": "exists"}
|
|
||||||
response.raise_for_status()
|
|
||||||
self._save_session(credential, session_id)
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def append_message(
|
|
||||||
self, credential: OpenVikingCredential | str, session_id: str, role: str, content: str
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/sessions/{session_id}/messages",
|
|
||||||
json={"role": role, "content": content},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def commit_session(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"/api/v1/sessions/{session_id}/commit",
|
|
||||||
json={"keep_recent_count": 0},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
self._save_commit_metadata(credential, session_id, data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def extract_session(self, credential: OpenVikingCredential | str, session_id: str) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post(f"/api/v1/sessions/{session_id}/extract")
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def get_task(self, credential: OpenVikingCredential | str, task_id: str) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.get(f"/api/v1/tasks/{task_id}")
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def upload_temp_file(self, credential: OpenVikingCredential | str, path: str | Path) -> dict[str, Any]:
|
|
||||||
file_path = Path(path)
|
|
||||||
async with self._credential_client(credential, json_content_type=False) as client:
|
|
||||||
with file_path.open("rb") as file_obj:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/resources/temp_upload",
|
|
||||||
files={"file": (file_path.name, file_obj)},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def add_resource(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
*,
|
|
||||||
to: str,
|
|
||||||
reason: str | None = None,
|
|
||||||
wait: bool = True,
|
|
||||||
directly_upload_media: bool = True,
|
|
||||||
path: str | None = None,
|
|
||||||
temp_file_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"to": to,
|
|
||||||
"wait": wait,
|
|
||||||
"directly_upload_media": directly_upload_media,
|
|
||||||
}
|
|
||||||
if reason is not None:
|
|
||||||
payload["reason"] = reason
|
|
||||||
if temp_file_id is not None:
|
|
||||||
payload["temp_file_id"] = temp_file_id
|
|
||||||
else:
|
|
||||||
payload["path"] = path
|
|
||||||
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post("/api/v1/resources", json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def delete_resource(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
uri: str,
|
|
||||||
recursive: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.delete(
|
|
||||||
"/api/v1/fs",
|
|
||||||
params={"uri": uri, "recursive": str(recursive).lower()},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def list_memories(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
uri: str = "viking://user/memories",
|
|
||||||
recursive: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.get(
|
|
||||||
"/api/v1/fs/ls",
|
|
||||||
params={"uri": uri, "recursive": str(recursive).lower()},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def read_memory(self, credential: OpenVikingCredential | str, uri: str) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.get("/api/v1/content/read", params={"uri": uri})
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def write_memory(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
*,
|
|
||||||
uri: str,
|
|
||||||
content: str,
|
|
||||||
mode: str = "create",
|
|
||||||
wait: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/content/write",
|
|
||||||
json={
|
|
||||||
"uri": uri,
|
|
||||||
"content": content,
|
|
||||||
"mode": mode,
|
|
||||||
"wait": wait,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def delete_memory(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
uri: str,
|
|
||||||
recursive: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.delete(
|
|
||||||
"/api/v1/fs",
|
|
||||||
params={"uri": uri, "recursive": str(recursive).lower()},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def find(self, credential: OpenVikingCredential | str, query: str, limit: int) -> dict[str, Any]:
|
|
||||||
user_id = credential.user_id if isinstance(credential, OpenVikingCredential) else None
|
|
||||||
target_uri = f"viking://user/{user_id}/memories/" if user_id else "viking://user/memories/"
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/search/find",
|
|
||||||
json={
|
|
||||||
"query": query,
|
|
||||||
"target_uri": target_uri,
|
|
||||||
"limit": limit,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
query: str,
|
|
||||||
limit: int,
|
|
||||||
level: int = 2,
|
|
||||||
score_threshold: float = 0.8,
|
|
||||||
target_uri: str = "viking://user/memories",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"query": query,
|
|
||||||
"target_uri": target_uri,
|
|
||||||
"limit": limit,
|
|
||||||
"level": level,
|
|
||||||
"score_threshold": score_threshold,
|
|
||||||
}
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post("/api/v1/search/search", json=payload)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def search_profile_memories(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
query: str,
|
|
||||||
limit: int,
|
|
||||||
level: int,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
async with self._credential_client(credential) as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/search/search",
|
|
||||||
json={
|
|
||||||
"query": query,
|
|
||||||
"limit": limit,
|
|
||||||
"level": level,
|
|
||||||
"target_uri": "viking://user/memories",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
json_content_type: bool = True,
|
|
||||||
) -> httpx.AsyncClient:
|
|
||||||
if isinstance(credential, str):
|
|
||||||
return self._client(credential, json_content_type=json_content_type)
|
|
||||||
if credential.user_key_auth:
|
|
||||||
return self._client(credential.api_key, json_content_type=json_content_type)
|
|
||||||
headers = {}
|
|
||||||
if credential.account_id:
|
|
||||||
headers["X-OpenViking-Account"] = credential.account_id
|
|
||||||
if credential.user_id:
|
|
||||||
headers["X-OpenViking-User"] = credential.user_id
|
|
||||||
if credential.agent_id:
|
|
||||||
headers["X-OpenViking-Agent"] = credential.agent_id
|
|
||||||
return self._client(credential.api_key, headers, json_content_type=json_content_type)
|
|
||||||
|
|
||||||
def _client(
|
|
||||||
self,
|
|
||||||
api_key: str,
|
|
||||||
extra_headers: dict[str, str] | None = None,
|
|
||||||
json_content_type: bool = True,
|
|
||||||
) -> httpx.AsyncClient:
|
|
||||||
headers = {"X-API-Key": api_key}
|
|
||||||
if json_content_type:
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
if extra_headers:
|
|
||||||
headers.update(extra_headers)
|
|
||||||
return httpx.AsyncClient(
|
|
||||||
base_url=self.base_url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=self.timeout,
|
|
||||||
verify=self.verify_ssl,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _extract_user_key(self, data: dict[str, Any]) -> str | None:
|
|
||||||
result = data.get("result") if isinstance(data.get("result"), dict) else data
|
|
||||||
value = result.get("user_key") if isinstance(result, dict) else None
|
|
||||||
return str(value) if value else None
|
|
||||||
|
|
||||||
def _create_account_payload(self, account_id: str, admin_user_id: str) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"account_id": account_id,
|
|
||||||
"admin_user_id": admin_user_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _account_id_for_user(self, user_id: str) -> str:
|
|
||||||
return f"{user_id}_account"
|
|
||||||
|
|
||||||
def _save_session(self, credential: OpenVikingCredential | str, session_id: str) -> None:
|
|
||||||
if isinstance(credential, OpenVikingCredential) and credential.user_id:
|
|
||||||
self.store.save_session(credential.user_id, session_id)
|
|
||||||
|
|
||||||
def _save_commit_metadata(
|
|
||||||
self,
|
|
||||||
credential: OpenVikingCredential | str,
|
|
||||||
session_id: str,
|
|
||||||
data: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(credential, OpenVikingCredential) or not credential.user_id:
|
|
||||||
return
|
|
||||||
result = data.get("result") if isinstance(data.get("result"), dict) else data
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
return
|
|
||||||
task_id = result.get("task_id")
|
|
||||||
if not task_id:
|
|
||||||
return
|
|
||||||
archive_uri = result.get("archive_uri")
|
|
||||||
self.store.save_task(
|
|
||||||
user_id=credential.user_id,
|
|
||||||
session_id=session_id,
|
|
||||||
task_id=str(task_id),
|
|
||||||
archive_uri=str(archive_uri) if archive_uri else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EverOSMemorySystemClient:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
config = get_config()
|
|
||||||
self.base_url = config.everos.url.rstrip("/")
|
|
||||||
self.api_key = config.everos.api_key
|
|
||||||
self.timeout = config.everos.timeout
|
|
||||||
self.verify_ssl = config.everos.verify_ssl
|
|
||||||
self.health_path = config.everos.health_path
|
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
|
||||||
async with self._client() as client:
|
|
||||||
response = await client.get(self.health_path)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def append_message(self, user_id: str, session_id: str, role: str, content: str) -> dict[str, Any]:
|
|
||||||
async with self._client() as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/memory/add",
|
|
||||||
json=self.build_message_payload(user_id=user_id, session_id=session_id, role=role, content=content),
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def build_message_payload(self, user_id: str, session_id: str, role: str, content: str) -> dict[str, Any]:
|
|
||||||
everos_role = "assistant" if role == "assistant" else "user"
|
|
||||||
sender_id = "assistant" if everos_role == "assistant" else user_id
|
|
||||||
timestamp = int(datetime.now(timezone.utc).timestamp() * 1000)
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"message_id": f"msg_{timestamp}",
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"sender_id": sender_id,
|
|
||||||
"sender_name": sender_id,
|
|
||||||
"role": everos_role,
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def flush(self, user_id: str, session_id: str) -> dict[str, Any]:
|
|
||||||
async with self._client() as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/memory/flush",
|
|
||||||
json={"session_id": session_id},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def search(self, user_id: str, session_id: str | None, query: str, method: str, limit: int) -> dict[str, Any]:
|
|
||||||
filters: dict[str, Any] = {}
|
|
||||||
if session_id:
|
|
||||||
filters["session_id"] = session_id
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"query": query,
|
|
||||||
"method": method,
|
|
||||||
"top_k": limit,
|
|
||||||
"include_profile": True,
|
|
||||||
}
|
|
||||||
if filters:
|
|
||||||
payload["filters"] = filters
|
|
||||||
async with self._client() as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/memory/search",
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def get_profile(self, user_id: str) -> dict[str, Any]:
|
|
||||||
async with self._client() as client:
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/memory/get",
|
|
||||||
json={
|
|
||||||
"user_id": user_id,
|
|
||||||
"memory_type": "profile",
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def _client(self) -> httpx.AsyncClient:
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
if self.api_key:
|
|
||||||
headers["X-API-Key"] = self.api_key
|
|
||||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
||||||
return httpx.AsyncClient(
|
|
||||||
base_url=self.base_url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=self.timeout,
|
|
||||||
verify=self.verify_ssl,
|
|
||||||
)
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
"""Configuration loading for Memory System API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ServerConfig(BaseModel):
|
|
||||||
host: str = "127.0.0.1"
|
|
||||||
port: int = 1934
|
|
||||||
api_key: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class OpenVikingConfig(BaseModel):
|
|
||||||
url: str = "http://127.0.0.1:1933"
|
|
||||||
api_key: str = ""
|
|
||||||
timeout: int = 30
|
|
||||||
verify_ssl: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class EverOSConfig(BaseModel):
|
|
||||||
url: str = "http://127.0.0.1:1995"
|
|
||||||
api_key: str = ""
|
|
||||||
timeout: int = 180
|
|
||||||
verify_ssl: bool = True
|
|
||||||
health_path: str = "/health"
|
|
||||||
|
|
||||||
|
|
||||||
class StorageConfig(BaseModel):
|
|
||||||
sqlite_path: str = "/home/tom/memory-gateway/memory_system_api.sqlite3"
|
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseModel):
|
|
||||||
level: str = "INFO"
|
|
||||||
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
|
||||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
||||||
openviking: OpenVikingConfig = Field(default_factory=OpenVikingConfig)
|
|
||||||
everos: EverOSConfig = Field(default_factory=EverOSConfig)
|
|
||||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
|
||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
||||||
|
|
||||||
|
|
||||||
_config: Config | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: str | None = None) -> Config:
|
|
||||||
path = Path(config_path or os.environ.get("MEMORY_SYSTEM_CONFIG", "config.yaml"))
|
|
||||||
if not path.exists():
|
|
||||||
return _apply_env_overrides(Config())
|
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
|
||||||
data = yaml.safe_load(handle) or {}
|
|
||||||
config = Config(
|
|
||||||
server=ServerConfig(**data.get("server", {})),
|
|
||||||
openviking=OpenVikingConfig(**data.get("openviking", {})),
|
|
||||||
everos=EverOSConfig(**data.get("everos", {})),
|
|
||||||
storage=StorageConfig(**data.get("storage", {})),
|
|
||||||
logging=LoggingConfig(**data.get("logging", {})),
|
|
||||||
)
|
|
||||||
return _apply_env_overrides(config)
|
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> Config:
|
|
||||||
global _config
|
|
||||||
if _config is None:
|
|
||||||
_config = load_config()
|
|
||||||
return _config
|
|
||||||
|
|
||||||
|
|
||||||
def set_config(config: Config) -> None:
|
|
||||||
global _config
|
|
||||||
_config = config
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_env_overrides(config: Config) -> Config:
|
|
||||||
updates: dict[str, dict[str, Any]] = {
|
|
||||||
"server": _env_updates("MEMORY_SYSTEM_SERVER", {"API_KEY": "api_key", "HOST": "host", "PORT": "port"}),
|
|
||||||
"openviking": _env_updates("OPENVIKING", {"URL": "url", "BASE_URL": "url", "API_KEY": "api_key", "TIMEOUT": "timeout"}),
|
|
||||||
"everos": _env_updates("EVEROS", {"URL": "url", "BASE_URL": "url", "API_KEY": "api_key", "TIMEOUT": "timeout"}),
|
|
||||||
"storage": _env_updates("MEMORY_SYSTEM_STORAGE", {"SQLITE_PATH": "sqlite_path"}),
|
|
||||||
}
|
|
||||||
for section, values in updates.items():
|
|
||||||
if values:
|
|
||||||
setattr(config, section, getattr(config, section).model_copy(update=values))
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def _env_updates(prefix: str, mapping: dict[str, str]) -> dict[str, Any]:
|
|
||||||
values: dict[str, Any] = {}
|
|
||||||
for env_name, field_name in mapping.items():
|
|
||||||
raw = os.environ.get(f"{prefix}_{env_name}")
|
|
||||||
if raw is None:
|
|
||||||
continue
|
|
||||||
values[field_name] = int(raw) if field_name in {"port", "timeout"} else raw
|
|
||||||
return values
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
"""Schemas for the lightweight Memory System API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
OperationStatus = Literal["success", "partial_success", "failed"]
|
|
||||||
MemoryWriteMode = Literal["create", "replace", "append"]
|
|
||||||
|
|
||||||
|
|
||||||
class MessageIngestRequest(BaseModel):
|
|
||||||
user_id: str = Field(min_length=1)
|
|
||||||
user_key: str = Field(min_length=1)
|
|
||||||
session_id: str = Field(min_length=1)
|
|
||||||
user_message: str | None = None
|
|
||||||
assistant_message: str | None = None
|
|
||||||
timestamp: int | None = None
|
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionUserRequest(BaseModel):
|
|
||||||
user_id: str = Field(min_length=1)
|
|
||||||
user_key: str = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskStatusRequest(SessionUserRequest):
|
|
||||||
session_id: str | None = Field(default=None, min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
|
||||||
user_id: str = Field(min_length=1)
|
|
||||||
user_key: str = Field(min_length=1)
|
|
||||||
session_id: str | None = None
|
|
||||||
query: str = Field(min_length=1)
|
|
||||||
use_llm: bool = False
|
|
||||||
limit: int = Field(default=10, ge=1, le=100)
|
|
||||||
level: int = Field(default=2, ge=0)
|
|
||||||
score_threshold: float = Field(default=0.8, ge=0, le=1)
|
|
||||||
target_uri: str = Field(default="viking://user/memories", min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
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 ProfileRequest(BaseModel):
|
|
||||||
user_key: str = Field(min_length=1)
|
|
||||||
query: str = Field(default="用户画像", min_length=1)
|
|
||||||
limit: int = Field(default=10, ge=1, le=100)
|
|
||||||
level: int = Field(default=2, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryWriteRequest(BaseModel):
|
|
||||||
user_id: str = Field(min_length=1)
|
|
||||||
user_key: str = Field(min_length=1)
|
|
||||||
uri: str = Field(min_length=1)
|
|
||||||
content: str
|
|
||||||
mode: MemoryWriteMode = "create"
|
|
||||||
wait: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceUploadRequest(BaseModel):
|
|
||||||
user_id: str = Field(min_length=1)
|
|
||||||
user_key: str = Field(min_length=1)
|
|
||||||
path: str = Field(min_length=1)
|
|
||||||
to: str = Field(min_length=1)
|
|
||||||
reason: str | None = None
|
|
||||||
wait: bool = True
|
|
||||||
directly_upload_media: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class BackendStatus(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
result: Any = None
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserCreateRequest(BaseModel):
|
|
||||||
user_id: str = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class AccountResponse(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
account: Any = None
|
|
||||||
backends: dict[str, BackendStatus]
|
|
||||||
|
|
||||||
|
|
||||||
class MessageIngestResponse(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
message_count: int
|
|
||||||
backends: dict[str, BackendStatus]
|
|
||||||
|
|
||||||
|
|
||||||
class CommitResponse(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
backends: dict[str, BackendStatus]
|
|
||||||
|
|
||||||
|
|
||||||
class ExtractResponse(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
backends: dict[str, BackendStatus]
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
|
||||||
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
|
|
||||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
|
||||||
backends: dict[str, BackendStatus]
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryOperationResponse(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
memory: Any = None
|
|
||||||
backends: dict[str, BackendStatus]
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceMutationResponse(BaseModel):
|
|
||||||
status: OperationStatus
|
|
||||||
resource: Any = None
|
|
||||||
backends: dict[str, BackendStatus]
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
"""Standalone FastAPI server for Memory System API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from .api import router
|
|
||||||
from .config import Config, load_config, set_config
|
|
||||||
|
|
||||||
|
|
||||||
request_logger = logging.getLogger("memory_system_api.requests")
|
|
||||||
|
|
||||||
app = FastAPI(title="Memory System API", version="0.1.0")
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def log_request_and_response(request: Request, call_next):
|
|
||||||
request_body = await request.body()
|
|
||||||
request_logger.info(
|
|
||||||
"request %s %s body=%s",
|
|
||||||
request.method,
|
|
||||||
_path_with_query(request),
|
|
||||||
_body_for_log(request_body),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def receive():
|
|
||||||
return {"type": "http.request", "body": request_body, "more_body": False}
|
|
||||||
|
|
||||||
response = await call_next(Request(request.scope, receive))
|
|
||||||
response_body = b""
|
|
||||||
async for chunk in response.body_iterator:
|
|
||||||
response_body += chunk
|
|
||||||
|
|
||||||
request_logger.info(
|
|
||||||
"response %s %s status=%s body=%s",
|
|
||||||
request.method,
|
|
||||||
_path_with_query(request),
|
|
||||||
response.status_code,
|
|
||||||
_body_for_log(response_body),
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
content=response_body,
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=dict(response.headers),
|
|
||||||
media_type=response.media_type,
|
|
||||||
background=response.background,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(router)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(config: Config | None = None) -> FastAPI:
|
|
||||||
if config:
|
|
||||||
set_config(config)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
def _path_with_query(request: Request) -> str:
|
|
||||||
query = request.url.query
|
|
||||||
return f"{request.url.path}?{query}" if query else request.url.path
|
|
||||||
|
|
||||||
|
|
||||||
def _body_for_log(body: bytes) -> str:
|
|
||||||
if not body:
|
|
||||||
return ""
|
|
||||||
return body.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
import argparse
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Memory System API")
|
|
||||||
parser.add_argument("--config", default="config.yaml", help="Config file path")
|
|
||||||
parser.add_argument("--host", default=None, help="Bind host")
|
|
||||||
parser.add_argument("--port", type=int, default=None, help="Bind port")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
config = load_config(args.config)
|
|
||||||
if args.host:
|
|
||||||
config.server.host = args.host
|
|
||||||
if args.port:
|
|
||||||
config.server.port = args.port
|
|
||||||
set_config(config)
|
|
||||||
logging.basicConfig(level=config.logging.level.upper(), format=config.logging.format)
|
|
||||||
|
|
||||||
uvicorn.run(app, host=config.server.host, port=config.server.port, log_level=config.logging.level.lower())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,504 +0,0 @@
|
|||||||
"""Orchestration for the lightweight Memory System API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any, Awaitable, Callable
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from .clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient
|
|
||||||
from .schemas import (
|
|
||||||
AccountResponse,
|
|
||||||
BackendStatus,
|
|
||||||
CommitResponse,
|
|
||||||
ExtractResponse,
|
|
||||||
MemoryOperationResponse,
|
|
||||||
MemoryWriteRequest,
|
|
||||||
MessageIngestRequest,
|
|
||||||
MessageIngestResponse,
|
|
||||||
ProfileResponse,
|
|
||||||
ResourceMutationResponse,
|
|
||||||
ResourceUploadRequest,
|
|
||||||
SearchRequest,
|
|
||||||
SearchResponse,
|
|
||||||
SessionContextRequest,
|
|
||||||
SessionContextResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MemorySystemService:
|
|
||||||
def __init__(self, openviking: Any | None = None, everos: Any | None = None) -> None:
|
|
||||||
self.openviking = openviking or OpenVikingMemorySystemClient()
|
|
||||||
self.everos = everos or EverOSMemorySystemClient()
|
|
||||||
|
|
||||||
async def create_user(self, user_id: str) -> AccountResponse:
|
|
||||||
backends = {"openviking": await self._capture(lambda: self.openviking.create_user(user_id))}
|
|
||||||
account = backends["openviking"].result if backends["openviking"].status == "success" else None
|
|
||||||
return AccountResponse(status=self._aggregate_status(backends), account=account, backends=backends)
|
|
||||||
|
|
||||||
async def upload_resource(self, request: ResourceUploadRequest) -> ResourceMutationResponse:
|
|
||||||
credential = self.openviking.credential_for_user(request.user_id, request.user_key)
|
|
||||||
|
|
||||||
async def upload_openviking() -> dict[str, Any]:
|
|
||||||
if self._is_remote_url(request.path):
|
|
||||||
return await self.openviking.add_resource(
|
|
||||||
credential,
|
|
||||||
path=request.path,
|
|
||||||
to=request.to,
|
|
||||||
reason=request.reason,
|
|
||||||
wait=request.wait,
|
|
||||||
directly_upload_media=request.directly_upload_media,
|
|
||||||
)
|
|
||||||
|
|
||||||
temp_upload = await self.openviking.upload_temp_file(credential, request.path)
|
|
||||||
temp_file_id = self._temp_file_id_from_result(temp_upload)
|
|
||||||
return await self.openviking.add_resource(
|
|
||||||
credential,
|
|
||||||
temp_file_id=temp_file_id,
|
|
||||||
to=request.to,
|
|
||||||
reason=request.reason,
|
|
||||||
wait=request.wait,
|
|
||||||
directly_upload_media=request.directly_upload_media,
|
|
||||||
)
|
|
||||||
|
|
||||||
backends = {"openviking": await self._capture(upload_openviking)}
|
|
||||||
resource = backends["openviking"].result if backends["openviking"].status == "success" else None
|
|
||||||
return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends)
|
|
||||||
|
|
||||||
async def delete_resource(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
user_key: str,
|
|
||||||
uri: str,
|
|
||||||
recursive: bool = True,
|
|
||||||
) -> ResourceMutationResponse:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key)
|
|
||||||
backends = {
|
|
||||||
"openviking": await self._capture(lambda: self.openviking.delete_resource(credential, uri, recursive)),
|
|
||||||
}
|
|
||||||
resource = backends["openviking"].result if backends["openviking"].status == "success" else None
|
|
||||||
return ResourceMutationResponse(status=self._aggregate_status(backends), resource=resource, backends=backends)
|
|
||||||
|
|
||||||
async def list_memories(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
user_key: str,
|
|
||||||
uri: str = "viking://user/memories",
|
|
||||||
recursive: bool = True,
|
|
||||||
) -> MemoryOperationResponse:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key)
|
|
||||||
backends = {
|
|
||||||
"openviking": await self._capture(lambda: self.openviking.list_memories(credential, uri, recursive)),
|
|
||||||
}
|
|
||||||
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
|
|
||||||
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, backends=backends)
|
|
||||||
|
|
||||||
async def read_memory(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
user_key: str,
|
|
||||||
uri: str,
|
|
||||||
) -> MemoryOperationResponse:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key)
|
|
||||||
backends = {
|
|
||||||
"openviking": await self._capture(lambda: self.openviking.read_memory(credential, uri)),
|
|
||||||
}
|
|
||||||
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
|
|
||||||
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, backends=backends)
|
|
||||||
|
|
||||||
async def write_memory(self, request: MemoryWriteRequest) -> MemoryOperationResponse:
|
|
||||||
credential = self.openviking.credential_for_user(request.user_id, request.user_key)
|
|
||||||
backends = {
|
|
||||||
"openviking": await self._capture(
|
|
||||||
lambda: self.openviking.write_memory(
|
|
||||||
credential,
|
|
||||||
uri=request.uri,
|
|
||||||
content=request.content,
|
|
||||||
mode=request.mode,
|
|
||||||
wait=request.wait,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
|
|
||||||
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, backends=backends)
|
|
||||||
|
|
||||||
async def delete_memory(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
user_key: str,
|
|
||||||
uri: str,
|
|
||||||
recursive: bool = False,
|
|
||||||
) -> MemoryOperationResponse:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key)
|
|
||||||
backends = {
|
|
||||||
"openviking": await self._capture(lambda: self.openviking.delete_memory(credential, uri, recursive)),
|
|
||||||
}
|
|
||||||
memory = backends["openviking"].result if backends["openviking"].status == "success" else None
|
|
||||||
return MemoryOperationResponse(status=self._aggregate_status(backends), memory=memory, backends=backends)
|
|
||||||
|
|
||||||
async def ingest_messages(self, request: MessageIngestRequest) -> MessageIngestResponse:
|
|
||||||
messages = self._messages_from_request(request)
|
|
||||||
if not messages:
|
|
||||||
raise ValueError("at least one message is required")
|
|
||||||
|
|
||||||
credential = self.openviking.credential_for_user(
|
|
||||||
request.user_id,
|
|
||||||
request.user_key,
|
|
||||||
agent_id=request.session_id,
|
|
||||||
)
|
|
||||||
await self.openviking.ensure_session(credential, request.session_id)
|
|
||||||
|
|
||||||
async def write_openviking() -> list[dict[str, Any]]:
|
|
||||||
results = []
|
|
||||||
for message in messages:
|
|
||||||
results.append(
|
|
||||||
await self.openviking.append_message(credential, request.session_id, message["role"], message["content"])
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def write_everos() -> list[dict[str, Any]]:
|
|
||||||
results = []
|
|
||||||
for message in messages:
|
|
||||||
results.append(
|
|
||||||
await self.everos.append_message(request.user_id, request.session_id, message["role"], message["content"])
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
backends = await self._run_backends(openviking=write_openviking, everos=write_everos)
|
|
||||||
return MessageIngestResponse(
|
|
||||||
status=self._aggregate_status(backends),
|
|
||||||
message_count=len(messages),
|
|
||||||
backends=backends,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def commit_session(self, user_id: str, user_key: str, session_id: str) -> CommitResponse:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key, agent_id=session_id)
|
|
||||||
|
|
||||||
async def commit_openviking() -> dict[str, Any]:
|
|
||||||
return await self.openviking.commit_session(credential, session_id)
|
|
||||||
|
|
||||||
async def flush_everos() -> dict[str, Any]:
|
|
||||||
return await self.everos.flush(user_id, session_id)
|
|
||||||
|
|
||||||
backends = await self._run_backends(openviking=commit_openviking, everos=flush_everos)
|
|
||||||
return CommitResponse(status=self._aggregate_status(backends), backends=backends)
|
|
||||||
|
|
||||||
async def extract_session(self, user_id: str, user_key: str, session_id: str) -> ExtractResponse:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key, agent_id=session_id)
|
|
||||||
backends = {
|
|
||||||
"openviking": await self._capture(lambda: self.openviking.extract_session(credential, session_id)),
|
|
||||||
}
|
|
||||||
return ExtractResponse(status=self._aggregate_status(backends), backends=backends)
|
|
||||||
|
|
||||||
async def get_openviking_task(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
user_key: str,
|
|
||||||
task_id: str,
|
|
||||||
session_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key, agent_id=session_id)
|
|
||||||
return await self.openviking.get_task(credential, task_id)
|
|
||||||
|
|
||||||
async def search(self, request: SearchRequest) -> SearchResponse:
|
|
||||||
credential = self.openviking.credential_for_user(
|
|
||||||
request.user_id,
|
|
||||||
request.user_key,
|
|
||||||
agent_id=request.session_id,
|
|
||||||
)
|
|
||||||
everos_method = "agentic" if request.use_llm else "hybrid"
|
|
||||||
|
|
||||||
async def search_openviking() -> dict[str, Any]:
|
|
||||||
return await self.openviking.search(
|
|
||||||
credential,
|
|
||||||
request.query,
|
|
||||||
request.limit,
|
|
||||||
request.level,
|
|
||||||
request.score_threshold,
|
|
||||||
request.target_uri,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def search_everos() -> dict[str, Any]:
|
|
||||||
return await self.everos.search(
|
|
||||||
request.user_id,
|
|
||||||
request.session_id,
|
|
||||||
request.query,
|
|
||||||
everos_method,
|
|
||||||
request.limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
backends = await self._run_backends(openviking=search_openviking, everos=search_everos)
|
|
||||||
backends = self._remove_vectors_from_backends(backends)
|
|
||||||
items = self._merge_search_items(backends)
|
|
||||||
compact_backends = self._compact_search_backends(backends)
|
|
||||||
return SearchResponse(
|
|
||||||
status=self._aggregate_status(backends),
|
|
||||||
items=items[: request.limit],
|
|
||||||
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,
|
|
||||||
user_key: str,
|
|
||||||
query: str = "用户画像",
|
|
||||||
limit: int = 10,
|
|
||||||
level: int = 2,
|
|
||||||
) -> ProfileResponse:
|
|
||||||
credential = self.openviking.credential_for_user(user_id, user_key)
|
|
||||||
backends = await self._run_backends(
|
|
||||||
everos=lambda: self.everos.get_profile(user_id),
|
|
||||||
openviking=lambda: self.openviking.search_profile_memories(credential, query, limit, level),
|
|
||||||
)
|
|
||||||
backends = self._remove_vectors_from_backends(backends)
|
|
||||||
profile = backends["everos"].result if backends["everos"].status == "success" else None
|
|
||||||
items = (
|
|
||||||
self._items_from_backend_result("openviking", backends["openviking"].result)[:limit]
|
|
||||||
if backends["openviking"].status == "success"
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
return ProfileResponse(
|
|
||||||
status=self._aggregate_status(backends),
|
|
||||||
profile=profile,
|
|
||||||
items=items,
|
|
||||||
backends=self._compact_profile_backends(backends),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def health(self) -> dict[str, Any]:
|
|
||||||
backends = await self._run_backends(openviking=self.openviking.health, everos=self.everos.health)
|
|
||||||
return {"status": self._aggregate_status(backends), "backends": backends}
|
|
||||||
|
|
||||||
def _messages_from_request(self, request: MessageIngestRequest) -> list[dict[str, str]]:
|
|
||||||
messages = []
|
|
||||||
if request.user_message:
|
|
||||||
messages.append({"role": "user", "content": request.user_message})
|
|
||||||
if request.assistant_message:
|
|
||||||
messages.append({"role": "assistant", "content": request.assistant_message})
|
|
||||||
return messages
|
|
||||||
|
|
||||||
def _is_remote_url(self, path: str) -> bool:
|
|
||||||
return urlparse(path).scheme in {"http", "https"}
|
|
||||||
|
|
||||||
def _temp_file_id_from_result(self, result: Any) -> str:
|
|
||||||
data = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result
|
|
||||||
temp_file_id = data.get("temp_file_id") if isinstance(data, dict) else None
|
|
||||||
if not temp_file_id:
|
|
||||||
raise ValueError("OpenViking temp upload response missing temp_file_id")
|
|
||||||
return str(temp_file_id)
|
|
||||||
|
|
||||||
async def _run_backends(self, **calls: Callable[[], Awaitable[Any]]) -> dict[str, BackendStatus]:
|
|
||||||
names = list(calls)
|
|
||||||
results = await asyncio.gather(*(self._capture(calls[name]) for name in names))
|
|
||||||
return dict(zip(names, results))
|
|
||||||
|
|
||||||
async def _capture(self, call: Callable[[], Awaitable[Any]]) -> BackendStatus:
|
|
||||||
try:
|
|
||||||
return BackendStatus(status="success", result=await call())
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
message = str(exc)
|
|
||||||
error = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
|
||||||
return BackendStatus(status="failed", error=error)
|
|
||||||
|
|
||||||
def _aggregate_status(self, backends: dict[str, BackendStatus]) -> str:
|
|
||||||
statuses = {backend.status for backend in backends.values()}
|
|
||||||
if statuses == {"success"}:
|
|
||||||
return "success"
|
|
||||||
if "success" in statuses:
|
|
||||||
return "partial_success"
|
|
||||||
return "failed"
|
|
||||||
|
|
||||||
def _merge_search_items(self, backends: dict[str, BackendStatus]) -> list[dict[str, Any]]:
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for backend_name, backend in backends.items():
|
|
||||||
if backend.status != "success":
|
|
||||||
continue
|
|
||||||
items.extend(self._items_from_backend_result(backend_name, backend.result))
|
|
||||||
return items
|
|
||||||
|
|
||||||
def _items_from_backend_result(self, backend_name: str, result: Any) -> list[dict[str, Any]]:
|
|
||||||
if isinstance(result, dict) and isinstance(result.get("items"), list):
|
|
||||||
return [self._with_backend(backend_name, item) for item in result["items"] if isinstance(item, dict)]
|
|
||||||
data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else result
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return []
|
|
||||||
if isinstance(data.get("result"), dict):
|
|
||||||
data = data["result"]
|
|
||||||
raw_items: list[dict[str, Any]] = []
|
|
||||||
for key in ("memories", "resources", "episodes", "profiles", "raw_messages"):
|
|
||||||
values = data.get(key)
|
|
||||||
if isinstance(values, list):
|
|
||||||
raw_items.extend(
|
|
||||||
self._compact_search_item(backend_name, key, item)
|
|
||||||
for item in values
|
|
||||||
if isinstance(item, dict)
|
|
||||||
)
|
|
||||||
return [self._with_backend(backend_name, item) for item in raw_items]
|
|
||||||
|
|
||||||
def _with_backend(self, backend_name: str, item: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
if "source_backend" in item:
|
|
||||||
return item
|
|
||||||
return {"source_backend": backend_name, **item}
|
|
||||||
|
|
||||||
def _compact_search_item(self, backend_name: str, collection: str, item: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
if backend_name == "everos":
|
|
||||||
fields = (
|
|
||||||
"id",
|
|
||||||
"user_id",
|
|
||||||
"session_id",
|
|
||||||
"timestamp",
|
|
||||||
"summary",
|
|
||||||
"score",
|
|
||||||
)
|
|
||||||
compact = {"memory_type": self._singular_memory_type(collection)}
|
|
||||||
compact.update({field: item[field] for field in fields if field in item and item[field] is not None})
|
|
||||||
return compact
|
|
||||||
return item
|
|
||||||
|
|
||||||
def _singular_memory_type(self, collection: str) -> str:
|
|
||||||
names = {
|
|
||||||
"memories": "memory",
|
|
||||||
"resources": "resource",
|
|
||||||
"episodes": "episode",
|
|
||||||
"profiles": "profile",
|
|
||||||
"raw_messages": "raw_message",
|
|
||||||
}
|
|
||||||
return names.get(collection, collection)
|
|
||||||
|
|
||||||
def _compact_search_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]:
|
|
||||||
return {
|
|
||||||
name: backend.model_copy(update={"result": self._compact_backend_result(name, backend.result)})
|
|
||||||
for name, backend in backends.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def _compact_backend_result(self, backend_name: str, result: Any) -> Any:
|
|
||||||
if backend_name == "everos":
|
|
||||||
data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else result
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return result
|
|
||||||
compact: dict[str, Any] = {
|
|
||||||
"counts": {
|
|
||||||
key: len(data.get(key) or [])
|
|
||||||
for key in ("episodes", "profiles", "raw_messages")
|
|
||||||
if isinstance(data.get(key), list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if "query" in data:
|
|
||||||
compact["query"] = data["query"]
|
|
||||||
return compact
|
|
||||||
|
|
||||||
if backend_name == "openviking":
|
|
||||||
data = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return result
|
|
||||||
compact = {
|
|
||||||
"status": result.get("status") if isinstance(result, dict) else None,
|
|
||||||
"total": data.get("total"),
|
|
||||||
"counts": {
|
|
||||||
key: len(data.get(key) or [])
|
|
||||||
for key in ("memories", "resources", "skills")
|
|
||||||
if isinstance(data.get(key), list)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if "query_plan" in data:
|
|
||||||
compact["query_plan"] = data["query_plan"]
|
|
||||||
return {key: value for key, value in compact.items() if value is not None}
|
|
||||||
|
|
||||||
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 _compact_profile_backends(self, backends: dict[str, BackendStatus]) -> dict[str, BackendStatus]:
|
|
||||||
return {
|
|
||||||
name: backend.model_copy(update={"result": self._compact_profile_backend_result(name, backend.result)})
|
|
||||||
for name, backend in backends.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def _compact_profile_backend_result(self, backend_name: str, result: Any) -> Any:
|
|
||||||
if backend_name == "openviking":
|
|
||||||
return self._compact_backend_result("openviking", result)
|
|
||||||
if backend_name == "everos":
|
|
||||||
data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else result
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return result
|
|
||||||
compact: dict[str, Any] = {}
|
|
||||||
for key in ("total_count", "count"):
|
|
||||||
if key in data:
|
|
||||||
compact[key] = data[key]
|
|
||||||
compact["counts"] = {
|
|
||||||
key: len(data.get(key) or [])
|
|
||||||
for key in ("episodes", "profiles", "agent_cases", "agent_skills")
|
|
||||||
if isinstance(data.get(key), list)
|
|
||||||
}
|
|
||||||
return compact
|
|
||||||
return 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)})
|
|
||||||
for name, backend in backends.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def _remove_vectors(self, value: Any) -> Any:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {key: self._remove_vectors(item) for key, item in value.items() if key != "vector"}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [self._remove_vectors(item) for item in value]
|
|
||||||
return value
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
"""Small SQLite store for OpenViking user keys."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import hmac
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ADMIN_ACCOUNT_ID = "admin"
|
|
||||||
ADMIN_USER_ID = "admin"
|
|
||||||
|
|
||||||
|
|
||||||
class OpenVikingUserKeyStore:
|
|
||||||
def __init__(self, sqlite_path: str) -> None:
|
|
||||||
self.sqlite_path = sqlite_path
|
|
||||||
self._ensure_table()
|
|
||||||
|
|
||||||
def get_account_key(self, account_id: str) -> str | None:
|
|
||||||
with self._connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT account_key FROM memory_system_openviking_accounts WHERE account_id = ?",
|
|
||||||
(account_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT user_key FROM memory_system_openviking_users
|
|
||||||
WHERE account_id = ?
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(account_id,),
|
|
||||||
).fetchone()
|
|
||||||
return str(row[0]) if row else None
|
|
||||||
|
|
||||||
def account_key_matches(self, account_id: str, account_key: str) -> bool:
|
|
||||||
expected = self.get_account_key(account_id)
|
|
||||||
return bool(expected and hmac.compare_digest(expected, account_key))
|
|
||||||
|
|
||||||
def save_account_key(self, account_id: str, admin_user_id: str, account_key: str) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO memory_system_openviking_accounts (account_id, admin_user_id, account_key, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(account_id) DO UPDATE SET
|
|
||||||
admin_user_id = excluded.admin_user_id,
|
|
||||||
account_key = excluded.account_key,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(account_id, admin_user_id, account_key, now, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_user_key(self, user_id: str) -> str | None:
|
|
||||||
with self._connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT user_key FROM memory_system_openviking_users WHERE user_id = ?",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT user_key FROM memory_system_openviking_users WHERE user_id = ?",
|
|
||||||
(self._legacy_store_key(ADMIN_ACCOUNT_ID, user_id),),
|
|
||||||
).fetchone()
|
|
||||||
return str(row[0]) if row else None
|
|
||||||
|
|
||||||
def save_user_key(self, user_id: str, user_key: str, account_id: str = ADMIN_ACCOUNT_ID) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO memory_system_openviking_users (user_id, account_id, user_key, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
|
||||||
user_key = excluded.user_key,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(user_id, account_id, user_key, now, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def user_key_matches(self, user_id: str, user_key: str) -> bool:
|
|
||||||
expected = self.get_user_key(user_id)
|
|
||||||
return bool(expected and hmac.compare_digest(expected, user_key))
|
|
||||||
|
|
||||||
def save_session(self, user_id: str, session_id: str) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO memory_system_openviking_sessions
|
|
||||||
(user_id, session_id, latest_task_id, latest_archive_uri, created_at, updated_at)
|
|
||||||
VALUES (?, ?, NULL, NULL, ?, ?)
|
|
||||||
ON CONFLICT(user_id, session_id) DO UPDATE SET
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(user_id, session_id, now, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_session(self, user_id: str, session_id: str) -> dict[str, str | None] | None:
|
|
||||||
with self._connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT user_id, session_id, latest_task_id, latest_archive_uri
|
|
||||||
FROM memory_system_openviking_sessions
|
|
||||||
WHERE user_id = ? AND session_id = ?
|
|
||||||
""",
|
|
||||||
(user_id, session_id),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"user_id": str(row[0]),
|
|
||||||
"session_id": str(row[1]),
|
|
||||||
"latest_task_id": str(row[2]) if row[2] is not None else None,
|
|
||||||
"latest_archive_uri": str(row[3]) if row[3] is not None else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_task(self, user_id: str, session_id: str, task_id: str, archive_uri: str | None) -> None:
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO memory_system_openviking_tasks
|
|
||||||
(task_id, user_id, session_id, archive_uri, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(task_id) DO UPDATE SET
|
|
||||||
user_id = excluded.user_id,
|
|
||||||
session_id = excluded.session_id,
|
|
||||||
archive_uri = excluded.archive_uri,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(task_id, user_id, session_id, archive_uri, now, now),
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO memory_system_openviking_sessions
|
|
||||||
(user_id, session_id, latest_task_id, latest_archive_uri, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, session_id) DO UPDATE SET
|
|
||||||
latest_task_id = excluded.latest_task_id,
|
|
||||||
latest_archive_uri = excluded.latest_archive_uri,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(user_id, session_id, task_id, archive_uri, now, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> dict[str, str | None] | None:
|
|
||||||
with self._connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT task_id, user_id, session_id, archive_uri
|
|
||||||
FROM memory_system_openviking_tasks
|
|
||||||
WHERE task_id = ?
|
|
||||||
""",
|
|
||||||
(task_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"task_id": str(row[0]),
|
|
||||||
"user_id": str(row[1]),
|
|
||||||
"session_id": str(row[2]),
|
|
||||||
"archive_uri": str(row[3]) if row[3] is not None else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _ensure_table(self) -> None:
|
|
||||||
path = Path(self.sqlite_path)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_accounts (
|
|
||||||
account_id TEXT PRIMARY KEY,
|
|
||||||
admin_user_id TEXT NOT NULL,
|
|
||||||
account_key TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_users (
|
|
||||||
user_id TEXT PRIMARY KEY,
|
|
||||||
account_id TEXT NOT NULL,
|
|
||||||
user_key TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_sessions (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
latest_task_id TEXT,
|
|
||||||
latest_archive_uri TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, session_id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS memory_system_openviking_tasks (
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
session_id TEXT NOT NULL,
|
|
||||||
archive_uri TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
return sqlite3.connect(self.sqlite_path)
|
|
||||||
|
|
||||||
def _legacy_store_key(self, account_id: str, user_id: str) -> str:
|
|
||||||
return f"{account_id}:{user_id}"
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"server": {
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 1933,
|
|
||||||
"auth_mode": "api_key",
|
|
||||||
"root_api_key": "<OPENVIKING_ROOT_KEY>",
|
|
||||||
"cors_origins": ["*"]
|
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"workspace": "/Users/tom/projects/openviking_workspace",
|
|
||||||
"agfs": {
|
|
||||||
"backend": "local"
|
|
||||||
},
|
|
||||||
"vectordb": {
|
|
||||||
"name": "context",
|
|
||||||
"backend": "local"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"memory": {
|
|
||||||
"version": "v2",
|
|
||||||
"agent_scope_mode": "user+agent"
|
|
||||||
},
|
|
||||||
"embedding": {
|
|
||||||
"dense": {
|
|
||||||
"provider": "<EMBEDDING_PROVIDER>",
|
|
||||||
"api_base": "<EMBEDDING_API_BASE>",
|
|
||||||
"api_key": "<EMBEDDING_API_KEY>",
|
|
||||||
"model": "<EMBEDDING_MODEL>",
|
|
||||||
"dimension": 1024
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"vlm": {
|
|
||||||
"provider": "<VLM_PROVIDER>",
|
|
||||||
"api_base": "<VLM_API_BASE>",
|
|
||||||
"api_key": "<VLM_API_KEY>",
|
|
||||||
"model": "<VLM_MODEL>"
|
|
||||||
},
|
|
||||||
"rerank": {
|
|
||||||
"provider": "<RERANK_PROVIDER>",
|
|
||||||
"api_base": "<RERANK_API_BASE>",
|
|
||||||
"api_key": "<RERANK_API_KEY>",
|
|
||||||
"model": "<RERANK_MODEL>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# Hermes Memory System Plugin
|
|
||||||
|
|
||||||
This Hermes memory provider talks to the Memory System API instead of calling OpenViking or EverOS directly.
|
|
||||||
|
|
||||||
It stores completed Hermes turns through:
|
|
||||||
|
|
||||||
- `POST /memory-system/messages`
|
|
||||||
- `POST /memory-system/sessions/{session_id}/commit` on session end
|
|
||||||
- `POST /memory-system/search` for recall
|
|
||||||
- `GET /memory-system/users/{user_id}/profile` for user profile reads
|
|
||||||
- `GET/POST/DELETE /memory-system/memories` for direct memory URI management
|
|
||||||
|
|
||||||
## Configure
|
|
||||||
|
|
||||||
Put these values in the Hermes profile env file, usually `~/.hermes/.env`:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934
|
|
||||||
MEMORY_SYSTEM_USER_ID=default
|
|
||||||
MEMORY_SYSTEM_USER_KEY=
|
|
||||||
MEMORY_SYSTEM_API_KEY=
|
|
||||||
MEMORY_SYSTEM_SEARCH_USE_LLM=false
|
|
||||||
MEMORY_SYSTEM_COMMIT_EVERY_TURNS=5
|
|
||||||
MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=300
|
|
||||||
MEMORY_SYSTEM_TIMEOUT_SECONDS=180
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also keep a separate file and point to it with `MEMORY_SYSTEM_ENV_FILE`.
|
|
||||||
Real environment variables still override file values.
|
|
||||||
|
|
||||||
`MEMORY_SYSTEM_USER_KEY` is the key returned by `POST /memory-system/users`.
|
|
||||||
If it is omitted, the plugin calls `/memory-system/users` during initialization and uses the returned key for the current process.
|
|
||||||
|
|
||||||
Then select this provider in Hermes memory config:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
memory:
|
|
||||||
provider: memory_system
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
- `memory_system_search`: search OpenViking and EverOS via Memory System API.
|
|
||||||
- `memory_system_profile`: read the EverOS profile memory for the active user.
|
|
||||||
- `memory_system_remember`: explicitly write an important memory and commit the session.
|
|
||||||
- `memory_system_memory_list`: list OpenViking memory URIs under `viking://user/memories`.
|
|
||||||
- `memory_system_memory_read`: read one memory URI.
|
|
||||||
- `memory_system_memory_write`: create, replace, or append one memory URI.
|
|
||||||
- `memory_system_memory_delete`: delete one memory URI, non-recursive by default.
|
|
||||||
|
|
||||||
Use the direct memory URI tools only when the model needs to inspect or edit a specific `viking://user/memories/...` item. Normal conversation recall should still use `memory_system_search`, and normal explicit remembering should still use `memory_system_remember`.
|
|
||||||
|
|
||||||
The plugin commits after 5 new turns or 300 seconds by default, whichever comes first.
|
|
||||||
Set either value to `0` to disable that trigger. Session end still commits any new turns that were not already committed.
|
|
||||||
|
|
||||||
`MEMORY_SYSTEM_TIMEOUT_SECONDS` should be long enough for commit/search calls that wait on EverOS LLM extraction or rerank services. The default is 180 seconds.
|
|
||||||
|
|
||||||
If commit returns `partial_success`, the plugin logs the response and does not mark the pending turns as committed, so a later periodic commit or session-end commit can retry EverOS flush.
|
|
||||||
|
|
||||||
Search responses from current Memory System API versions do not include raw `vector` fields. The API strips those large embedding arrays before returning merged results or backend debug payloads.
|
|
||||||
|
|
||||||
The plugin is intentionally thin. User identity, session identity, backend writes, OpenViking commit, and EverOS flush stay owned by Memory System API.
|
|
||||||
@ -1,827 +0,0 @@
|
|||||||
"""Hermes memory provider for Memory System API.
|
|
||||||
|
|
||||||
Memory System API wraps OpenViking session memory and EverOS user profile
|
|
||||||
memory behind one small HTTP surface. This plugin keeps Hermes integration
|
|
||||||
thin: completed turns are written to the API, session end triggers commit,
|
|
||||||
and tools expose search/profile/explicit remember operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from agent.memory_provider import MemoryProvider
|
|
||||||
from tools.registry import tool_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_DEFAULT_ENDPOINT = "http://127.0.0.1:1934"
|
|
||||||
_DEFAULT_TIMEOUT = 180.0
|
|
||||||
_CONFIG_KEYS = {
|
|
||||||
"MEMORY_SYSTEM_ENDPOINT",
|
|
||||||
"MEMORY_SYSTEM_API_KEY",
|
|
||||||
"MEMORY_SYSTEM_USER_ID",
|
|
||||||
"MEMORY_SYSTEM_USER_KEY",
|
|
||||||
"MEMORY_SYSTEM_SEARCH_USE_LLM",
|
|
||||||
"MEMORY_SYSTEM_COMMIT_EVERY_TURNS",
|
|
||||||
"MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS",
|
|
||||||
"MEMORY_SYSTEM_TIMEOUT_SECONDS",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_httpx():
|
|
||||||
try:
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
return httpx
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool = False) -> bool:
|
|
||||||
return _bool_value(_memory_system_config().get(name), default)
|
|
||||||
|
|
||||||
|
|
||||||
def _bool_value(value: Optional[str], default: bool = False) -> bool:
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _env_int(name: str, default: int) -> int:
|
|
||||||
return _int_value(_memory_system_config().get(name), default)
|
|
||||||
|
|
||||||
|
|
||||||
def _int_value(value: Optional[str], default: int) -> int:
|
|
||||||
if value is None or value.strip() == "":
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _env_float(name: str, default: float) -> float:
|
|
||||||
return _float_value(_memory_system_config().get(name), default)
|
|
||||||
|
|
||||||
|
|
||||||
def _float_value(value: Optional[str], default: float) -> float:
|
|
||||||
if value is None or value.strip() == "":
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_env_file(path: Path) -> Dict[str, str]:
|
|
||||||
values: Dict[str, str] = {}
|
|
||||||
try:
|
|
||||||
lines = path.read_text(encoding="utf-8").splitlines()
|
|
||||||
except OSError:
|
|
||||||
return values
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
continue
|
|
||||||
if stripped.startswith("export "):
|
|
||||||
stripped = stripped[len("export ") :].strip()
|
|
||||||
if "=" not in stripped:
|
|
||||||
continue
|
|
||||||
key, value = stripped.split("=", 1)
|
|
||||||
key = key.strip()
|
|
||||||
if key not in _CONFIG_KEYS:
|
|
||||||
continue
|
|
||||||
value = value.strip().strip('"').strip("'")
|
|
||||||
values[key] = value
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _memory_system_config(hermes_home: str = "") -> Dict[str, str]:
|
|
||||||
candidates: List[Path] = []
|
|
||||||
candidates.extend(
|
|
||||||
[
|
|
||||||
Path.cwd() / ".env",
|
|
||||||
Path.cwd() / "memory_system.env",
|
|
||||||
Path.home() / ".hermes" / ".env",
|
|
||||||
Path.home() / ".hermes" / "memory_system.env",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if hermes_home:
|
|
||||||
candidates.append(Path(hermes_home).expanduser() / ".env")
|
|
||||||
candidates.append(Path(hermes_home).expanduser() / "memory_system.env")
|
|
||||||
env_hermes_home = os.environ.get("HERMES_HOME", "")
|
|
||||||
if env_hermes_home:
|
|
||||||
candidates.append(Path(env_hermes_home).expanduser() / ".env")
|
|
||||||
candidates.append(Path(env_hermes_home).expanduser() / "memory_system.env")
|
|
||||||
explicit_file = os.environ.get("MEMORY_SYSTEM_ENV_FILE", "")
|
|
||||||
if explicit_file:
|
|
||||||
candidates.append(Path(explicit_file).expanduser())
|
|
||||||
|
|
||||||
config: Dict[str, str] = {}
|
|
||||||
seen: set[Path] = set()
|
|
||||||
for path in candidates:
|
|
||||||
resolved = path.expanduser()
|
|
||||||
if resolved in seen:
|
|
||||||
continue
|
|
||||||
seen.add(resolved)
|
|
||||||
config.update(_parse_env_file(resolved))
|
|
||||||
|
|
||||||
for key in _CONFIG_KEYS:
|
|
||||||
if key in os.environ:
|
|
||||||
config[key] = os.environ[key]
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
class _MemorySystemClient:
|
|
||||||
"""Small sync HTTP client for Memory System API."""
|
|
||||||
|
|
||||||
def __init__(self, endpoint: str, api_key: str = "", timeout: float = _DEFAULT_TIMEOUT):
|
|
||||||
self._endpoint = endpoint.rstrip("/")
|
|
||||||
self._api_key = api_key
|
|
||||||
self._timeout = timeout
|
|
||||||
self._httpx = _get_httpx()
|
|
||||||
if self._httpx is None:
|
|
||||||
raise ImportError("httpx is required for memory_system: pip install httpx")
|
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
if self._api_key:
|
|
||||||
headers["X-API-Key"] = self._api_key
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def _url(self, path: str) -> str:
|
|
||||||
return f"{self._endpoint}{path}"
|
|
||||||
|
|
||||||
def get(self, path: str) -> Dict[str, Any]:
|
|
||||||
response = self._httpx.get(self._url(path), headers=self._headers(), timeout=self._timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def delete(self, path: str) -> Dict[str, Any]:
|
|
||||||
response = self._httpx.delete(self._url(path), headers=self._headers(), timeout=self._timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
||||||
response = self._httpx.post(
|
|
||||||
self._url(path),
|
|
||||||
json=payload or {},
|
|
||||||
headers=self._headers(),
|
|
||||||
timeout=self._timeout,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def health(self) -> bool:
|
|
||||||
try:
|
|
||||||
response = self._httpx.get(
|
|
||||||
self._url("/memory-system/health"),
|
|
||||||
headers=self._headers(),
|
|
||||||
timeout=3.0,
|
|
||||||
)
|
|
||||||
return response.status_code == 200
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
SEARCH_SCHEMA = {
|
|
||||||
"name": "memory_system_search",
|
|
||||||
"description": (
|
|
||||||
"Search persistent memory through Memory System API. "
|
|
||||||
"By default this uses fast hybrid search; set use_llm=true for agentic search."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"query": {"type": "string", "description": "Search query."},
|
|
||||||
"use_llm": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Use agentic LLM search instead of hybrid search.",
|
|
||||||
},
|
|
||||||
"limit": {"type": "integer", "description": "Maximum results, default 10."},
|
|
||||||
"session_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Optional session override. Defaults to the active Hermes session.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["query"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
PROFILE_SCHEMA = {
|
|
||||||
"name": "memory_system_profile",
|
|
||||||
"description": "Read the current user's profile memory from Memory System API.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"user_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Optional user override. Defaults to the active Hermes user.",
|
|
||||||
},
|
|
||||||
"user_key": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Required only when user_id overrides the active Hermes user.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
REMEMBER_SCHEMA = {
|
|
||||||
"name": "memory_system_remember",
|
|
||||||
"description": (
|
|
||||||
"Store an important memory through Memory System API and commit the active session. "
|
|
||||||
"Use this when information should be remembered beyond the current conversation."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"content": {"type": "string", "description": "Memory text to store."},
|
|
||||||
"session_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Optional session override. Defaults to the active Hermes session.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["content"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
MEMORY_LIST_SCHEMA = {
|
|
||||||
"name": "memory_system_memory_list",
|
|
||||||
"description": "List OpenViking memory URIs through Memory System API.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"uri": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Memory root URI. Defaults to viking://user/memories.",
|
|
||||||
},
|
|
||||||
"recursive": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Whether to list recursively. Defaults to true.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
MEMORY_READ_SCHEMA = {
|
|
||||||
"name": "memory_system_memory_read",
|
|
||||||
"description": "Read one OpenViking memory URI through Memory System API.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"uri": {"type": "string", "description": "Memory URI to read."},
|
|
||||||
},
|
|
||||||
"required": ["uri"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
MEMORY_WRITE_SCHEMA = {
|
|
||||||
"name": "memory_system_memory_write",
|
|
||||||
"description": "Create, replace, or append an OpenViking memory URI through Memory System API.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"uri": {"type": "string", "description": "Target memory URI."},
|
|
||||||
"content": {"type": "string", "description": "Memory content to write."},
|
|
||||||
"mode": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["create", "replace", "append"],
|
|
||||||
"description": "Write mode. Use replace to modify an existing memory.",
|
|
||||||
},
|
|
||||||
"wait": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Wait for Memory System processing/indexing. Defaults to true.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["uri", "content"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
MEMORY_DELETE_SCHEMA = {
|
|
||||||
"name": "memory_system_memory_delete",
|
|
||||||
"description": "Delete one OpenViking memory URI through Memory System API.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"uri": {"type": "string", "description": "Memory URI to delete."},
|
|
||||||
"recursive": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Delete recursively. Defaults to false; use true only for directory-like memories.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["uri"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MemorySystemMemoryProvider(MemoryProvider):
|
|
||||||
"""Hermes MemoryProvider backed by Memory System API."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "memory_system"
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
config = _memory_system_config()
|
|
||||||
self._endpoint = config.get("MEMORY_SYSTEM_ENDPOINT", _DEFAULT_ENDPOINT)
|
|
||||||
self._api_key = config.get("MEMORY_SYSTEM_API_KEY", "")
|
|
||||||
self._user_id = config.get("MEMORY_SYSTEM_USER_ID", "default")
|
|
||||||
self._user_key = config.get("MEMORY_SYSTEM_USER_KEY", "")
|
|
||||||
self._session_id = ""
|
|
||||||
self._client: Optional[_MemorySystemClient] = None
|
|
||||||
self._sync_thread: Optional[threading.Thread] = None
|
|
||||||
self._prefetch_thread: Optional[threading.Thread] = None
|
|
||||||
self._prefetch_lock = threading.Lock()
|
|
||||||
self._prefetch_result = ""
|
|
||||||
self._turn_count = 0
|
|
||||||
self._last_commit_turn = 0
|
|
||||||
self._last_commit_time = time.monotonic()
|
|
||||||
self._commit_every_turns = _env_int("MEMORY_SYSTEM_COMMIT_EVERY_TURNS", 5)
|
|
||||||
self._commit_interval_seconds = _env_int("MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS", 300)
|
|
||||||
self._timeout = _env_float("MEMORY_SYSTEM_TIMEOUT_SECONDS", _DEFAULT_TIMEOUT)
|
|
||||||
self._default_use_llm = _env_bool("MEMORY_SYSTEM_SEARCH_USE_LLM", False)
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
return bool(_memory_system_config().get("MEMORY_SYSTEM_ENDPOINT")) and _get_httpx() is not None
|
|
||||||
|
|
||||||
def get_config_schema(self) -> List[Dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"key": "endpoint",
|
|
||||||
"description": "Memory System API endpoint.",
|
|
||||||
"required": True,
|
|
||||||
"default": _DEFAULT_ENDPOINT,
|
|
||||||
"env_var": "MEMORY_SYSTEM_ENDPOINT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "api_key",
|
|
||||||
"description": "Memory System API key, if server.api_key is configured.",
|
|
||||||
"secret": True,
|
|
||||||
"required": False,
|
|
||||||
"env_var": "MEMORY_SYSTEM_API_KEY",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "user_id",
|
|
||||||
"description": "Default Memory System user id for this Hermes profile.",
|
|
||||||
"required": False,
|
|
||||||
"default": "default",
|
|
||||||
"env_var": "MEMORY_SYSTEM_USER_ID",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "user_key",
|
|
||||||
"description": (
|
|
||||||
"Memory System user key returned by /memory-system/users. "
|
|
||||||
"If omitted, the plugin creates or looks up the configured user on initialization."
|
|
||||||
),
|
|
||||||
"secret": True,
|
|
||||||
"required": False,
|
|
||||||
"env_var": "MEMORY_SYSTEM_USER_KEY",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "commit_every_turns",
|
|
||||||
"description": "Commit after this many new turns. Set 0 to disable turn-based commits.",
|
|
||||||
"required": False,
|
|
||||||
"default": 5,
|
|
||||||
"env_var": "MEMORY_SYSTEM_COMMIT_EVERY_TURNS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "commit_interval_seconds",
|
|
||||||
"description": "Commit after this many seconds if new turns exist. Set 0 to disable time-based commits.",
|
|
||||||
"required": False,
|
|
||||||
"default": 300,
|
|
||||||
"env_var": "MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "timeout_seconds",
|
|
||||||
"description": "HTTP timeout for Memory System API requests. Commit may wait for EverOS LLM extraction.",
|
|
||||||
"required": False,
|
|
||||||
"default": _DEFAULT_TIMEOUT,
|
|
||||||
"env_var": "MEMORY_SYSTEM_TIMEOUT_SECONDS",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
def initialize(self, session_id: str, **kwargs) -> None:
|
|
||||||
config = _memory_system_config(str(kwargs.get("hermes_home") or ""))
|
|
||||||
self._endpoint = config.get("MEMORY_SYSTEM_ENDPOINT", _DEFAULT_ENDPOINT)
|
|
||||||
self._api_key = config.get("MEMORY_SYSTEM_API_KEY", "")
|
|
||||||
self._user_id = (
|
|
||||||
config.get("MEMORY_SYSTEM_USER_ID")
|
|
||||||
or kwargs.get("user_id")
|
|
||||||
or kwargs.get("agent_identity")
|
|
||||||
or "default"
|
|
||||||
)
|
|
||||||
self._user_key = config.get("MEMORY_SYSTEM_USER_KEY") or kwargs.get("user_key") or ""
|
|
||||||
self._session_id = session_id
|
|
||||||
self._default_use_llm = _bool_value(config.get("MEMORY_SYSTEM_SEARCH_USE_LLM"), False)
|
|
||||||
self._commit_every_turns = _int_value(config.get("MEMORY_SYSTEM_COMMIT_EVERY_TURNS"), 5)
|
|
||||||
self._commit_interval_seconds = _int_value(
|
|
||||||
config.get("MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS"), 300
|
|
||||||
)
|
|
||||||
self._timeout = _float_value(config.get("MEMORY_SYSTEM_TIMEOUT_SECONDS"), _DEFAULT_TIMEOUT)
|
|
||||||
self._last_commit_turn = 0
|
|
||||||
self._last_commit_time = time.monotonic()
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = _MemorySystemClient(self._endpoint, self._api_key, self._timeout)
|
|
||||||
if not client.health():
|
|
||||||
logger.warning("Memory System API health check failed: %s", self._endpoint)
|
|
||||||
return
|
|
||||||
self._client = client
|
|
||||||
self._ensure_user_key()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Memory System API initialization failed: %s", exc)
|
|
||||||
self._client = None
|
|
||||||
|
|
||||||
def system_prompt_block(self) -> str:
|
|
||||||
if not self._client:
|
|
||||||
return ""
|
|
||||||
return (
|
|
||||||
"# Memory System\n"
|
|
||||||
"Persistent memory is active. Use memory_system_search for recall, "
|
|
||||||
"memory_system_profile for user profile, and memory_system_remember "
|
|
||||||
"for important information that should be stored. Use memory_system_memory_* "
|
|
||||||
"tools only when you need to list, read, edit, or delete specific memory URIs."
|
|
||||||
)
|
|
||||||
|
|
||||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
||||||
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
|
||||||
self._prefetch_thread.join(timeout=3.0)
|
|
||||||
with self._prefetch_lock:
|
|
||||||
result = self._prefetch_result
|
|
||||||
self._prefetch_result = ""
|
|
||||||
if not result:
|
|
||||||
return ""
|
|
||||||
return f"## Memory System Context\n{result}"
|
|
||||||
|
|
||||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
|
||||||
if not self._client or not query:
|
|
||||||
return
|
|
||||||
|
|
||||||
def _run() -> None:
|
|
||||||
try:
|
|
||||||
response = self._client.post(
|
|
||||||
"/memory-system/search",
|
|
||||||
{
|
|
||||||
"user_id": self._user_id,
|
|
||||||
"user_key": self._user_key,
|
|
||||||
"session_id": session_id or self._session_id,
|
|
||||||
"query": query,
|
|
||||||
"use_llm": self._default_use_llm,
|
|
||||||
"limit": 5,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
formatted = self._format_items(response.get("items", []), limit=5)
|
|
||||||
if formatted:
|
|
||||||
with self._prefetch_lock:
|
|
||||||
self._prefetch_result = formatted
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Memory System prefetch failed: %s", exc)
|
|
||||||
|
|
||||||
self._prefetch_thread = threading.Thread(
|
|
||||||
target=_run, daemon=True, name="memory-system-prefetch"
|
|
||||||
)
|
|
||||||
self._prefetch_thread.start()
|
|
||||||
|
|
||||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
|
||||||
if not self._client:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._turn_count += 1
|
|
||||||
|
|
||||||
def _sync() -> None:
|
|
||||||
try:
|
|
||||||
payload = self._message_payload(
|
|
||||||
session_id=session_id or self._session_id,
|
|
||||||
user_message=user_content,
|
|
||||||
assistant_message=assistant_content,
|
|
||||||
)
|
|
||||||
self._client.post("/memory-system/messages", payload)
|
|
||||||
if self._should_commit_now():
|
|
||||||
self._commit_session(session_id or self._session_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Memory System sync_turn failed: %s", exc)
|
|
||||||
|
|
||||||
if self._sync_thread and self._sync_thread.is_alive():
|
|
||||||
self._sync_thread.join(timeout=5.0)
|
|
||||||
|
|
||||||
self._sync_thread = threading.Thread(target=_sync, daemon=True, name="memory-system-sync")
|
|
||||||
self._sync_thread.start()
|
|
||||||
|
|
||||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
|
||||||
if not self._client:
|
|
||||||
return
|
|
||||||
if self._sync_thread and self._sync_thread.is_alive():
|
|
||||||
self._sync_thread.join(timeout=10.0)
|
|
||||||
if self._turn_count == 0 or self._last_commit_turn >= self._turn_count:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._commit_session(self._session_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Memory System session commit failed: %s", exc)
|
|
||||||
|
|
||||||
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
|
||||||
if action != "add" or not content:
|
|
||||||
return
|
|
||||||
self._remember(content, session_id=self._session_id, commit=False)
|
|
||||||
|
|
||||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
SEARCH_SCHEMA,
|
|
||||||
PROFILE_SCHEMA,
|
|
||||||
REMEMBER_SCHEMA,
|
|
||||||
MEMORY_LIST_SCHEMA,
|
|
||||||
MEMORY_READ_SCHEMA,
|
|
||||||
MEMORY_WRITE_SCHEMA,
|
|
||||||
MEMORY_DELETE_SCHEMA,
|
|
||||||
]
|
|
||||||
|
|
||||||
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
|
||||||
if not self._client:
|
|
||||||
return tool_error("Memory System API is not connected")
|
|
||||||
try:
|
|
||||||
if tool_name == "memory_system_search":
|
|
||||||
return self._tool_search(args)
|
|
||||||
if tool_name == "memory_system_profile":
|
|
||||||
return self._tool_profile(args)
|
|
||||||
if tool_name == "memory_system_remember":
|
|
||||||
return self._tool_remember(args)
|
|
||||||
if tool_name == "memory_system_memory_list":
|
|
||||||
return self._tool_memory_list(args)
|
|
||||||
if tool_name == "memory_system_memory_read":
|
|
||||||
return self._tool_memory_read(args)
|
|
||||||
if tool_name == "memory_system_memory_write":
|
|
||||||
return self._tool_memory_write(args)
|
|
||||||
if tool_name == "memory_system_memory_delete":
|
|
||||||
return self._tool_memory_delete(args)
|
|
||||||
return tool_error(f"Unknown tool: {tool_name}")
|
|
||||||
except Exception as exc:
|
|
||||||
return tool_error(str(exc))
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
for thread in (self._sync_thread, self._prefetch_thread):
|
|
||||||
if thread and thread.is_alive():
|
|
||||||
thread.join(timeout=5.0)
|
|
||||||
|
|
||||||
def _message_payload(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session_id: str,
|
|
||||||
user_message: str = "",
|
|
||||||
assistant_message: str = "",
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
payload: Dict[str, Any] = {
|
|
||||||
"user_id": self._user_id,
|
|
||||||
"user_key": self._user_key,
|
|
||||||
"session_id": session_id,
|
|
||||||
"metadata": {"source": "hermes", "provider": "memory_system"},
|
|
||||||
}
|
|
||||||
if user_message:
|
|
||||||
payload["user_message"] = user_message[:4000]
|
|
||||||
if assistant_message:
|
|
||||||
payload["assistant_message"] = assistant_message[:4000]
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _format_items(self, items: List[Dict[str, Any]], *, limit: int) -> str:
|
|
||||||
parts = []
|
|
||||||
for item in items[:limit]:
|
|
||||||
text = self._memory_text(item)
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
source = item.get("source_backend") or item.get("source") or item.get("backend") or "memory"
|
|
||||||
score = item.get("score")
|
|
||||||
prefix = f"[{source}]"
|
|
||||||
if isinstance(score, (int, float)):
|
|
||||||
prefix = f"{prefix} {score:.2f}"
|
|
||||||
parts.append(f"- {prefix} {text}")
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
def _memory_text(self, item: Dict[str, Any]) -> str:
|
|
||||||
for key in (
|
|
||||||
"memory",
|
|
||||||
"content",
|
|
||||||
"text",
|
|
||||||
"summary",
|
|
||||||
"abstract",
|
|
||||||
"fact",
|
|
||||||
"value",
|
|
||||||
):
|
|
||||||
value = item.get(key)
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
return value.strip()
|
|
||||||
for key in ("memory", "content", "text", "summary"):
|
|
||||||
value = item.get("data", {}).get(key) if isinstance(item.get("data"), dict) else None
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
return value.strip()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _compact_search_response(self, response: Dict[str, Any], *, limit: int) -> Dict[str, Any]:
|
|
||||||
compact_items = []
|
|
||||||
for item in response.get("items", [])[:limit]:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
text = self._memory_text(item)
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
compact: Dict[str, Any] = {
|
|
||||||
"source_backend": item.get("source_backend") or item.get("source") or item.get("backend") or "memory",
|
|
||||||
"text": text[:1200],
|
|
||||||
}
|
|
||||||
memory_type = item.get("memory_type") or item.get("type") or item.get("category")
|
|
||||||
if memory_type:
|
|
||||||
compact["memory_type"] = memory_type
|
|
||||||
score = item.get("score")
|
|
||||||
if isinstance(score, (int, float)):
|
|
||||||
compact["score"] = score
|
|
||||||
uri = item.get("uri")
|
|
||||||
if isinstance(uri, str) and uri.startswith("viking://"):
|
|
||||||
compact["uri"] = uri
|
|
||||||
compact_items.append(compact)
|
|
||||||
|
|
||||||
result: Dict[str, Any] = {
|
|
||||||
"status": response.get("status", "success"),
|
|
||||||
"items": compact_items,
|
|
||||||
}
|
|
||||||
if not compact_items:
|
|
||||||
result["message"] = "No memory items found."
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _should_commit_now(self) -> bool:
|
|
||||||
if self._last_commit_turn >= self._turn_count:
|
|
||||||
return False
|
|
||||||
new_turns = self._turn_count - self._last_commit_turn
|
|
||||||
if self._commit_every_turns > 0 and new_turns >= self._commit_every_turns:
|
|
||||||
return True
|
|
||||||
elapsed = time.monotonic() - self._last_commit_time
|
|
||||||
return self._commit_interval_seconds > 0 and elapsed >= self._commit_interval_seconds
|
|
||||||
|
|
||||||
def _commit_session(self, session_id: str) -> Dict[str, Any]:
|
|
||||||
response = self._client.post(
|
|
||||||
f"/memory-system/sessions/{session_id}/commit",
|
|
||||||
self._user_payload(),
|
|
||||||
)
|
|
||||||
if response.get("status") == "success":
|
|
||||||
self._last_commit_turn = self._turn_count
|
|
||||||
self._last_commit_time = time.monotonic()
|
|
||||||
else:
|
|
||||||
logger.warning("Memory System commit did not fully succeed: %s", response)
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _tool_search(self, args: Dict[str, Any]) -> str:
|
|
||||||
query = str(args.get("query", "")).strip()
|
|
||||||
if not query:
|
|
||||||
return tool_error("query is required")
|
|
||||||
limit = int(args.get("limit") or 10)
|
|
||||||
limit = max(1, min(limit, 100))
|
|
||||||
payload = {
|
|
||||||
"user_id": self._user_id,
|
|
||||||
"user_key": self._user_key,
|
|
||||||
"session_id": args.get("session_id") or self._session_id,
|
|
||||||
"query": query,
|
|
||||||
"use_llm": bool(args.get("use_llm", self._default_use_llm)),
|
|
||||||
"limit": limit,
|
|
||||||
}
|
|
||||||
response = self._client.post("/memory-system/search", payload)
|
|
||||||
return json.dumps(self._compact_search_response(response, limit=limit), ensure_ascii=False)
|
|
||||||
|
|
||||||
def _tool_profile(self, args: Dict[str, Any]) -> str:
|
|
||||||
user_id = str(args.get("user_id") or self._user_id).strip()
|
|
||||||
if not user_id:
|
|
||||||
return tool_error("user_id is required")
|
|
||||||
user_key = (
|
|
||||||
self._user_key
|
|
||||||
if user_id == self._user_id
|
|
||||||
else str(args.get("user_key") or "").strip()
|
|
||||||
)
|
|
||||||
if not user_key:
|
|
||||||
return tool_error("user_key is required for profile reads")
|
|
||||||
path = f"/memory-system/users/{user_id}/profile?{urlencode({'user_key': user_key})}"
|
|
||||||
return json.dumps(
|
|
||||||
self._client.get(path),
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _tool_remember(self, args: Dict[str, Any]) -> str:
|
|
||||||
content = str(args.get("content", "")).strip()
|
|
||||||
if not content:
|
|
||||||
return tool_error("content is required")
|
|
||||||
session_id = str(args.get("session_id") or self._session_id).strip()
|
|
||||||
return json.dumps(self._remember(content, session_id=session_id, commit=True), ensure_ascii=False)
|
|
||||||
|
|
||||||
def _remember(self, content: str, *, session_id: str, commit: bool) -> Dict[str, Any]:
|
|
||||||
if not self._client:
|
|
||||||
return {"error": "Memory System API is not connected"}
|
|
||||||
response = self._client.post(
|
|
||||||
"/memory-system/messages",
|
|
||||||
self._message_payload(session_id=session_id, user_message=content),
|
|
||||||
)
|
|
||||||
if commit:
|
|
||||||
commit_response = self._client.post(
|
|
||||||
f"/memory-system/sessions/{session_id}/commit",
|
|
||||||
self._user_payload(),
|
|
||||||
)
|
|
||||||
return {"status": response.get("status"), "write": response, "commit": commit_response}
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _ensure_user_key(self) -> None:
|
|
||||||
if self._user_key:
|
|
||||||
return
|
|
||||||
if not self._client:
|
|
||||||
return
|
|
||||||
response = self._client.post("/memory-system/users", {"user_id": self._user_id})
|
|
||||||
result = (
|
|
||||||
response.get("account", {}).get("result", {})
|
|
||||||
if isinstance(response.get("account"), dict)
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
user_key = result.get("user_key")
|
|
||||||
if not user_key:
|
|
||||||
raise ValueError("Memory System user creation response missing account.result.user_key")
|
|
||||||
self._user_key = str(user_key)
|
|
||||||
|
|
||||||
def _user_payload(self) -> Dict[str, str]:
|
|
||||||
if not self._user_key:
|
|
||||||
raise ValueError("Memory System user_key is required")
|
|
||||||
return {"user_id": self._user_id, "user_key": self._user_key}
|
|
||||||
|
|
||||||
def _query_path(self, path: str, params: Dict[str, Any]) -> str:
|
|
||||||
return f"{path}?{urlencode(params)}"
|
|
||||||
|
|
||||||
def _tool_memory_list(self, args: Dict[str, Any]) -> str:
|
|
||||||
uri = str(args.get("uri") or "viking://user/memories").strip()
|
|
||||||
recursive = self._arg_bool(args.get("recursive"), True)
|
|
||||||
path = self._query_path(
|
|
||||||
"/memory-system/memories",
|
|
||||||
{
|
|
||||||
"user_id": self._user_id,
|
|
||||||
"user_key": self._user_key,
|
|
||||||
"uri": uri,
|
|
||||||
"recursive": str(recursive).lower(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return json.dumps(self._client.get(path), ensure_ascii=False)
|
|
||||||
|
|
||||||
def _tool_memory_read(self, args: Dict[str, Any]) -> str:
|
|
||||||
uri = str(args.get("uri", "")).strip()
|
|
||||||
if not uri:
|
|
||||||
return tool_error("uri is required")
|
|
||||||
path = self._query_path(
|
|
||||||
"/memory-system/memories/content",
|
|
||||||
{"user_id": self._user_id, "user_key": self._user_key, "uri": uri},
|
|
||||||
)
|
|
||||||
return json.dumps(self._client.get(path), ensure_ascii=False)
|
|
||||||
|
|
||||||
def _tool_memory_write(self, args: Dict[str, Any]) -> str:
|
|
||||||
uri = str(args.get("uri", "")).strip()
|
|
||||||
content = str(args.get("content", ""))
|
|
||||||
if not uri:
|
|
||||||
return tool_error("uri is required")
|
|
||||||
if not content:
|
|
||||||
return tool_error("content is required")
|
|
||||||
mode = str(args.get("mode") or "create").strip().lower()
|
|
||||||
if mode not in {"create", "replace", "append"}:
|
|
||||||
return tool_error("mode must be create, replace, or append")
|
|
||||||
payload = {
|
|
||||||
"user_id": self._user_id,
|
|
||||||
"user_key": self._user_key,
|
|
||||||
"uri": uri,
|
|
||||||
"content": content,
|
|
||||||
"mode": mode,
|
|
||||||
"wait": self._arg_bool(args.get("wait"), True),
|
|
||||||
}
|
|
||||||
return json.dumps(self._client.post("/memory-system/memories", payload), ensure_ascii=False)
|
|
||||||
|
|
||||||
def _tool_memory_delete(self, args: Dict[str, Any]) -> str:
|
|
||||||
uri = str(args.get("uri", "")).strip()
|
|
||||||
if not uri:
|
|
||||||
return tool_error("uri is required")
|
|
||||||
recursive = self._arg_bool(args.get("recursive"), False)
|
|
||||||
path = self._query_path(
|
|
||||||
"/memory-system/memories",
|
|
||||||
{
|
|
||||||
"user_id": self._user_id,
|
|
||||||
"user_key": self._user_key,
|
|
||||||
"uri": uri,
|
|
||||||
"recursive": str(recursive).lower(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return json.dumps(self._client.delete(path), ensure_ascii=False)
|
|
||||||
|
|
||||||
def _arg_bool(self, value: Any, default: bool) -> bool:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return _bool_value(str(value), default)
|
|
||||||
|
|
||||||
|
|
||||||
def register(ctx) -> None:
|
|
||||||
ctx.register_memory_provider(MemorySystemMemoryProvider())
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934
|
|
||||||
MEMORY_SYSTEM_USER_ID=default
|
|
||||||
MEMORY_SYSTEM_USER_KEY=
|
|
||||||
MEMORY_SYSTEM_API_KEY=
|
|
||||||
MEMORY_SYSTEM_SEARCH_USE_LLM=false
|
|
||||||
MEMORY_SYSTEM_COMMIT_EVERY_TURNS=5
|
|
||||||
MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=300
|
|
||||||
MEMORY_SYSTEM_TIMEOUT_SECONDS=180
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
name: memory_system
|
|
||||||
version: 0.1.2
|
|
||||||
description: "Memory System API provider for Hermes, combining OpenViking session memory and EverOS user profile memory."
|
|
||||||
pip_dependencies:
|
|
||||||
- httpx
|
|
||||||
requires_env:
|
|
||||||
- MEMORY_SYSTEM_ENDPOINT
|
|
||||||
hooks:
|
|
||||||
- on_session_end
|
|
||||||
@ -1,32 +1,32 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "memory-system-api"
|
name = "memory-gateway2"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Lightweight Memory System API for OpenViking session memory and EverOS user profiles"
|
description = "Lightweight Memory Gateway for EverOS user resources"
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.109.0",
|
"fastapi>=0.104.0",
|
||||||
"httpx>=0.26.0",
|
"httpx>=0.25.0",
|
||||||
"pydantic>=2.5.0",
|
"pydantic>=2.7.1",
|
||||||
"pyyaml>=6.0",
|
"python-dotenv>=1.0.1",
|
||||||
"uvicorn>=0.27.0",
|
"python-multipart>=0.0.9",
|
||||||
]
|
"uvicorn[standard]>=0.24.0",
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
memory-gateway = "memory_system_api.server:main"
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0.0",
|
|
||||||
"ruff>=0.1.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["setuptools>=68"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.setuptools.packages.find]
|
||||||
packages = ["memory_system_api"]
|
where = ["."]
|
||||||
|
include = ["core*"]
|
||||||
|
|
||||||
[tool.ruff]
|
[dependency-groups]
|
||||||
target-version = "py310"
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
---
|
|
||||||
name: memory-system-api
|
|
||||||
description: "Use when an AI agent needs to operate this repository's Memory System API, including creating users, writing session messages, committing or extracting memory, managing memory URIs, searching memories, reading profiles, or debugging OpenViking/EverOS backend results."
|
|
||||||
---
|
|
||||||
|
|
||||||
# Memory System API
|
|
||||||
|
|
||||||
This skill is for using the Memory Gateway service in this repo. Prefer it over direct OpenViking or EverOS calls unless the user explicitly asks to debug a backend.
|
|
||||||
|
|
||||||
## Current Contract
|
|
||||||
|
|
||||||
The API is user-gated:
|
|
||||||
|
|
||||||
- Create a user first with `POST /memory-system/users`.
|
|
||||||
- Save the returned `account.result.user_key`.
|
|
||||||
- Send that value back as `user_key` on business API calls.
|
|
||||||
- Do not send `account_id` on business APIs.
|
|
||||||
|
|
||||||
Do not assume business APIs will auto-create users. Non-user-creation endpoints fail when the user was not created first or when the supplied `user_key` does not match the stored key.
|
|
||||||
|
|
||||||
`X-API-Key` is separate. It protects the Memory System API itself only when `server.api_key` is configured.
|
|
||||||
|
|
||||||
## Identity Model
|
|
||||||
|
|
||||||
- `user_id`: end user.
|
|
||||||
- `user_key`: OpenViking key returned when the user is created.
|
|
||||||
- `session_id`: conversation ID and OpenViking session ID.
|
|
||||||
|
|
||||||
Internally the gateway creates one isolated OpenViking account per business user:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"account_id": "<user_id>_account", "admin_user_id": "<user_id>"}
|
|
||||||
```
|
|
||||||
|
|
||||||
The gateway does not call `/api/v1/admin/accounts/admin/users`. User creation goes through `/api/v1/admin/accounts`, stores the returned `user_key`, and later business calls use that user key directly.
|
|
||||||
|
|
||||||
The SQLite store is the source of truth for:
|
|
||||||
|
|
||||||
- `user_id -> user_key`
|
|
||||||
- `user_id + session_id`
|
|
||||||
- `task_id`
|
|
||||||
- `archive_uri`
|
|
||||||
|
|
||||||
Session memory is retrieved under OpenViking using the explicit user/session URI paths:
|
|
||||||
|
|
||||||
```text
|
|
||||||
viking://user/memories
|
|
||||||
viking://user/memories/...
|
|
||||||
viking://user/<session_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
Base path: `/memory-system`
|
|
||||||
|
|
||||||
| Method | Path | Purpose | Requires `user_key` |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `GET` | `/health` | Check OpenViking and EverOS health | No |
|
|
||||||
| `POST` | `/users` | Create OpenViking user and store user key | No |
|
|
||||||
| `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 |
|
|
||||||
| `GET` | `/memories` | List OpenViking memory URIs under a memory root | Yes |
|
|
||||||
| `GET` | `/memories/content` | Read one OpenViking memory URI | Yes |
|
|
||||||
| `POST` | `/memories` | Create, replace, or append an OpenViking memory via `content/write` | Yes |
|
|
||||||
| `DELETE` | `/memories` | Delete an OpenViking memory URI via `fs` | Yes |
|
|
||||||
| `POST` | `/resources` | Upload local file or remote URL to OpenViking resources | Yes |
|
|
||||||
| `DELETE` | `/resources` | Delete OpenViking resource URI via `fs` | Yes |
|
|
||||||
| `POST` | `/search` | Search OpenViking and EverOS | Yes |
|
|
||||||
| `GET` | `/users/{user_id}/profile` | Read EverOS profile | Yes |
|
|
||||||
|
|
||||||
## Required Inputs
|
|
||||||
|
|
||||||
For business APIs:
|
|
||||||
|
|
||||||
```text
|
|
||||||
user_id: <user id from /users>
|
|
||||||
user_key: <account.result.user_key from /users>
|
|
||||||
```
|
|
||||||
|
|
||||||
If configured:
|
|
||||||
|
|
||||||
```text
|
|
||||||
X-API-Key: <server.api_key>
|
|
||||||
```
|
|
||||||
|
|
||||||
Never place OpenViking root keys in user-facing examples unless the user is explicitly configuring the server. Never ask callers to send `X-Account-Key`; that contract is obsolete.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
Create user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST "$BASE/memory-system/users" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id":"userA"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Write messages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST "$BASE/memory-system/messages" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "userA",
|
|
||||||
"user_key": "'"$USER_KEY"'",
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"user_message": "请记住:我喜欢拿铁。",
|
|
||||||
"assistant_message": "好的。"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Commit:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST "$BASE/memory-system/sessions/sessionA1/commit" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id":"userA","user_key":"'"$USER_KEY"'"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Search:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST "$BASE/memory-system/search" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "userA",
|
|
||||||
"user_key": "'"$USER_KEY"'",
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"query": "我喜欢喝什么?",
|
|
||||||
"use_llm": false,
|
|
||||||
"limit": 10
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
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"
|
|
||||||
```
|
|
||||||
|
|
||||||
List memories:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -G "$BASE/memory-system/memories" \
|
|
||||||
--data-urlencode "user_id=userA" \
|
|
||||||
--data-urlencode "user_key=$USER_KEY" \
|
|
||||||
--data-urlencode "uri=viking://user/memories" \
|
|
||||||
--data-urlencode "recursive=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
Read memory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -G "$BASE/memory-system/memories/content" \
|
|
||||||
--data-urlencode "user_id=userA" \
|
|
||||||
--data-urlencode "user_key=$USER_KEY" \
|
|
||||||
--data-urlencode "uri=viking://user/memories/preferences/python.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
Create, replace, or append memory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST "$BASE/memory-system/memories" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "userA",
|
|
||||||
"user_key": "'"$USER_KEY"'",
|
|
||||||
"uri": "viking://user/memories/preferences/python.md",
|
|
||||||
"content": "# Python 偏好\n\n用户偏好使用 Python 做数据分析。",
|
|
||||||
"mode": "replace",
|
|
||||||
"wait": true
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete memory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X DELETE -G "$BASE/memory-system/memories" \
|
|
||||||
--data-urlencode "user_id=userA" \
|
|
||||||
--data-urlencode "user_key=$USER_KEY" \
|
|
||||||
--data-urlencode "uri=viking://user/memories/preferences/python.md" \
|
|
||||||
--data-urlencode "recursive=false"
|
|
||||||
```
|
|
||||||
|
|
||||||
For directory-like or composite memories, retry deletion with `recursive=true` only after OpenViking reports that recursive deletion is required.
|
|
||||||
|
|
||||||
## Response Handling
|
|
||||||
|
|
||||||
Top-level `status` is one of:
|
|
||||||
|
|
||||||
- `success`: all attempted backends succeeded.
|
|
||||||
- `partial_success`: at least one backend succeeded and one failed.
|
|
||||||
- `failed`: all attempted backends failed.
|
|
||||||
|
|
||||||
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`.
|
|
||||||
- Omitting `user_key` on business calls.
|
|
||||||
- Sending `account_id` to business APIs.
|
|
||||||
- Confusing `X-API-Key` with `user_key`.
|
|
||||||
- Assuming memory updates use a PATCH endpoint; use `POST /memory-system/memories` with `mode=replace` or `mode=append`.
|
|
||||||
- Deleting memories recursively by default; use `recursive=false` unless the target is a directory or composite resource.
|
|
||||||
- Expecting `backends.everos.result` to still contain full `episodes` or `original_data`.
|
|
||||||
- Assuming the gateway stores these values only in memory; it persists them in SQLite.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
After changing API behavior or this skill:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PYTHONPATH=/home/tom/memory-gateway pytest -q
|
|
||||||
python -m compileall -q memory_system_api plugins eval tests
|
|
||||||
```
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "Memory System API"
|
|
||||||
short_description: "Use the configured Memory System API endpoint from AI agents."
|
|
||||||
default_prompt: "Use the Memory System API skill with the configured endpoint to create users, write session messages with user_id and user_key, commit or extract sessions, manage memory URIs, search memory, and read user profiles."
|
|
||||||
@ -1,345 +0,0 @@
|
|||||||
# Memory System API Reference
|
|
||||||
|
|
||||||
Use the deployed service URL supplied by the user, runtime, or configuration:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<MEMORY_SYSTEM_BASE_URL>
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not assume a localhost address. In agent workflows, resolve the endpoint from `MEMORY_SYSTEM_ENDPOINT`, Hermes memory config, platform config, or user input.
|
|
||||||
|
|
||||||
If an API key is configured, add:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
-H "X-API-Key: <gateway-api-key>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Health
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s <MEMORY_SYSTEM_BASE_URL>/memory-system/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## Create User
|
|
||||||
|
|
||||||
Create the business user before calling any business endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/users \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "<USER_ID>"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Save the returned `account.result.user_key` and pass it as `user_key` on later calls:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"account": {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": "<USER_ID>_account",
|
|
||||||
"admin_user_id": "<USER_ID>",
|
|
||||||
"user_key": "<USER_KEY>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not send `account_id` to business endpoints.
|
|
||||||
|
|
||||||
## Write Messages
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/messages \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "<USER_ID>",
|
|
||||||
"user_key": "<USER_KEY>",
|
|
||||||
"session_id": "<SESSION_ID>",
|
|
||||||
"user_message": "我喜欢喝拿铁,不喜欢美式。",
|
|
||||||
"assistant_message": "好的,我会记住你的咖啡偏好。"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
`user_message` and `assistant_message` are optional independently, but at least one must be present.
|
|
||||||
|
|
||||||
## Commit Session
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/commit \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user_id": "<USER_ID>", "user_key": "<USER_KEY>"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the returned OpenViking task ID, if present:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s "<MEMORY_SYSTEM_BASE_URL>/memory-system/openviking/tasks/<TASK_ID>?user_id=<USER_ID>&user_key=<USER_KEY>&session_id=<SESSION_ID>"
|
|
||||||
```
|
|
||||||
|
|
||||||
`commit` can return `partial_success` if OpenViking accepted the archive but EverOS flush failed or timed out. This is retryable:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "partial_success",
|
|
||||||
"backends": {
|
|
||||||
"openviking": {"status": "success", "result": {"status": "ok"}},
|
|
||||||
"everos": {"status": "failed", "error": "ReadTimeout"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait for EverOS or its upstream LLM/rerank service to recover, then call the same commit endpoint again.
|
|
||||||
|
|
||||||
## Immediate Extract
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/sessions/<SESSION_ID>/extract \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manage Memories
|
|
||||||
|
|
||||||
Memory management operates on OpenViking memory URIs, usually under `viking://user/memories`. There is no separate PATCH endpoint. Update existing memory by writing directly to its URI.
|
|
||||||
|
|
||||||
### List Memory URIs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories" \
|
|
||||||
--data-urlencode "user_id=<USER_ID>" \
|
|
||||||
--data-urlencode "user_key=<USER_KEY>" \
|
|
||||||
--data-urlencode "uri=viking://user/memories" \
|
|
||||||
--data-urlencode "recursive=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
This maps to OpenViking `GET /api/v1/fs/ls`.
|
|
||||||
|
|
||||||
### Read Memory Content
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories/content" \
|
|
||||||
--data-urlencode "user_id=<USER_ID>" \
|
|
||||||
--data-urlencode "user_key=<USER_KEY>" \
|
|
||||||
--data-urlencode "uri=viking://user/memories/preferences/python.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
This maps to OpenViking `GET /api/v1/content/read`.
|
|
||||||
|
|
||||||
### Create, Replace, Or Append Memory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "<USER_ID>",
|
|
||||||
"user_key": "<USER_KEY>",
|
|
||||||
"uri": "viking://user/<USER_ID>/memories/preferences/饮食偏好.md",
|
|
||||||
"content": "用户喜欢喝茶",
|
|
||||||
"mode": "create",
|
|
||||||
"wait": true
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
For preference memories, write directly under `memories/preferences/`; do not add an extra `/user` path segment.
|
|
||||||
|
|
||||||
`mode` supports:
|
|
||||||
|
|
||||||
- `create`: create a new memory URI.
|
|
||||||
- `replace`: fully replace an existing memory.
|
|
||||||
- `append`: append content to an existing memory.
|
|
||||||
|
|
||||||
This maps to OpenViking `POST /api/v1/content/write`. OpenViking refreshes semantic and vector indexes after writing.
|
|
||||||
|
|
||||||
### Delete Memory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X DELETE --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/memories" \
|
|
||||||
--data-urlencode "user_id=<USER_ID>" \
|
|
||||||
--data-urlencode "user_key=<USER_KEY>" \
|
|
||||||
--data-urlencode "uri=viking://user/memories/preferences/python.md" \
|
|
||||||
--data-urlencode "recursive=false"
|
|
||||||
```
|
|
||||||
|
|
||||||
Default to `recursive=false`. If OpenViking reports that the URI is a directory or composite resource, retry with `recursive=true`.
|
|
||||||
|
|
||||||
## Search
|
|
||||||
|
|
||||||
Without LLM planning:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/search \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "<USER_ID>",
|
|
||||||
"user_key": "<USER_KEY>",
|
|
||||||
"session_id": "<SESSION_ID>",
|
|
||||||
"query": "我喜欢喝什么咖啡?",
|
|
||||||
"use_llm": false,
|
|
||||||
"limit": 10,
|
|
||||||
"level": 2,
|
|
||||||
"score_threshold": 0.8,
|
|
||||||
"target_uri": "viking://user/memories"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
With LLM planning:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST <MEMORY_SYSTEM_BASE_URL>/memory-system/search \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"user_id": "<USER_ID>",
|
|
||||||
"user_key": "<USER_KEY>",
|
|
||||||
"session_id": "<SESSION_ID>",
|
|
||||||
"query": "我的偏好是什么?",
|
|
||||||
"use_llm": true,
|
|
||||||
"limit": 10,
|
|
||||||
"level": 2,
|
|
||||||
"score_threshold": 0.8,
|
|
||||||
"target_uri": "viking://user/memories"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Raw API search responses include merged `items` plus compact backend diagnostics. Fields named `vector` are stripped recursively before the API returns JSON. The API calls current EverOS `POST /api/v1/memory/search` with top-level `user_id`, `query`, `method`, `top_k`, `include_profile`, and optional `filters.session_id`; it does not return full EverOS episode/profile payloads inside `backends`. OpenViking search uses `/api/v1/search/search`; `target_uri`, `level`, and `score_threshold` are optional request fields.
|
|
||||||
|
|
||||||
The search response shape should now look more like:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"source_backend": "everos",
|
|
||||||
"memory_type": "episode",
|
|
||||||
"id": "episode-1",
|
|
||||||
"user_id": "userB",
|
|
||||||
"session_id": "sessionB1",
|
|
||||||
"timestamp": "2026-05-22T07:50:51.750000Z",
|
|
||||||
"summary": "userB 在对话中表示自己喜欢拿铁。",
|
|
||||||
"score": 0.72
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"backends": {
|
|
||||||
"everos": {
|
|
||||||
"status": "success",
|
|
||||||
"result": {
|
|
||||||
"counts": {"episodes": 1, "profiles": 0},
|
|
||||||
"query": {"text": "我喜欢喝什么?", "method": "agentic"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When the API is used through the Hermes `memory_system` provider, the tool result is intentionally compact and should look like:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"source_backend": "openviking",
|
|
||||||
"memory_type": "event",
|
|
||||||
"score": 0.92,
|
|
||||||
"text": "Relevant recalled memory text",
|
|
||||||
"uri": "viking://..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the compact `text` fields for answering. Only inspect raw `backends` when debugging API/backend failures.
|
|
||||||
|
|
||||||
## Profile
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS --get "<MEMORY_SYSTEM_BASE_URL>/memory-system/users/<USER_ID>/profile" \
|
|
||||||
--data-urlencode "user_key=<USER_KEY>" \
|
|
||||||
--data-urlencode "query=我想喝东西" \
|
|
||||||
--data-urlencode "limit=10" \
|
|
||||||
--data-urlencode "level=2"
|
|
||||||
```
|
|
||||||
|
|
||||||
This combines EverOS profile with OpenViking memory recall. The OpenViking request uses `/api/v1/search/search` with `target_uri: viking://user/memories`, `limit`, and `level`.
|
|
||||||
|
|
||||||
## Response Interpretation
|
|
||||||
|
|
||||||
Inspect backend status:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "partial_success",
|
|
||||||
"backends": {
|
|
||||||
"openviking": {"status": "success", "result": {}},
|
|
||||||
"everos": {"status": "failed", "error": "..."}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use backend-specific errors for debugging.
|
|
||||||
|
|
||||||
OpenViking user keys are intentionally hidden from other users, but the caller must present the matching `user_key` for business APIs. The gateway stores the created `user_id/user_key`, `session_id`, `task_id`, and `archive_uri` in SQLite.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 548 KiB |
419
tests/test_gateway.py
Normal file
419
tests/test_gateway.py
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.api import create_app
|
||||||
|
from core.config import GatewayConfig
|
||||||
|
from core.db import init_db
|
||||||
|
from core.repository import MemoryRepository
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEverOSClient:
|
||||||
|
def __init__(self, search_results: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
self.add_calls: list[dict[str, Any]] = []
|
||||||
|
self.flush_calls: list[dict[str, str]] = []
|
||||||
|
self.search_calls: list[dict[str, Any]] = []
|
||||||
|
self.search_results = search_results or []
|
||||||
|
|
||||||
|
async def add_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self.add_calls.append(payload)
|
||||||
|
return {"request_id": "add", "data": {"status": "accumulated"}}
|
||||||
|
|
||||||
|
async def flush_memory(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
app_id: str,
|
||||||
|
project_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
self.flush_calls.append(
|
||||||
|
{"session_id": session_id, "app_id": app_id, "project_id": project_id}
|
||||||
|
)
|
||||||
|
return {"request_id": "flush", "data": {"status": "extracted"}}
|
||||||
|
|
||||||
|
async def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self.search_calls.append(payload)
|
||||||
|
return {"request_id": "search", "data": {"episodes": self.search_results}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(tmp_path: Path) -> GatewayConfig:
|
||||||
|
return GatewayConfig(
|
||||||
|
everos_base_url="http://everos.test",
|
||||||
|
database_path=tmp_path / "gateway.sqlite3",
|
||||||
|
storage_dir=tmp_path / "storage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repo(config: GatewayConfig) -> MemoryRepository:
|
||||||
|
init_db(config.database_path)
|
||||||
|
return MemoryRepository(config.database_path)
|
||||||
|
|
||||||
|
|
||||||
|
def app_client(
|
||||||
|
config: GatewayConfig,
|
||||||
|
everos_client: FakeEverOSClient,
|
||||||
|
) -> httpx.AsyncClient:
|
||||||
|
app = create_app(config=config, everos_client=everos_client)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(client: httpx.AsyncClient, user_id: str = "u_123") -> str:
|
||||||
|
response = await client.post("/users", json={"user_id": user_id})
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["user_id"] == user_id
|
||||||
|
assert body["user_key"]
|
||||||
|
return body["user_key"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_generates_and_persists_user_key(
|
||||||
|
config: GatewayConfig,
|
||||||
|
repo: MemoryRepository,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client, "u_123")
|
||||||
|
|
||||||
|
user = repo.get_user("u_123")
|
||||||
|
assert user is not None
|
||||||
|
assert user["user_key"] == user_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_resource_creates_record_and_calls_everos(
|
||||||
|
config: GatewayConfig,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/resources",
|
||||||
|
data={"user_id": "u_123", "user_key": user_key, "title": "Contract"},
|
||||||
|
files={"file": ("contract.txt", b"pay in 30 days", "text/plain")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
resource_id = body["resource_id"]
|
||||||
|
assert body["session_id"] == f"resource:u_123:{resource_id}"
|
||||||
|
assert body["uri"] == f"resource://u_123/{resource_id}"
|
||||||
|
assert body["status"] == "extracted"
|
||||||
|
|
||||||
|
repo = MemoryRepository(config.database_path)
|
||||||
|
resource = repo.get_resource(resource_id)
|
||||||
|
assert resource is not None
|
||||||
|
assert resource["status"] == "extracted"
|
||||||
|
assert resource["original_filename"] == "contract.txt"
|
||||||
|
assert resource["content_type"] == "text"
|
||||||
|
assert resource["sha256"]
|
||||||
|
assert resource["size_bytes"] == len(b"pay in 30 days")
|
||||||
|
assert not resource["uri"].startswith("resource://")
|
||||||
|
|
||||||
|
assert len(everos.add_calls) == 1
|
||||||
|
add_payload = everos.add_calls[0]
|
||||||
|
assert add_payload["session_id"] == f"resource:u_123:{resource_id}"
|
||||||
|
content = add_payload["messages"][0]["content"][0]
|
||||||
|
assert content["type"] == "text"
|
||||||
|
assert content["uri"].startswith("file://")
|
||||||
|
assert content["extras"] == {"resource_id": resource_id, "source": "user_upload"}
|
||||||
|
assert everos.flush_calls == [
|
||||||
|
{
|
||||||
|
"session_id": f"resource:u_123:{resource_id}",
|
||||||
|
"app_id": "default",
|
||||||
|
"project_id": "default",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resource_detail_does_not_leak_internal_uri(
|
||||||
|
config: GatewayConfig,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client)
|
||||||
|
created = await client.post(
|
||||||
|
"/resources",
|
||||||
|
data={"user_id": "u_123", "user_key": user_key},
|
||||||
|
files={"file": ("note.txt", b"hello", "text/plain")},
|
||||||
|
)
|
||||||
|
resource_id = created.json()["resource_id"]
|
||||||
|
detail = await client.get(
|
||||||
|
f"/resources/{resource_id}",
|
||||||
|
params={"user_id": "u_123", "user_key": user_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert detail.status_code == 200, detail.text
|
||||||
|
resources = detail.json()["resources"]
|
||||||
|
assert len(resources) == 1
|
||||||
|
assert resources[0]["uri"] == f"resource://u_123/{resource_id}"
|
||||||
|
assert "file://" not in detail.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resource_detail_returns_empty_when_user_has_no_resource(
|
||||||
|
config: GatewayConfig,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client, "u_empty")
|
||||||
|
response = await client.get(
|
||||||
|
"/resources/r_missing",
|
||||||
|
params={"user_id": "u_empty", "user_key": user_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json() == {"resources": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resources_are_isolated_by_user_key(
|
||||||
|
config: GatewayConfig,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
alice_key = await create_user(client, "alice")
|
||||||
|
bob_key = await create_user(client, "bob")
|
||||||
|
created = await client.post(
|
||||||
|
"/resources",
|
||||||
|
data={"user_id": "alice", "user_key": alice_key},
|
||||||
|
files={"file": ("alice.txt", b"alice-only", "text/plain")},
|
||||||
|
)
|
||||||
|
resource_id = created.json()["resource_id"]
|
||||||
|
bob_detail = await client.get(
|
||||||
|
f"/resources/{resource_id}",
|
||||||
|
params={"user_id": "bob", "user_key": bob_key},
|
||||||
|
)
|
||||||
|
bob_list = await client.get(
|
||||||
|
"/resources",
|
||||||
|
params={"user_id": "bob", "user_key": bob_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bob_detail.status_code == 200, bob_detail.text
|
||||||
|
assert bob_detail.json() == {"resources": []}
|
||||||
|
assert bob_list.status_code == 200, bob_list.text
|
||||||
|
assert bob_list.json() == {"resources": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resource_api_rejects_invalid_user_key(
|
||||||
|
config: GatewayConfig,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
await create_user(client, "u_123")
|
||||||
|
response = await client.get(
|
||||||
|
"/resources",
|
||||||
|
params={"user_id": "u_123", "user_key": "wrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deleted_resource_is_excluded_from_resource_scope_search(
|
||||||
|
config: GatewayConfig,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient(
|
||||||
|
[{"id": "mem_1", "session_id": "resource:u_123:r_live", "episode": "live"}]
|
||||||
|
)
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client)
|
||||||
|
created = await client.post(
|
||||||
|
"/resources",
|
||||||
|
data={"user_id": "u_123", "user_key": user_key},
|
||||||
|
files={"file": ("note.txt", b"hello", "text/plain")},
|
||||||
|
)
|
||||||
|
resource_id = created.json()["resource_id"]
|
||||||
|
delete_response = await client.delete(
|
||||||
|
f"/resources/{resource_id}",
|
||||||
|
params={"user_id": "u_123", "user_key": user_key},
|
||||||
|
)
|
||||||
|
search_response = await client.post(
|
||||||
|
"/memories/search",
|
||||||
|
json={
|
||||||
|
"user_id": "u_123",
|
||||||
|
"user_key": user_key,
|
||||||
|
"query": "hello",
|
||||||
|
"scope": ["resources"],
|
||||||
|
"top_k": 8,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert delete_response.status_code == 200
|
||||||
|
assert search_response.status_code == 200
|
||||||
|
assert everos.search_calls == []
|
||||||
|
assert search_response.json()["results"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tombstone_filters_search_results(
|
||||||
|
config: GatewayConfig,
|
||||||
|
repo: MemoryRepository,
|
||||||
|
) -> None:
|
||||||
|
repo.create_resource(
|
||||||
|
id="r_1",
|
||||||
|
user_id="u_123",
|
||||||
|
app_id="default",
|
||||||
|
project_id="default",
|
||||||
|
session_id="resource:u_123:r_1",
|
||||||
|
original_filename="a.txt",
|
||||||
|
mime_type="text/plain",
|
||||||
|
content_type="text",
|
||||||
|
uri="file:///private/a.txt",
|
||||||
|
uri_public=False,
|
||||||
|
sha256="abc",
|
||||||
|
size_bytes=1,
|
||||||
|
title=None,
|
||||||
|
description=None,
|
||||||
|
status="extracted",
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
repo.add_tombstone(
|
||||||
|
user_id="u_123",
|
||||||
|
memory_id="mem_deleted",
|
||||||
|
session_id=None,
|
||||||
|
reason="user deleted",
|
||||||
|
)
|
||||||
|
everos = FakeEverOSClient(
|
||||||
|
[
|
||||||
|
{"id": "mem_deleted", "session_id": "resource:u_123:r_1", "episode": "x"},
|
||||||
|
{"id": "mem_live", "session_id": "resource:u_123:r_1", "episode": "y"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/memories/search",
|
||||||
|
json={
|
||||||
|
"user_id": "u_123",
|
||||||
|
"user_key": user_key,
|
||||||
|
"query": "hello",
|
||||||
|
"scope": ["resources"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert [result["id"] for result in results] == ["mem_live"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_override_replaces_search_result_text(
|
||||||
|
config: GatewayConfig,
|
||||||
|
repo: MemoryRepository,
|
||||||
|
) -> None:
|
||||||
|
repo.create_resource(
|
||||||
|
id="r_1",
|
||||||
|
user_id="u_123",
|
||||||
|
app_id="default",
|
||||||
|
project_id="default",
|
||||||
|
session_id="resource:u_123:r_1",
|
||||||
|
original_filename="a.txt",
|
||||||
|
mime_type="text/plain",
|
||||||
|
content_type="text",
|
||||||
|
uri="file:///private/a.txt",
|
||||||
|
uri_public=False,
|
||||||
|
sha256="abc",
|
||||||
|
size_bytes=1,
|
||||||
|
title=None,
|
||||||
|
description=None,
|
||||||
|
status="extracted",
|
||||||
|
error_message=None,
|
||||||
|
)
|
||||||
|
everos = FakeEverOSClient(
|
||||||
|
[{"id": "mem_1", "session_id": "resource:u_123:r_1", "episode": "old text"}]
|
||||||
|
)
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client)
|
||||||
|
patch_response = await client.patch(
|
||||||
|
"/memories/mem_1",
|
||||||
|
json={
|
||||||
|
"user_id": "u_123",
|
||||||
|
"user_key": user_key,
|
||||||
|
"session_id": "resource:u_123:r_1",
|
||||||
|
"override_text": "corrected text",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
search_response = await client.post(
|
||||||
|
"/memories/search",
|
||||||
|
json={
|
||||||
|
"user_id": "u_123",
|
||||||
|
"user_key": user_key,
|
||||||
|
"query": "hello",
|
||||||
|
"scope": ["resources"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert patch_response.status_code == 200, patch_response.text
|
||||||
|
result = search_response.json()["results"][0]
|
||||||
|
assert result["id"] == "mem_1"
|
||||||
|
assert result["text"] == "corrected text"
|
||||||
|
assert result["raw"]["episode"] == "old text"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_resources_returns_only_not_deleted(
|
||||||
|
config: GatewayConfig,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client)
|
||||||
|
first = await client.post(
|
||||||
|
"/resources",
|
||||||
|
data={"user_id": "u_123", "user_key": user_key},
|
||||||
|
files={"file": ("a.txt", b"a", "text/plain")},
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/resources",
|
||||||
|
data={"user_id": "u_123", "user_key": user_key},
|
||||||
|
files={"file": ("b.txt", b"b", "text/plain")},
|
||||||
|
)
|
||||||
|
await client.delete(
|
||||||
|
f"/resources/{first.json()['resource_id']}",
|
||||||
|
params={"user_id": "u_123", "user_key": user_key},
|
||||||
|
)
|
||||||
|
response = await client.get(
|
||||||
|
"/resources",
|
||||||
|
params={"user_id": "u_123", "user_key": user_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()["resources"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["filename"] == "b.txt"
|
||||||
|
assert items[0]["uri"].startswith("resource://u_123/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_memory_writes_tombstone(
|
||||||
|
config: GatewayConfig,
|
||||||
|
repo: MemoryRepository,
|
||||||
|
) -> None:
|
||||||
|
everos = FakeEverOSClient()
|
||||||
|
async with app_client(config, everos) as client:
|
||||||
|
user_key = await create_user(client)
|
||||||
|
response = await client.request(
|
||||||
|
"DELETE",
|
||||||
|
"/memories/mem_1",
|
||||||
|
json={
|
||||||
|
"user_id": "u_123",
|
||||||
|
"user_key": user_key,
|
||||||
|
"session_id": "resource:u_123:r_1",
|
||||||
|
"reason": "manual delete",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
tombstones = repo.get_tombstones("u_123")
|
||||||
|
assert len(tombstones) == 1
|
||||||
|
assert tombstones[0]["memory_id"] == "mem_1"
|
||||||
|
assert tombstones[0]["session_id"] == "resource:u_123:r_1"
|
||||||
@ -1,417 +0,0 @@
|
|||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
HERMES_ROOT = Path("/home/tom/hermes-agent")
|
|
||||||
PLUGIN_PATH = Path(__file__).resolve().parents[1] / "plugins" / "memory" / "memory_system" / "__init__.py"
|
|
||||||
|
|
||||||
|
|
||||||
def load_plugin_module():
|
|
||||||
if str(HERMES_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(HERMES_ROOT))
|
|
||||||
spec = importlib.util.spec_from_file_location("memory_system_plugin", PLUGIN_PATH)
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
assert spec.loader is not None
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
def __init__(self, commit_status="success"):
|
|
||||||
self.posts = []
|
|
||||||
self.gets = []
|
|
||||||
self.deletes = []
|
|
||||||
self.commit_status = commit_status
|
|
||||||
|
|
||||||
def post(self, path, payload=None):
|
|
||||||
self.posts.append((path, payload or {}))
|
|
||||||
if path == "/memory-system/users":
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"account": {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": f"{payload['user_id']}_account",
|
|
||||||
"admin_user_id": payload["user_id"],
|
|
||||||
"user_key": f"{payload['user_id']}-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if path == "/memory-system/search":
|
|
||||||
return {
|
|
||||||
"status": "success",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"source_backend": "openviking",
|
|
||||||
"content": "likes latte",
|
|
||||||
"score": 0.9,
|
|
||||||
"uri": "viking://user/user-1/memories/a",
|
|
||||||
"vector": [0.1, 0.2],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source_backend": "everos",
|
|
||||||
"memory": "prefers warm coffee",
|
|
||||||
"memory_type": "profile",
|
|
||||||
"original_data": {"large": "payload"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"backends": {
|
|
||||||
"openviking": {"status": "success", "result": {"verbose": True}},
|
|
||||||
"everos": {"status": "success", "result": {"verbose": True}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if path.endswith("/commit"):
|
|
||||||
return {"status": self.commit_status}
|
|
||||||
if path == "/memory-system/memories":
|
|
||||||
return {"status": "success", "memory": {"status": "ok", "result": {"uri": payload.get("uri")}}}
|
|
||||||
return {"status": "success"}
|
|
||||||
|
|
||||||
def get(self, path):
|
|
||||||
self.gets.append(path)
|
|
||||||
if path.startswith("/memory-system/memories/content"):
|
|
||||||
return {"status": "success", "memory": {"status": "ok", "result": {"content": "# Python"}}}
|
|
||||||
if path.startswith("/memory-system/memories"):
|
|
||||||
return {"status": "success", "memory": {"status": "ok", "result": {"children": []}}}
|
|
||||||
return {"status": "success", "profile": {"coffee": "latte"}}
|
|
||||||
|
|
||||||
def delete(self, path):
|
|
||||||
self.deletes.append(path)
|
|
||||||
return {"status": "success", "memory": {"status": "ok", "result": {"estimated_deleted_count": 1}}}
|
|
||||||
|
|
||||||
|
|
||||||
def make_provider():
|
|
||||||
module = load_plugin_module()
|
|
||||||
provider = module.MemorySystemMemoryProvider()
|
|
||||||
provider._client = FakeClient()
|
|
||||||
provider._endpoint = "http://127.0.0.1:1934"
|
|
||||||
provider._user_id = "user-1"
|
|
||||||
provider._user_key = "user-1-key"
|
|
||||||
provider._session_id = "session-1"
|
|
||||||
provider._commit_every_turns = 0
|
|
||||||
provider._commit_interval_seconds = 0
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_sync(provider):
|
|
||||||
thread = provider._sync_thread
|
|
||||||
if thread and thread.is_alive():
|
|
||||||
thread.join(timeout=2.0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_initialize_loads_config_from_hermes_env_file(tmp_path, monkeypatch):
|
|
||||||
module = load_plugin_module()
|
|
||||||
hermes_home = tmp_path / ".hermes"
|
|
||||||
hermes_home.mkdir()
|
|
||||||
env_file = hermes_home / ".env"
|
|
||||||
env_file.write_text(
|
|
||||||
"\n".join(
|
|
||||||
[
|
|
||||||
"MEMORY_SYSTEM_ENDPOINT=http://127.0.0.1:1934",
|
|
||||||
"MEMORY_SYSTEM_USER_ID=file-user",
|
|
||||||
"MEMORY_SYSTEM_USER_KEY=file-user-key",
|
|
||||||
"MEMORY_SYSTEM_COMMIT_EVERY_TURNS=3",
|
|
||||||
"MEMORY_SYSTEM_COMMIT_INTERVAL_SECONDS=60",
|
|
||||||
"MEMORY_SYSTEM_TIMEOUT_SECONDS=123",
|
|
||||||
]
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
for key in list(os.environ):
|
|
||||||
if key.startswith("MEMORY_SYSTEM_"):
|
|
||||||
monkeypatch.delenv(key, raising=False)
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
class InitClient(FakeClient):
|
|
||||||
def __init__(self, endpoint, api_key="", timeout=0):
|
|
||||||
super().__init__()
|
|
||||||
self.endpoint = endpoint
|
|
||||||
self.api_key = api_key
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
def health(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "_MemorySystemClient", InitClient)
|
|
||||||
provider = module.MemorySystemMemoryProvider()
|
|
||||||
|
|
||||||
provider.initialize("session-1", hermes_home=str(hermes_home))
|
|
||||||
|
|
||||||
assert provider._endpoint == "http://127.0.0.1:1934"
|
|
||||||
assert provider._user_id == "file-user"
|
|
||||||
assert provider._user_key == "file-user-key"
|
|
||||||
assert provider._commit_every_turns == 3
|
|
||||||
assert provider._commit_interval_seconds == 60
|
|
||||||
assert provider._timeout == 123
|
|
||||||
assert provider._client.endpoint == "http://127.0.0.1:1934"
|
|
||||||
assert provider._client.timeout == 123
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_turn_posts_user_and_assistant_messages():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
provider.sync_turn("hello", "hi there")
|
|
||||||
wait_for_sync(provider)
|
|
||||||
|
|
||||||
assert provider._client.posts == [
|
|
||||||
(
|
|
||||||
"/memory-system/messages",
|
|
||||||
{
|
|
||||||
"user_id": "user-1",
|
|
||||||
"user_key": "user-1-key",
|
|
||||||
"session_id": "session-1",
|
|
||||||
"user_message": "hello",
|
|
||||||
"assistant_message": "hi there",
|
|
||||||
"metadata": {"source": "hermes", "provider": "memory_system"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_session_end_commits_after_turn_sync():
|
|
||||||
provider = make_provider()
|
|
||||||
provider.sync_turn("hello", "hi there")
|
|
||||||
|
|
||||||
provider.on_session_end([])
|
|
||||||
|
|
||||||
assert provider._client.posts[-1] == (
|
|
||||||
"/memory-system/sessions/session-1/commit",
|
|
||||||
{"user_id": "user-1", "user_key": "user-1-key"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_turn_commits_every_configured_turns():
|
|
||||||
provider = make_provider()
|
|
||||||
provider._commit_every_turns = 2
|
|
||||||
provider._commit_interval_seconds = 0
|
|
||||||
|
|
||||||
provider.sync_turn("turn 1", "reply 1")
|
|
||||||
wait_for_sync(provider)
|
|
||||||
provider.sync_turn("turn 2", "reply 2")
|
|
||||||
wait_for_sync(provider)
|
|
||||||
|
|
||||||
assert provider._client.posts[-1] == (
|
|
||||||
"/memory-system/sessions/session-1/commit",
|
|
||||||
{"user_id": "user-1", "user_key": "user-1-key"},
|
|
||||||
)
|
|
||||||
assert provider._last_commit_turn == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_partial_commit_does_not_mark_turns_committed():
|
|
||||||
provider = make_provider()
|
|
||||||
provider._client = FakeClient(commit_status="partial_success")
|
|
||||||
provider._turn_count = 2
|
|
||||||
|
|
||||||
response = provider._commit_session("session-1")
|
|
||||||
|
|
||||||
assert response["status"] == "partial_success"
|
|
||||||
assert provider._last_commit_turn == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_on_session_end_skips_when_periodic_commit_is_current():
|
|
||||||
provider = make_provider()
|
|
||||||
provider._commit_every_turns = 1
|
|
||||||
provider._commit_interval_seconds = 0
|
|
||||||
provider.sync_turn("hello", "hi there")
|
|
||||||
wait_for_sync(provider)
|
|
||||||
|
|
||||||
provider.on_session_end([])
|
|
||||||
|
|
||||||
commit_posts = [
|
|
||||||
post for post in provider._client.posts if post[0] == "/memory-system/sessions/session-1/commit"
|
|
||||||
]
|
|
||||||
assert len(commit_posts) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_tool_uses_memory_system_api():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
result = json.loads(provider.handle_tool_call("memory_system_search", {"query": "coffee", "limit": 3}))
|
|
||||||
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert result["items"] == [
|
|
||||||
{
|
|
||||||
"source_backend": "openviking",
|
|
||||||
"score": 0.9,
|
|
||||||
"text": "likes latte",
|
|
||||||
"uri": "viking://user/user-1/memories/a",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source_backend": "everos",
|
|
||||||
"memory_type": "profile",
|
|
||||||
"text": "prefers warm coffee",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
assert "backends" not in result
|
|
||||||
assert "vector" not in json.dumps(result)
|
|
||||||
assert "original_data" not in json.dumps(result)
|
|
||||||
assert provider._client.posts[-1] == (
|
|
||||||
"/memory-system/search",
|
|
||||||
{
|
|
||||||
"user_id": "user-1",
|
|
||||||
"user_key": "user-1-key",
|
|
||||||
"session_id": "session-1",
|
|
||||||
"query": "coffee",
|
|
||||||
"use_llm": False,
|
|
||||||
"limit": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_profile_tool_reads_user_profile():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
result = json.loads(provider.handle_tool_call("memory_system_profile", {}))
|
|
||||||
|
|
||||||
assert result["profile"] == {"coffee": "latte"}
|
|
||||||
assert provider._client.gets == ["/memory-system/users/user-1/profile?user_key=user-1-key"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_remember_tool_writes_and_commits():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
result = json.loads(provider.handle_tool_call("memory_system_remember", {"content": "likes latte"}))
|
|
||||||
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert provider._client.posts == [
|
|
||||||
(
|
|
||||||
"/memory-system/messages",
|
|
||||||
{
|
|
||||||
"user_id": "user-1",
|
|
||||||
"user_key": "user-1-key",
|
|
||||||
"session_id": "session-1",
|
|
||||||
"user_message": "likes latte",
|
|
||||||
"metadata": {"source": "hermes", "provider": "memory_system"},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"/memory-system/sessions/session-1/commit",
|
|
||||||
{"user_id": "user-1", "user_key": "user-1-key"},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_initialize_creates_user_when_user_key_is_not_configured(tmp_path, monkeypatch):
|
|
||||||
module = load_plugin_module()
|
|
||||||
for key in list(os.environ):
|
|
||||||
if key.startswith("MEMORY_SYSTEM_"):
|
|
||||||
monkeypatch.delenv(key, raising=False)
|
|
||||||
monkeypatch.setenv("MEMORY_SYSTEM_ENDPOINT", "http://127.0.0.1:1934")
|
|
||||||
monkeypatch.setenv("MEMORY_SYSTEM_USER_ID", "new-user")
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
|
|
||||||
class InitClient(FakeClient):
|
|
||||||
def __init__(self, endpoint, api_key="", timeout=0):
|
|
||||||
super().__init__()
|
|
||||||
self.endpoint = endpoint
|
|
||||||
|
|
||||||
def health(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "_MemorySystemClient", InitClient)
|
|
||||||
provider = module.MemorySystemMemoryProvider()
|
|
||||||
|
|
||||||
provider.initialize("session-1")
|
|
||||||
|
|
||||||
assert provider._user_key == "new-user-key"
|
|
||||||
assert provider._client.posts == [("/memory-system/users", {"user_id": "new-user"})]
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_tool_schemas_include_memory_management_tools():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
tool_names = {schema["name"] for schema in provider.get_tool_schemas()}
|
|
||||||
|
|
||||||
assert {
|
|
||||||
"memory_system_memory_list",
|
|
||||||
"memory_system_memory_read",
|
|
||||||
"memory_system_memory_write",
|
|
||||||
"memory_system_memory_delete",
|
|
||||||
} <= tool_names
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_list_tool_calls_memories_endpoint():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
result = json.loads(provider.handle_tool_call("memory_system_memory_list", {"recursive": True}))
|
|
||||||
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert provider._client.gets == [
|
|
||||||
"/memory-system/memories?user_id=user-1&user_key=user-1-key&uri=viking%3A%2F%2Fuser%2Fmemories&recursive=true"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_read_tool_calls_memory_content_endpoint():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
result = json.loads(provider.handle_tool_call(
|
|
||||||
"memory_system_memory_read",
|
|
||||||
{"uri": "viking://user/memories/preferences/python.md"},
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert provider._client.gets == [
|
|
||||||
"/memory-system/memories/content?user_id=user-1&user_key=user-1-key&uri=viking%3A%2F%2Fuser%2Fmemories%2Fpreferences%2Fpython.md"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_write_tool_posts_memory_payload():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
result = json.loads(provider.handle_tool_call(
|
|
||||||
"memory_system_memory_write",
|
|
||||||
{
|
|
||||||
"uri": "viking://user/memories/preferences/python.md",
|
|
||||||
"content": "# Python",
|
|
||||||
"mode": "replace",
|
|
||||||
"wait": True,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert provider._client.posts == [
|
|
||||||
(
|
|
||||||
"/memory-system/memories",
|
|
||||||
{
|
|
||||||
"user_id": "user-1",
|
|
||||||
"user_key": "user-1-key",
|
|
||||||
"uri": "viking://user/memories/preferences/python.md",
|
|
||||||
"content": "# Python",
|
|
||||||
"mode": "replace",
|
|
||||||
"wait": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_delete_tool_deletes_memory_uri_non_recursive_by_default():
|
|
||||||
provider = make_provider()
|
|
||||||
|
|
||||||
result = json.loads(provider.handle_tool_call(
|
|
||||||
"memory_system_memory_delete",
|
|
||||||
{"uri": "viking://user/memories/preferences/python.md"},
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result["status"] == "success"
|
|
||||||
assert provider._client.deletes == [
|
|
||||||
"/memory-system/memories?user_id=user-1&user_key=user-1-key&uri=viking%3A%2F%2Fuser%2Fmemories%2Fpreferences%2Fpython.md&recursive=false"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_register_adds_provider():
|
|
||||||
module = load_plugin_module()
|
|
||||||
|
|
||||||
class Ctx:
|
|
||||||
def __init__(self):
|
|
||||||
self.providers = []
|
|
||||||
|
|
||||||
def register_memory_provider(self, provider):
|
|
||||||
self.providers.append(provider)
|
|
||||||
|
|
||||||
ctx = Ctx()
|
|
||||||
module.register(ctx)
|
|
||||||
|
|
||||||
assert len(ctx.providers) == 1
|
|
||||||
assert ctx.providers[0].name == "memory_system"
|
|
||||||
@ -1,848 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from memory_system_api.clients import EverOSMemorySystemClient, OpenVikingMemorySystemClient
|
|
||||||
|
|
||||||
|
|
||||||
class FakeStore:
|
|
||||||
def __init__(self):
|
|
||||||
self.users = {}
|
|
||||||
self.accounts = {}
|
|
||||||
self.sessions = []
|
|
||||||
self.tasks = []
|
|
||||||
|
|
||||||
def get_user_key(self, user_id: str) -> str | None:
|
|
||||||
return self.users.get(user_id)
|
|
||||||
|
|
||||||
def save_user_key(self, user_id: str, user_key: str, account_id: str = "admin") -> None:
|
|
||||||
self.users[user_id] = user_key
|
|
||||||
|
|
||||||
def get_account_key(self, account_id: str) -> str | None:
|
|
||||||
return self.accounts.get(account_id)
|
|
||||||
|
|
||||||
def save_account_key(self, account_id: str, admin_user_id: str, account_key: str) -> None:
|
|
||||||
self.accounts[account_id] = account_key
|
|
||||||
|
|
||||||
def account_key_matches(self, account_id: str, account_key: str) -> bool:
|
|
||||||
return self.accounts.get(account_id) == account_key
|
|
||||||
|
|
||||||
def user_key_matches(self, user_id: str, user_key: str) -> bool:
|
|
||||||
return self.users.get(user_id) == user_key
|
|
||||||
|
|
||||||
def save_session(self, user_id: str, session_id: str) -> None:
|
|
||||||
self.sessions.append((user_id, session_id))
|
|
||||||
|
|
||||||
def save_task(self, user_id: str, session_id: str, task_id: str, archive_uri: str | None) -> None:
|
|
||||||
self.tasks.append((user_id, session_id, task_id, archive_uri))
|
|
||||||
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
def __init__(self, status_code: int, data: dict):
|
|
||||||
self.status_code = status_code
|
|
||||||
self._data = data
|
|
||||||
|
|
||||||
def json(self) -> dict:
|
|
||||||
return self._data
|
|
||||||
|
|
||||||
def raise_for_status(self) -> None:
|
|
||||||
if self.status_code >= 400:
|
|
||||||
raise AssertionError(f"unexpected status {self.status_code}")
|
|
||||||
|
|
||||||
|
|
||||||
class FakeAsyncClient:
|
|
||||||
def __init__(self, calls: list, responses: list[FakeResponse], api_key: str, headers: dict):
|
|
||||||
self.calls = calls
|
|
||||||
self.responses = responses
|
|
||||||
self.api_key = api_key
|
|
||||||
self.headers = headers
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def post(self, path: str, json: dict | None = None, files: dict | None = None) -> FakeResponse:
|
|
||||||
if files and "file" in files:
|
|
||||||
uploaded = files["file"]
|
|
||||||
files = {"file": uploaded[0] if isinstance(uploaded, tuple) else uploaded}
|
|
||||||
self.calls.append(("post", self.api_key, self.headers, path, json, files))
|
|
||||||
return self.responses.pop(0)
|
|
||||||
|
|
||||||
async def get(self, path: str, params: dict | None = None) -> FakeResponse:
|
|
||||||
self.calls.append(("get", self.api_key, self.headers, path, params))
|
|
||||||
return self.responses.pop(0)
|
|
||||||
|
|
||||||
async def delete(self, path: str, params: dict | None = None) -> FakeResponse:
|
|
||||||
self.calls.append(("delete", self.api_key, self.headers, path, params))
|
|
||||||
return self.responses.pop(0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_rejects_unknown_user_credentials():
|
|
||||||
store = FakeStore()
|
|
||||||
client = OpenVikingMemorySystemClient(store=store)
|
|
||||||
|
|
||||||
try:
|
|
||||||
client.credential_for_user("tom", "missing-key")
|
|
||||||
except PermissionError as exc:
|
|
||||||
assert "Invalid user key" in str(exc)
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected PermissionError")
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_accepts_matching_user_credentials():
|
|
||||||
store = FakeStore()
|
|
||||||
store.save_user_key("tom", "tom-key")
|
|
||||||
client = OpenVikingMemorySystemClient(store=store)
|
|
||||||
client.root_key = "root-key"
|
|
||||||
|
|
||||||
credential = client.credential_for_user("tom", "tom-key", agent_id="sess-1")
|
|
||||||
|
|
||||||
assert credential.api_key == "tom-key"
|
|
||||||
assert credential.account_id == "tom_account"
|
|
||||||
assert credential.user_id == "tom"
|
|
||||||
assert credential.agent_id == "sess-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_client_uses_x_api_key_for_user_keys():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
client.root_key = "root-key"
|
|
||||||
|
|
||||||
http_client = client._client("tom-key")
|
|
||||||
try:
|
|
||||||
assert http_client.headers["X-API-Key"] == "tom-key"
|
|
||||||
assert "Authorization" not in http_client.headers
|
|
||||||
finally:
|
|
||||||
asyncio.run(http_client.aclose())
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_create_user_creates_isolated_admin_account():
|
|
||||||
store = FakeStore()
|
|
||||||
client = OpenVikingMemorySystemClient(store=store)
|
|
||||||
client.root_key = "root-key"
|
|
||||||
calls = []
|
|
||||||
responses = [
|
|
||||||
FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": "userA_account",
|
|
||||||
"admin_user_id": "userA",
|
|
||||||
"user_key": "userA-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(client.create_user("userA"))
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": "userA_account",
|
|
||||||
"admin_user_id": "userA",
|
|
||||||
"user_key": "userA-key",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert store.accounts == {"userA_account": "userA-key"}
|
|
||||||
assert store.users == {"userA": "userA-key"}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"root-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/admin/accounts",
|
|
||||||
{"account_id": "userA_account", "admin_user_id": "userA"},
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_create_user_creates_account_even_when_admin_workspace_exists():
|
|
||||||
store = FakeStore()
|
|
||||||
store.save_account_key("admin", "admin", "admin-key")
|
|
||||||
client = OpenVikingMemorySystemClient(store=store)
|
|
||||||
client.root_key = "root-key"
|
|
||||||
calls = []
|
|
||||||
responses = [
|
|
||||||
FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": "userB_account",
|
|
||||||
"admin_user_id": "userB",
|
|
||||||
"user_key": "userB-key",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(client.create_user("userB"))
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"account_id": "userB_account",
|
|
||||||
"admin_user_id": "userB",
|
|
||||||
"user_key": "userB-key",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert store.accounts == {"admin": "admin-key", "userB_account": "userB-key"}
|
|
||||||
assert store.users == {"userB": "userB-key"}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"root-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/admin/accounts",
|
|
||||||
{"account_id": "userB_account", "admin_user_id": "userB"},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_user_key_auth_is_used_for_session_create():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
client.root_key = "root-key"
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"session_id": "sess-2"}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom", agent_id="sess-2")
|
|
||||||
|
|
||||||
result = asyncio.run(client.ensure_session(credential, "sess-2"))
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"session_id": "sess-2"}}
|
|
||||||
assert client.store.sessions == [("tom", "sess-2")]
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/sessions",
|
|
||||||
{"session_id": "sess-2"},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_find_uses_current_identity_memory_scope():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: 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.find(credential, "咖啡", 5))
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"memories": []}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/search/find",
|
|
||||||
{"query": "咖啡", "target_uri": "viking://user/tom/memories/", "limit": 5},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_search_uses_fixed_user_memory_target_with_level_and_score_threshold():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: 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.search(credential, "咖啡", 5, level=3, score_threshold=0.7))
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"memories": []}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/search/search",
|
|
||||||
{
|
|
||||||
"query": "咖啡",
|
|
||||||
"target_uri": "viking://user/memories",
|
|
||||||
"limit": 5,
|
|
||||||
"level": 3,
|
|
||||||
"score_threshold": 0.7,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_search_accepts_custom_target_uri():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: 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.search(
|
|
||||||
credential,
|
|
||||||
"咖啡",
|
|
||||||
5,
|
|
||||||
level=3,
|
|
||||||
score_threshold=0.7,
|
|
||||||
target_uri="viking://user/custom/memories",
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"memories": []}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/search/search",
|
|
||||||
{
|
|
||||||
"query": "咖啡",
|
|
||||||
"target_uri": "viking://user/custom/memories",
|
|
||||||
"limit": 5,
|
|
||||||
"level": 3,
|
|
||||||
"score_threshold": 0.7,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_profile_search_uses_user_memory_target_and_level():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"memories": []}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(client.search_profile_memories(credential, "我想喝东西", 10, 2))
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"memories": []}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/search/search",
|
|
||||||
{
|
|
||||||
"query": "我想喝东西",
|
|
||||||
"limit": 10,
|
|
||||||
"level": 2,
|
|
||||||
"target_uri": "viking://user/memories",
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
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, json_content_type=True: 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 = []
|
|
||||||
responses = [
|
|
||||||
FakeResponse(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"status": "accepted",
|
|
||||||
"task_id": "task-1",
|
|
||||||
"archive_uri": "viking://session/tom/sess-1/history/archive_001",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(client.commit_session(credential, "sess-1"))
|
|
||||||
|
|
||||||
assert result == {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"status": "accepted",
|
|
||||||
"task_id": "task-1",
|
|
||||||
"archive_uri": "viking://session/tom/sess-1/history/archive_001",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert client.store.tasks == [("tom", "sess-1", "task-1", "viking://session/tom/sess-1/history/archive_001")]
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/sessions/sess-1/commit",
|
|
||||||
{"keep_recent_count": 0},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_upload_temp_file_posts_multipart(tmp_path):
|
|
||||||
path = tmp_path / "report.pdf"
|
|
||||||
path.write_bytes(b"pdf-bytes")
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [
|
|
||||||
FakeResponse(
|
|
||||||
200,
|
|
||||||
{"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(client.upload_temp_file(credential, path))
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/resources/temp_upload",
|
|
||||||
None,
|
|
||||||
{"file": "report.pdf"},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_add_resource_posts_url_payload():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
client.add_resource(
|
|
||||||
credential,
|
|
||||||
path="https://example.com/photo.png",
|
|
||||||
to="viking://resources/tom/images/photo.png",
|
|
||||||
reason="上传远程图片",
|
|
||||||
wait=True,
|
|
||||||
directly_upload_media=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/resources",
|
|
||||||
{
|
|
||||||
"path": "https://example.com/photo.png",
|
|
||||||
"to": "viking://resources/tom/images/photo.png",
|
|
||||||
"reason": "上传远程图片",
|
|
||||||
"wait": True,
|
|
||||||
"directly_upload_media": True,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_delete_resource_sends_uri_and_recursive_flag():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"estimated_deleted_count": 4}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
client.delete_resource(
|
|
||||||
credential,
|
|
||||||
uri="viking://resources/tom/files/report.pdf",
|
|
||||||
recursive=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"estimated_deleted_count": 4}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"delete",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/fs",
|
|
||||||
{"uri": "viking://resources/tom/files/report.pdf", "recursive": "true"},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_list_memories_calls_fs_ls_with_recursive_flag():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"children": []}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
client.list_memories(
|
|
||||||
credential,
|
|
||||||
uri="viking://user/memories",
|
|
||||||
recursive=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"children": []}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"get",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/fs/ls",
|
|
||||||
{"uri": "viking://user/memories", "recursive": "true"},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_read_memory_calls_content_read():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"content": "# Python"}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(client.read_memory(credential, "viking://user/memories/preferences/python.md"))
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"content": "# Python"}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"get",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/content/read",
|
|
||||||
{"uri": "viking://user/memories/preferences/python.md"},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_write_memory_posts_content_write_mode_and_wait():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"uri": "viking://user/memories/profile.md"}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
client.write_memory(
|
|
||||||
credential,
|
|
||||||
uri="viking://user/memories/profile.md",
|
|
||||||
content="# Profile\n\nLikes Python.",
|
|
||||||
mode="replace",
|
|
||||||
wait=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"uri": "viking://user/memories/profile.md"}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/content/write",
|
|
||||||
{
|
|
||||||
"uri": "viking://user/memories/profile.md",
|
|
||||||
"content": "# Profile\n\nLikes Python.",
|
|
||||||
"mode": "replace",
|
|
||||||
"wait": True,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_openviking_delete_memory_defaults_non_recursive():
|
|
||||||
client = OpenVikingMemorySystemClient(store=FakeStore())
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"status": "ok", "result": {"estimated_deleted_count": 1}})]
|
|
||||||
client._client = lambda api_key, extra_headers=None, json_content_type=True: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
api_key,
|
|
||||||
extra_headers or {},
|
|
||||||
)
|
|
||||||
credential = client.user_credential("tom-key", "tom")
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
client.delete_memory(
|
|
||||||
credential,
|
|
||||||
uri="viking://user/memories/preferences/python.md",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"status": "ok", "result": {"estimated_deleted_count": 1}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"delete",
|
|
||||||
"tom-key",
|
|
||||||
{},
|
|
||||||
"/api/v1/fs",
|
|
||||||
{"uri": "viking://user/memories/preferences/python.md", "recursive": "false"},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_everos_append_message_posts_current_memory_add_contract():
|
|
||||||
client = EverOSMemorySystemClient()
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"status": "accumulated", "message_count": 1}})]
|
|
||||||
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
client.api_key or "",
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(client.append_message("tom", "sess-1", "user", "我喜欢拿铁"))
|
|
||||||
|
|
||||||
assert result == {"request_id": "req-1", "data": {"status": "accumulated", "message_count": 1}}
|
|
||||||
assert calls[0][0] == "post"
|
|
||||||
assert calls[0][3] == "/api/v1/memory/add"
|
|
||||||
payload = calls[0][4]
|
|
||||||
assert payload["session_id"] == "sess-1"
|
|
||||||
assert "user_id" not in payload
|
|
||||||
assert payload["messages"][0]["sender_id"] == "tom"
|
|
||||||
assert payload["messages"][0]["role"] == "user"
|
|
||||||
assert payload["messages"][0]["content"] == "我喜欢拿铁"
|
|
||||||
|
|
||||||
|
|
||||||
def test_everos_flush_posts_current_memory_flush_contract():
|
|
||||||
client = EverOSMemorySystemClient()
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"status": "extracted"}})]
|
|
||||||
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
client.api_key or "",
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(client.flush("tom", "sess-1"))
|
|
||||||
|
|
||||||
assert result == {"request_id": "req-1", "data": {"status": "extracted"}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
client.api_key or "",
|
|
||||||
{},
|
|
||||||
"/api/v1/memory/flush",
|
|
||||||
{"session_id": "sess-1"},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_everos_search_posts_current_memory_search_contract():
|
|
||||||
client = EverOSMemorySystemClient()
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"episodes": []}})]
|
|
||||||
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
client.api_key or "",
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(client.search("tom", "sess-1", "牛奶在哪里", "hybrid", 7))
|
|
||||||
|
|
||||||
assert result == {"request_id": "req-1", "data": {"episodes": []}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
client.api_key or "",
|
|
||||||
{},
|
|
||||||
"/api/v1/memory/search",
|
|
||||||
{
|
|
||||||
"user_id": "tom",
|
|
||||||
"query": "牛奶在哪里",
|
|
||||||
"method": "hybrid",
|
|
||||||
"top_k": 7,
|
|
||||||
"include_profile": True,
|
|
||||||
"filters": {"session_id": "sess-1"},
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_everos_get_profile_posts_current_memory_get_contract():
|
|
||||||
client = EverOSMemorySystemClient()
|
|
||||||
calls = []
|
|
||||||
responses = [FakeResponse(200, {"request_id": "req-1", "data": {"profiles": []}})]
|
|
||||||
client._client = lambda: FakeAsyncClient( # type: ignore[method-assign]
|
|
||||||
calls,
|
|
||||||
responses,
|
|
||||||
client.api_key or "",
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(client.get_profile("tom"))
|
|
||||||
|
|
||||||
assert result == {"request_id": "req-1", "data": {"profiles": []}}
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"post",
|
|
||||||
client.api_key or "",
|
|
||||||
{},
|
|
||||||
"/api/v1/memory/get",
|
|
||||||
{
|
|
||||||
"user_id": "tom",
|
|
||||||
"memory_type": "profile",
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
|
|
||||||
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/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/resources" in paths
|
|
||||||
assert "/memory-system/memories" in paths
|
|
||||||
assert "/memory-system/memories/content" in paths
|
|
||||||
assert "/memory-system/users/{user_id}/profile" in paths
|
|
||||||
task_methods = {
|
|
||||||
method
|
|
||||||
for route in app.routes
|
|
||||||
if getattr(route, "path", "") == "/memory-system/openviking/tasks/{task_id}"
|
|
||||||
for method in getattr(route, "methods", set())
|
|
||||||
}
|
|
||||||
profile_methods = {
|
|
||||||
method
|
|
||||||
for route in app.routes
|
|
||||||
if getattr(route, "path", "") == "/memory-system/users/{user_id}/profile"
|
|
||||||
for method in getattr(route, "methods", set())
|
|
||||||
}
|
|
||||||
assert {"GET", "POST"} <= task_methods
|
|
||||||
assert {"GET", "POST"} <= profile_methods
|
|
||||||
resource_methods = {
|
|
||||||
method
|
|
||||||
for route in app.routes
|
|
||||||
if getattr(route, "path", "") == "/memory-system/resources"
|
|
||||||
for method in getattr(route, "methods", set())
|
|
||||||
}
|
|
||||||
assert {"DELETE", "POST"} <= resource_methods
|
|
||||||
memory_methods = {
|
|
||||||
method
|
|
||||||
for route in app.routes
|
|
||||||
if getattr(route, "path", "") == "/memory-system/memories"
|
|
||||||
for method in getattr(route, "methods", set())
|
|
||||||
}
|
|
||||||
memory_content_methods = {
|
|
||||||
method
|
|
||||||
for route in app.routes
|
|
||||||
if getattr(route, "path", "") == "/memory-system/memories/content"
|
|
||||||
for method in getattr(route, "methods", set())
|
|
||||||
}
|
|
||||||
assert {"DELETE", "GET", "POST"} <= memory_methods
|
|
||||||
assert {"GET"} <= memory_content_methods
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_system_messages_does_not_require_account_key_header():
|
|
||||||
from memory_system_api.server import app
|
|
||||||
|
|
||||||
route = next(route for route in app.routes if getattr(route, "path", "") == "/memory-system/messages")
|
|
||||||
|
|
||||||
assert all(getattr(dependency.call, "__name__", "") != "account_key_header" for dependency in route.dependant.dependencies)
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_system_logs_request_and_response_bodies(caplog):
|
|
||||||
from memory_system_api.api import get_service
|
|
||||||
from memory_system_api.server import app
|
|
||||||
|
|
||||||
class FakeService:
|
|
||||||
async def create_user(self, user_id: str):
|
|
||||||
return {"status": "success", "account": {"user_id": user_id}}
|
|
||||||
|
|
||||||
app.dependency_overrides[get_service] = lambda: FakeService()
|
|
||||||
|
|
||||||
try:
|
|
||||||
with caplog.at_level(logging.INFO, logger="memory_system_api.requests"):
|
|
||||||
response = TestClient(app).post("/memory-system/users", json={"user_id": "userA"})
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success", "account": {"user_id": "userA"}}
|
|
||||||
assert any("request POST /memory-system/users body={\"user_id\":\"userA\"}" in record.message for record in caplog.records)
|
|
||||||
assert any(
|
|
||||||
"response POST /memory-system/users status=200 body={\"status\":\"success\",\"account\":{\"user_id\":\"userA\"}}"
|
|
||||||
in record.message
|
|
||||||
for record in caplog.records
|
|
||||||
)
|
|
||||||
@ -1,725 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from memory_system_api.schemas import (
|
|
||||||
MemoryWriteRequest,
|
|
||||||
MessageIngestRequest,
|
|
||||||
ResourceUploadRequest,
|
|
||||||
SearchRequest,
|
|
||||||
SessionContextRequest,
|
|
||||||
)
|
|
||||||
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 create_user(self, user_id: str) -> dict:
|
|
||||||
self.calls.append(("create_user", user_id))
|
|
||||||
return {"account_id": f"{user_id}_account", "admin_user_id": user_id, "user_key": f"{user_id}-key"}
|
|
||||||
|
|
||||||
def credential_for_user(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
user_key: str,
|
|
||||||
agent_id: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
self.calls.append(("credential_for_user", user_id, user_key, agent_id))
|
|
||||||
if user_key != f"{user_id}-key":
|
|
||||||
raise PermissionError("Invalid user key")
|
|
||||||
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, query: str, limit: int) -> dict:
|
|
||||||
self.calls.append(("find", user_key, query, limit))
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
return {"items": [{"source": "openviking-find"}]}
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
user_key: str,
|
|
||||||
query: str,
|
|
||||||
limit: int,
|
|
||||||
level: int = 2,
|
|
||||||
score_threshold: float = 0.8,
|
|
||||||
target_uri: str = "viking://user/memories",
|
|
||||||
) -> dict:
|
|
||||||
self.calls.append(("search", user_key, query, limit, level, score_threshold, target_uri))
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
return {"items": [{"source": "openviking-search"}]}
|
|
||||||
|
|
||||||
async def search_profile_memories(self, user_key: str, query: str, limit: int, level: int) -> dict:
|
|
||||||
self.calls.append(("search_profile_memories", user_key, query, limit, level))
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {
|
|
||||||
"memories": [
|
|
||||||
{
|
|
||||||
"context_type": "memory",
|
|
||||||
"uri": "viking://user/tom/memories/preferences/coffee.md",
|
|
||||||
"level": 2,
|
|
||||||
"score": 0.91,
|
|
||||||
"abstract": "用户喜欢喝咖啡。",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resources": [],
|
|
||||||
"skills": [],
|
|
||||||
"total": 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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"}}
|
|
||||||
|
|
||||||
async def upload_temp_file(self, user_key: str, path) -> dict:
|
|
||||||
self.calls.append(("upload_temp_file", user_key, str(path)))
|
|
||||||
return {"status": "ok", "result": {"temp_file_id": "upload_report.pdf"}}
|
|
||||||
|
|
||||||
async def add_resource(
|
|
||||||
self,
|
|
||||||
user_key: str,
|
|
||||||
*,
|
|
||||||
to: str,
|
|
||||||
reason: str | None,
|
|
||||||
wait: bool,
|
|
||||||
directly_upload_media: bool,
|
|
||||||
path: str | None = None,
|
|
||||||
temp_file_id: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
self.calls.append((
|
|
||||||
"add_resource",
|
|
||||||
user_key,
|
|
||||||
path,
|
|
||||||
temp_file_id,
|
|
||||||
to,
|
|
||||||
reason,
|
|
||||||
wait,
|
|
||||||
directly_upload_media,
|
|
||||||
))
|
|
||||||
return {"status": "ok", "result": {"uri": to}}
|
|
||||||
|
|
||||||
async def delete_resource(self, user_key: str, uri: str, recursive: bool = True) -> dict:
|
|
||||||
self.calls.append(("delete_resource", user_key, uri, recursive))
|
|
||||||
return {"status": "ok", "result": {"uri": uri, "estimated_deleted_count": 4}}
|
|
||||||
|
|
||||||
async def list_memories(self, user_key: str, uri: str, recursive: bool = True) -> dict:
|
|
||||||
self.calls.append(("list_memories", user_key, uri, recursive))
|
|
||||||
return {"status": "ok", "result": {"children": [{"uri": "viking://user/memories/profile.md"}]}}
|
|
||||||
|
|
||||||
async def read_memory(self, user_key: str, uri: str) -> dict:
|
|
||||||
self.calls.append(("read_memory", user_key, uri))
|
|
||||||
return {"status": "ok", "result": {"uri": uri, "content": "# Profile"}}
|
|
||||||
|
|
||||||
async def write_memory(self, user_key: str, uri: str, content: str, mode: str, wait: bool = True) -> dict:
|
|
||||||
self.calls.append(("write_memory", user_key, uri, content, mode, wait))
|
|
||||||
return {"status": "ok", "result": {"uri": uri, "mode": mode}}
|
|
||||||
|
|
||||||
async def delete_memory(self, user_key: str, uri: str, recursive: bool = False) -> dict:
|
|
||||||
self.calls.append(("delete_memory", user_key, uri, recursive))
|
|
||||||
return {"status": "ok", "result": {"uri": uri, "estimated_deleted_count": 1}}
|
|
||||||
|
|
||||||
|
|
||||||
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}"}]}
|
|
||||||
|
|
||||||
async def flush(self, user_id: str, session_id: str) -> dict:
|
|
||||||
self.calls.append(("flush", user_id, session_id))
|
|
||||||
return {"status": "flushed"}
|
|
||||||
|
|
||||||
async def get_profile(self, user_id: str) -> dict:
|
|
||||||
self.calls.append(("get_profile", user_id))
|
|
||||||
return {
|
|
||||||
"data": {
|
|
||||||
"episodes": [],
|
|
||||||
"profiles": [
|
|
||||||
{
|
|
||||||
"id": "profile-1",
|
|
||||||
"user_id": user_id,
|
|
||||||
"profile_data": {"summary": "喜欢咖啡"},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"agent_cases": [],
|
|
||||||
"agent_skills": [],
|
|
||||||
"total_count": 1,
|
|
||||||
"count": 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FakeEverOSWithVector(FakeEverOS):
|
|
||||||
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))
|
|
||||||
return {
|
|
||||||
"data": {
|
|
||||||
"episodes": [{"id": "episode-1", "vector": [0.1, 0.2]}],
|
|
||||||
"original_data": {
|
|
||||||
"episodes": {
|
|
||||||
"episode-1": {
|
|
||||||
"summary": "喜欢拿铁",
|
|
||||||
"vector": [0.1, 0.2],
|
|
||||||
"nested": {"vector": [0.3]},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class FakeEverOSVerbose(FakeEverOS):
|
|
||||||
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))
|
|
||||||
return {
|
|
||||||
"data": {
|
|
||||||
"episodes": [
|
|
||||||
{
|
|
||||||
"id": "episode-1",
|
|
||||||
"user_id": user_id,
|
|
||||||
"session_id": session_id,
|
|
||||||
"timestamp": "2026-05-22T07:50:51.750000Z",
|
|
||||||
"summary": "userB 在对话中表示自己喜欢拿铁。",
|
|
||||||
"subject": "UserB 表达对拿铁的喜好",
|
|
||||||
"episode": "userB 在对话中表示自己喜欢拿铁。",
|
|
||||||
"type": "Conversation",
|
|
||||||
"parent_id": "parent-1",
|
|
||||||
"score": 0.72,
|
|
||||||
"atomic_facts": [],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"profiles": [],
|
|
||||||
"raw_messages": [],
|
|
||||||
"query": {
|
|
||||||
"text": query,
|
|
||||||
"method": method,
|
|
||||||
"filters_applied": {"user_id": user_id, "session_id": session_id},
|
|
||||||
},
|
|
||||||
"original_data": {
|
|
||||||
"episodes": {
|
|
||||||
"episode-1": {
|
|
||||||
"id": "episode-1",
|
|
||||||
"summary": "userB 在对话中表示自己喜欢拿铁。",
|
|
||||||
"episode": "userB 在对话中表示自己喜欢拿铁。",
|
|
||||||
"vector_model": "Qwen3-VL-Embedding-2B",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_capture_includes_exception_type_when_message_is_empty():
|
|
||||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOS())
|
|
||||||
|
|
||||||
class EmptyError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def fail():
|
|
||||||
raise EmptyError()
|
|
||||||
|
|
||||||
response = asyncio.run(service._capture(fail))
|
|
||||||
|
|
||||||
assert response.status == "failed"
|
|
||||||
assert response.error == "EmptyError"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_user_delegates_to_openviking_only():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.create_user("alice"))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.account == {"account_id": "alice_account", "admin_user_id": "alice", "user_key": "alice-key"}
|
|
||||||
assert openviking.calls == [("create_user", "alice")]
|
|
||||||
assert everos.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_upload_resource_with_url_delegates_directly_to_openviking_add_resource():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=FakeEverOS())
|
|
||||||
|
|
||||||
response = asyncio.run(service.upload_resource(ResourceUploadRequest(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
path="https://example.com/images/photo.png",
|
|
||||||
to="viking://resources/tom/images/photo.png",
|
|
||||||
reason="上传远程图片",
|
|
||||||
)))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/images/photo.png"}}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
(
|
|
||||||
"add_resource",
|
|
||||||
"key-tom",
|
|
||||||
"https://example.com/images/photo.png",
|
|
||||||
None,
|
|
||||||
"viking://resources/tom/images/photo.png",
|
|
||||||
"上传远程图片",
|
|
||||||
True,
|
|
||||||
True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_upload_resource_with_local_path_uploads_temp_file_first(tmp_path):
|
|
||||||
path = tmp_path / "report.pdf"
|
|
||||||
path.write_bytes(b"pdf-bytes")
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=FakeEverOS())
|
|
||||||
|
|
||||||
response = asyncio.run(service.upload_resource(ResourceUploadRequest(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
path=str(path),
|
|
||||||
to="viking://resources/tom/files/report.pdf",
|
|
||||||
reason="上传本地文件",
|
|
||||||
)))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.resource == {"status": "ok", "result": {"uri": "viking://resources/tom/files/report.pdf"}}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
("upload_temp_file", "key-tom", str(path)),
|
|
||||||
(
|
|
||||||
"add_resource",
|
|
||||||
"key-tom",
|
|
||||||
None,
|
|
||||||
"upload_report.pdf",
|
|
||||||
"viking://resources/tom/files/report.pdf",
|
|
||||||
"上传本地文件",
|
|
||||||
True,
|
|
||||||
True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_resource_delegates_to_openviking_only():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.delete_resource(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
uri="viking://resources/tom/files/report.pdf",
|
|
||||||
recursive=True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.resource == {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {"uri": "viking://resources/tom/files/report.pdf", "estimated_deleted_count": 4},
|
|
||||||
}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
("delete_resource", "key-tom", "viking://resources/tom/files/report.pdf", True),
|
|
||||||
]
|
|
||||||
assert everos.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_memories_delegates_to_openviking_only():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.list_memories(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
uri="viking://user/memories",
|
|
||||||
recursive=True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.memory == {"status": "ok", "result": {"children": [{"uri": "viking://user/memories/profile.md"}]}}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
("list_memories", "key-tom", "viking://user/memories", True),
|
|
||||||
]
|
|
||||||
assert everos.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_memory_delegates_to_openviking_only():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.read_memory(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
uri="viking://user/memories/profile.md",
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.memory == {"status": "ok", "result": {"uri": "viking://user/memories/profile.md", "content": "# Profile"}}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
("read_memory", "key-tom", "viking://user/memories/profile.md"),
|
|
||||||
]
|
|
||||||
assert everos.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_memory_delegates_to_openviking_content_write_only():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.write_memory(MemoryWriteRequest(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
uri="viking://user/memories/profile.md",
|
|
||||||
content="# Profile\n\nLikes Python.",
|
|
||||||
mode="replace",
|
|
||||||
wait=True,
|
|
||||||
)))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.memory == {"status": "ok", "result": {"uri": "viking://user/memories/profile.md", "mode": "replace"}}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
("write_memory", "key-tom", "viking://user/memories/profile.md", "# Profile\n\nLikes Python.", "replace", True),
|
|
||||||
]
|
|
||||||
assert everos.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_memory_supports_openviking_create_mode():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.write_memory(MemoryWriteRequest(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
uri="viking://user/tom/memories/preferences/饮食偏好.md",
|
|
||||||
content="用户喜欢喝茶",
|
|
||||||
mode="create",
|
|
||||||
wait=True,
|
|
||||||
)))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.memory == {"status": "ok", "result": {"uri": "viking://user/tom/memories/preferences/饮食偏好.md", "mode": "create"}}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
("write_memory", "key-tom", "viking://user/tom/memories/preferences/饮食偏好.md", "用户喜欢喝茶", "create", True),
|
|
||||||
]
|
|
||||||
assert everos.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_memory_delegates_to_openviking_only_and_defaults_non_recursive():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.delete_memory(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
uri="viking://user/memories/preferences/python.md",
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.memory == {
|
|
||||||
"status": "ok",
|
|
||||||
"result": {"uri": "viking://user/memories/preferences/python.md", "estimated_deleted_count": 1},
|
|
||||||
}
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", None),
|
|
||||||
("delete_memory", "key-tom", "viking://user/memories/preferences/python.md", False),
|
|
||||||
]
|
|
||||||
assert everos.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_removes_vectors_from_items_and_backend_results():
|
|
||||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOSWithVector())
|
|
||||||
|
|
||||||
response = asyncio.run(service.search(
|
|
||||||
SearchRequest(user_id="tom", user_key="tom-key", session_id="sess-1", query="咖啡偏好", use_llm=False, limit=5),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.items == [
|
|
||||||
{"source_backend": "openviking", "source": "openviking-search"},
|
|
||||||
{"source_backend": "everos", "memory_type": "episode", "id": "episode-1"},
|
|
||||||
]
|
|
||||||
assert not _has_key(response.backends["everos"].result, "vector")
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_returns_compact_items_and_backend_diagnostics_without_duplicate_raw_payloads():
|
|
||||||
service = MemorySystemService(openviking=FakeOpenViking(), everos=FakeEverOSVerbose())
|
|
||||||
|
|
||||||
response = asyncio.run(service.search(
|
|
||||||
SearchRequest(user_id="tom", user_key="tom-key", session_id="sess-1", query="我喜欢喝什么?", use_llm=True),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.items == [
|
|
||||||
{"source_backend": "openviking", "source": "openviking-search"},
|
|
||||||
{
|
|
||||||
"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 response.backends["everos"].result == {
|
|
||||||
"counts": {"episodes": 1, "profiles": 0, "raw_messages": 0},
|
|
||||||
"query": {
|
|
||||||
"text": "我喜欢喝什么?",
|
|
||||||
"method": "agentic",
|
|
||||||
"filters_applied": {"user_id": "tom", "session_id": "sess-1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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 test_profile_combines_everos_profile_and_openviking_memory_search():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.get_profile("tom", "tom-key", query="我想喝东西", limit=10, level=2))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.profile == {
|
|
||||||
"data": {
|
|
||||||
"episodes": [],
|
|
||||||
"profiles": [
|
|
||||||
{
|
|
||||||
"id": "profile-1",
|
|
||||||
"user_id": "tom",
|
|
||||||
"profile_data": {"summary": "喜欢咖啡"},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"agent_cases": [],
|
|
||||||
"agent_skills": [],
|
|
||||||
"total_count": 1,
|
|
||||||
"count": 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert response.items == [
|
|
||||||
{
|
|
||||||
"source_backend": "openviking",
|
|
||||||
"context_type": "memory",
|
|
||||||
"uri": "viking://user/tom/memories/preferences/coffee.md",
|
|
||||||
"level": 2,
|
|
||||||
"score": 0.91,
|
|
||||||
"abstract": "用户喜欢喝咖啡。",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
assert response.backends["everos"].result == {
|
|
||||||
"total_count": 1,
|
|
||||||
"count": 1,
|
|
||||||
"counts": {
|
|
||||||
"episodes": 0,
|
|
||||||
"profiles": 1,
|
|
||||||
"agent_cases": 0,
|
|
||||||
"agent_skills": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert "profiles" not in response.backends["everos"].result
|
|
||||||
assert ("credential_for_user", "tom", "tom-key", None) in openviking.calls
|
|
||||||
assert ("search_profile_memories", "key-tom", "我想喝东西", 10, 2) in openviking.calls
|
|
||||||
assert everos.calls == [("get_profile", "tom")]
|
|
||||||
|
|
||||||
|
|
||||||
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())
|
|
||||||
if isinstance(value, list):
|
|
||||||
return any(_has_key(item, key) for item in value)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
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",
|
|
||||||
user_key="tom-key",
|
|
||||||
session_id="sess-1",
|
|
||||||
user_message="我喜欢拿铁",
|
|
||||||
assistant_message="我记住了",
|
|
||||||
)
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.message_count == 2
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", "sess-1"),
|
|
||||||
("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", user_key="tom-key", 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", user_key="tom-key", 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_commit_uses_user_key_without_account_id():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.commit_session("tom", "tom-key", "sess-1"))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert openviking.calls == [
|
|
||||||
("credential_for_user", "tom", "tom-key", "sess-1"),
|
|
||||||
("commit_session", "key-tom", "sess-1"),
|
|
||||||
]
|
|
||||||
assert everos.calls == [("flush", "tom", "sess-1")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_uses_openviking_search_and_hybrid_without_llm():
|
|
||||||
openviking = FakeOpenViking()
|
|
||||||
everos = FakeEverOS()
|
|
||||||
service = MemorySystemService(openviking=openviking, everos=everos)
|
|
||||||
|
|
||||||
response = asyncio.run(service.search(
|
|
||||||
SearchRequest(
|
|
||||||
user_id="tom",
|
|
||||||
user_key="tom-key",
|
|
||||||
session_id="sess-1",
|
|
||||||
query="咖啡偏好",
|
|
||||||
use_llm=False,
|
|
||||||
limit=5,
|
|
||||||
level=3,
|
|
||||||
score_threshold=0.7,
|
|
||||||
target_uri="viking://user/custom/memories",
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert response.status == "success"
|
|
||||||
assert response.items == [
|
|
||||||
{"source_backend": "openviking", "source": "openviking-search"},
|
|
||||||
{"source_backend": "everos", "source": "everos-hybrid"},
|
|
||||||
]
|
|
||||||
assert ("credential_for_user", "tom", "tom-key", "sess-1") in openviking.calls
|
|
||||||
assert ("search", "key-tom", "咖啡偏好", 5, 3, 0.7, "viking://user/custom/memories") 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", user_key="tom-key", 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 ("credential_for_user", "tom", "tom-key", "sess-1") in openviking.calls
|
|
||||||
assert ("search", "key-tom", "咖啡偏好", 5, 2, 0.8, "viking://user/memories") in openviking.calls
|
|
||||||
assert ("search", "tom", "sess-1", "咖啡偏好", "agentic", 5) in everos.calls
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
from memory_system_api.store import OpenVikingUserKeyStore
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_persists_openviking_user_session_and_task_metadata(tmp_path):
|
|
||||||
db_path = tmp_path / "memory.sqlite3"
|
|
||||||
store = OpenVikingUserKeyStore(str(db_path))
|
|
||||||
|
|
||||||
store.save_user_key("userA", "userA-key")
|
|
||||||
store.save_session("userA", "sessionA1")
|
|
||||||
store.save_task(
|
|
||||||
user_id="userA",
|
|
||||||
session_id="sessionA1",
|
|
||||||
task_id="task-1",
|
|
||||||
archive_uri="viking://session/userA/sessionA1/history/archive_001",
|
|
||||||
)
|
|
||||||
|
|
||||||
reopened = OpenVikingUserKeyStore(str(db_path))
|
|
||||||
|
|
||||||
assert reopened.get_user_key("userA") == "userA-key"
|
|
||||||
assert reopened.user_key_matches("userA", "userA-key")
|
|
||||||
assert reopened.get_session("userA", "sessionA1") == {
|
|
||||||
"user_id": "userA",
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"latest_task_id": "task-1",
|
|
||||||
"latest_archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
|
||||||
}
|
|
||||||
assert reopened.get_task("task-1") == {
|
|
||||||
"task_id": "task-1",
|
|
||||||
"user_id": "userA",
|
|
||||||
"session_id": "sessionA1",
|
|
||||||
"archive_uri": "viking://session/userA/sessionA1/history/archive_001",
|
|
||||||
}
|
|
||||||
Binary file not shown.
545
uv.lock
generated
545
uv.lock
generated
@ -1,545 +0,0 @@
|
|||||||
version = 1
|
|
||||||
revision = 3
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "annotated-doc"
|
|
||||||
version = "0.0.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "annotated-types"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anyio"
|
|
||||||
version = "4.13.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
||||||
{ name = "idna" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2026.5.20"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "click"
|
|
||||||
version = "8.4.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "exceptiongroup"
|
|
||||||
version = "1.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastapi"
|
|
||||||
version = "0.136.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "annotated-doc" },
|
|
||||||
{ name = "pydantic" },
|
|
||||||
{ name = "starlette" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "typing-inspection" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h11"
|
|
||||||
version = "0.16.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpcore"
|
|
||||||
version = "1.0.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "h11" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpx"
|
|
||||||
version = "0.28.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "httpcore" },
|
|
||||||
{ name = "idna" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.16"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "iniconfig"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memory-system-api"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { editable = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "fastapi" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
{ name = "pydantic" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "uvicorn" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "ruff" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "fastapi", specifier = ">=0.109.0" },
|
|
||||||
{ name = "httpx", specifier = ">=0.26.0" },
|
|
||||||
{ name = "pydantic", specifier = ">=2.5.0" },
|
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
|
||||||
{ name = "pyyaml", specifier = ">=6.0" },
|
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
|
||||||
{ name = "uvicorn", specifier = ">=0.27.0" },
|
|
||||||
]
|
|
||||||
provides-extras = ["dev"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "packaging"
|
|
||||||
version = "26.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pluggy"
|
|
||||||
version = "1.6.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pydantic"
|
|
||||||
version = "2.13.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "annotated-types" },
|
|
||||||
{ name = "pydantic-core" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "typing-inspection" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pydantic-core"
|
|
||||||
version = "2.46.4"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pygments"
|
|
||||||
version = "2.20.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pytest"
|
|
||||||
version = "9.0.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
||||||
{ name = "iniconfig" },
|
|
||||||
{ name = "packaging" },
|
|
||||||
{ name = "pluggy" },
|
|
||||||
{ name = "pygments" },
|
|
||||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyyaml"
|
|
||||||
version = "6.0.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ruff"
|
|
||||||
version = "0.15.14"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "starlette"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "anyio" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tomli"
|
|
||||||
version = "2.4.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.15.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-inspection"
|
|
||||||
version = "0.4.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uvicorn"
|
|
||||||
version = "0.48.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "click" },
|
|
||||||
{ name = "h11" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user