Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-05-08 17:40:11 +08:00
parent 602c2bd71b
commit 0acee1ec6c
20 changed files with 5410 additions and 79 deletions

View File

@ -0,0 +1,93 @@
# OpenViking Adapter Config
## Overview
Memory Gateway v2 keeps the OpenViking ingest adapter in `offline` / `skeleton`
mode by default. In the default configuration it does not send any HTTP
requests.
## Modes
### Offline
`mode: offline`
The adapter must not touch the network. It returns fixture-backed normalized
results through the existing skeleton path.
### Skeleton
`mode: skeleton`
This behaves like `offline` for now. It keeps the same normalized result path
without sending HTTP requests.
### Real
Real mode is enabled only when:
- `mode: real`
When real mode is active, the adapter may send an HTTP request for OpenViking
ingest only. Commit and retrieve remain offline/skeleton in the current phase.
The legacy `enabled` field is retained for config compatibility, but it does
not open the network path by itself.
## Config Fields
- `base_url`
The OpenViking API base URL.
- `api_key`
Token used only for request headers.
- `timeout`
Request timeout in seconds.
- `verify_ssl`
TLS verification toggle for the real HTTP path.
- `ingest_path`
Configurable ingest endpoint path template. The current placeholder is
`/api/v1/sessions/{session_id}/messages`.
## Example Config
### Offline Example
```yaml
openviking:
enabled: false
mode: offline
url: http://localhost:1933
timeout: 30
verify_ssl: true
```
### Real Example
```yaml
openviking:
enabled: false
mode: real
url: https://openviking.example.internal
api_key: YOUR_OPENVIKING_TOKEN
timeout: 30
verify_ssl: true
ingest_path: /api/v1/sessions/{session_id}/messages
```
## Security
Runtime ingest requests may temporarily include `content` while the current
request is in flight. Memory Gateway does not persist `content`,
`raw_request`, `messages`, or `transcript` into SQLite metadata, outbox
payloads, or audit summaries.
`api_key` / tokens are used only in request headers. They do not belong in:
- adapter result metadata
- audit summaries
- persisted MemoryRef metadata
- error messages
## Notes
The current ingest endpoint path is still a configurable placeholder. It should
be calibrated once the real OpenViking API contract is stable.

90
memory_gateway/api_v2.py Normal file
View File

@ -0,0 +1,90 @@
"""Memory Gateway v2 workflow API."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Query
from .schemas_v2 import (
BackendRefStatus,
BackendType,
CommitJobView,
CommitRequest,
CommitResponse,
FeedbackRequest,
FeedbackResponse,
IngestRequest,
IngestResponse,
MemoryRefType,
MemoryRefView,
OutboxProcessResponse,
RetrieveRequest,
RetrieveResponse,
)
from .server_auth import verify_api_key_compat
from .services_v2 import v2_service
router = APIRouter(prefix="/v2", tags=["memory-v2"], dependencies=[Depends(verify_api_key_compat)])
@router.post("/conversations/ingest", response_model=IngestResponse)
async def ingest_conversation(request: IngestRequest):
return await v2_service.ingest_conversation_turn(request)
@router.post("/conversations/{session_id}/commit", response_model=CommitResponse)
async def commit_conversation(session_id: str, request: CommitRequest):
return await v2_service.commit_session(session_id, request)
@router.get("/jobs/{job_id}", response_model=CommitJobView)
async def get_commit_job(job_id: str):
return v2_service.get_commit_job_view(job_id)
@router.post("/context/retrieve", response_model=RetrieveResponse)
async def retrieve_context(request: RetrieveRequest):
return await v2_service.retrieve_context(request)
@router.get("/memory/refs", response_model=list[MemoryRefView])
async def list_memory_refs(
workspace_id: Optional[str] = Query(default=None),
user_id: Optional[str] = Query(default=None),
agent_id: Optional[str] = Query(default=None),
session_id: Optional[str] = Query(default=None),
namespace: Optional[str] = Query(default=None),
backend_type: Optional[BackendType] = Query(default=None),
ref_type: Optional[MemoryRefType] = Query(default=None),
status: Optional[BackendRefStatus] = Query(default=None),
limit: int = Query(default=100, ge=1, le=1000),
):
return v2_service.list_memory_refs(
workspace_id=workspace_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
namespace=namespace,
backend_type=backend_type,
ref_type=ref_type,
status=status,
limit=limit,
)
@router.post("/memory/feedback", response_model=FeedbackResponse)
async def memory_feedback(request: FeedbackRequest):
return await v2_service.record_memory_feedback(request)
@router.post("/admin/outbox/process", response_model=OutboxProcessResponse, tags=["memory-v2-admin"])
async def process_outbox(
limit: int = Query(default=100, ge=1, le=1000),
worker_id: Optional[str] = Query(default=None),
lease_seconds: int = Query(default=300, ge=1, le=3600),
):
return await v2_service.process_pending_outbox_events_summary(
limit=limit,
worker_id=worker_id,
lease_seconds=lease_seconds,
)

View File

@ -0,0 +1,129 @@
"""Contract-first mapping spec for future v2 backend adapters.
This module intentionally does not call OpenViking, EverMemOS, or Obsidian.
It documents the stable Gateway control-plane fields that may be persisted in
outbox payload refs, SQLite metadata_json, audit summaries, and related control
records. It is not a validator for transient runtime adapter request objects:
the Gateway may pass conversation content to a backend during the current
request lifecycle, but must not persist that content in SQLite/outbox/audit
control-plane records.
"""
from __future__ import annotations
from typing import Final, NamedTuple
from .backend_contracts import BackendCommitResult, BackendOperation, BackendRetrieveResult, BackendWriteResult
from .schemas_v2 import BackendType
CONTROL_PLANE_PERSISTED_PAYLOAD_FIELDS: Final[frozenset[str]] = frozenset(
{
"event_id",
"gateway_id",
"workspace_id",
"user_id",
"agent_id",
"session_id",
"turn_id",
"namespace",
"source_type",
"source_event_id",
"backend_type",
"operation",
"payload_ref",
"metadata",
"trace",
"role",
}
)
CONTROL_PLANE_PAYLOAD_FIELDS: Final[frozenset[str]] = CONTROL_PLANE_PERSISTED_PAYLOAD_FIELDS
DISALLOWED_PAYLOAD_FIELDS: Final[frozenset[str]] = frozenset(
{
"content",
"raw_request",
"messages",
"conversation",
"transcript",
}
)
class AdapterMappingSpec(NamedTuple):
backend_type: BackendType
operation: BackendOperation
adapter_method: str
backend_capability: str
result_model: type[BackendWriteResult] | type[BackendCommitResult] | type[BackendRetrieveResult]
allowed_payload_fields: frozenset[str] = CONTROL_PLANE_PERSISTED_PAYLOAD_FIELDS
ADAPTER_MAPPING_SPECS: Final[tuple[AdapterMappingSpec, ...]] = (
AdapterMappingSpec(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.INGEST_TURN,
adapter_method="ingest_conversation_turn",
backend_capability="session archive append / resource context organization",
result_model=BackendWriteResult,
),
AdapterMappingSpec(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.COMMIT_SESSION,
adapter_method="commit_session_v2",
backend_capability="session commit and session archive ref creation",
result_model=BackendCommitResult,
),
AdapterMappingSpec(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.RETRIEVE_CONTEXT,
adapter_method="retrieve_context_v2",
backend_capability="runtime session/resource context retrieval",
result_model=BackendRetrieveResult,
),
AdapterMappingSpec(
backend_type=BackendType.EVERMEMOS,
operation=BackendOperation.INGEST_TURN,
adapter_method="ingest_message",
backend_capability="message-level memory ingestion",
result_model=BackendWriteResult,
),
AdapterMappingSpec(
backend_type=BackendType.EVERMEMOS,
operation=BackendOperation.COMMIT_SESSION,
adapter_method="extract_profile_long_term_v2",
backend_capability="episodic/profile/long-term extraction",
result_model=BackendCommitResult,
),
AdapterMappingSpec(
backend_type=BackendType.EVERMEMOS,
operation=BackendOperation.RETRIEVE_CONTEXT,
adapter_method="retrieve_context_v2",
backend_capability="episodic/profile/long-term memory retrieval",
result_model=BackendRetrieveResult,
),
AdapterMappingSpec(
backend_type=BackendType.OBSIDIAN,
operation=BackendOperation.CREATE_REVIEW_DRAFT,
adapter_method="create_review_draft_v2",
backend_capability="human review draft creation for high-risk/high-conflict candidates",
result_model=BackendWriteResult,
),
)
def get_adapter_mapping_spec(backend_type: BackendType, operation: BackendOperation) -> AdapterMappingSpec:
for spec in ADAPTER_MAPPING_SPECS:
if spec.backend_type == backend_type and spec.operation == operation:
return spec
raise KeyError(f"No v2 adapter mapping for {backend_type.value}:{operation.value}")
def validate_control_plane_payload(payload: dict[str, object]) -> None:
"""Validate only persisted control-plane payloads, not runtime adapter requests."""
blocked = sorted(DISALLOWED_PAYLOAD_FIELDS.intersection(payload))
if blocked:
raise ValueError(f"Control-plane persisted payload includes disallowed content fields: {', '.join(blocked)}")
def validate_control_plane_persisted_payload(payload: dict[str, object]) -> None:
validate_control_plane_payload(payload)

View File

@ -0,0 +1,134 @@
"""Backend adapter contracts for Memory Gateway v2."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from uuid import uuid4
from pydantic import BaseModel, Field
from .schemas import utc_now
from .schemas_v2 import BackendType, MemoryRefType, OperationStatus
class BackendOperation(str, Enum):
INGEST_TURN = "ingest_turn"
COMMIT_SESSION = "commit_session"
RETRIEVE_CONTEXT = "retrieve_context"
CREATE_REVIEW_DRAFT = "create_review_draft"
class BackendResultStatus(str, Enum):
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
PENDING = "pending"
class OutboxEventStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
SUCCESS = "success"
SKIPPED = "skipped"
FAILED = "failed"
DEAD_LETTER = "dead_letter"
class BackendWriteResult(BaseModel):
backend_type: BackendType
operation: BackendOperation
status: BackendResultStatus
native_id: Optional[str] = None
native_uri: Optional[str] = None
retryable: bool = False
error_code: Optional[str] = None
error_message: Optional[str] = None
latency_ms: Optional[float] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class BackendProducedRef(BaseModel):
ref_type: MemoryRefType
native_id: Optional[str] = None
native_uri: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class BackendCommitResult(BaseModel):
backend_type: BackendType
operation: BackendOperation = BackendOperation.COMMIT_SESSION
status: BackendResultStatus
native_id: Optional[str] = None
native_uri: Optional[str] = None
retryable: bool = False
error_code: Optional[str] = None
error_message: Optional[str] = None
latency_ms: Optional[float] = None
created_refs: list[str] = Field(default_factory=list)
refs: list[BackendProducedRef] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class BackendRetrieveItem(BaseModel):
text: Optional[str] = None
source_backend: BackendType
ref_id: Optional[str] = None
score: float = 0.0
memory_type: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class BackendRetrieveResult(BaseModel):
backend_type: BackendType
operation: BackendOperation = BackendOperation.RETRIEVE_CONTEXT
status: BackendResultStatus
native_id: Optional[str] = None
native_uri: Optional[str] = None
retryable: bool = False
error_code: Optional[str] = None
error_message: Optional[str] = None
latency_ms: Optional[float] = None
items: list[BackendRetrieveItem] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class OutboxEvent(BaseModel):
id: str = Field(default_factory=lambda: f"outbox_{uuid4().hex[:16]}")
event_type: str
gateway_id: str
workspace_id: str
user_id: str
agent_id: Optional[str] = None
session_id: Optional[str] = None
backend_type: BackendType
operation: BackendOperation
payload_ref: Optional[str] = None
status: OutboxEventStatus = OutboxEventStatus.PENDING
attempt_count: int = 0
max_attempts: int = 3
next_retry_at: Optional[datetime] = None
last_error: Optional[str] = None
locked_by: Optional[str] = None
locked_at: Optional[datetime] = None
lease_expires_at: Optional[datetime] = None
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class CommitJob(BaseModel):
job_id: str = Field(default_factory=lambda: f"job_{uuid4().hex[:16]}")
workspace_id: str
user_id: str
agent_id: Optional[str] = None
session_id: str
namespace: Optional[str] = None
status: OperationStatus = OperationStatus.ACCEPTED
requested_by: Optional[str] = None
created_refs_count: int = 0
error_message: Optional[str] = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None

View File

@ -0,0 +1,259 @@
"""Offline response normalization helpers for future v2 backend adapters."""
from __future__ import annotations
from typing import Any
from .backend_contracts import (
BackendCommitResult,
BackendOperation,
BackendProducedRef,
BackendResultStatus,
BackendRetrieveItem,
BackendRetrieveResult,
BackendWriteResult,
)
from .backend_ref_mapping import map_backend_ref_type
from .schemas_v2 import BackendType
SAFE_METADATA_KEYS = {
"backend_request_id",
"request_id",
"trace_id",
"latency_ms",
"schema_version",
"source_channel",
"reason",
"original_ref_type",
"confidence",
"score",
"version",
"created_at",
}
BLOCKED_METADATA_KEYS = {"content", "raw_request", "messages", "conversation", "transcript"}
def safe_backend_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
if not metadata:
return {}
safe: dict[str, Any] = {}
for key, value in metadata.items():
if key in BLOCKED_METADATA_KEYS or key not in SAFE_METADATA_KEYS:
continue
if isinstance(value, (str, int, float, bool)) or value is None:
safe[key] = value
return safe
def normalize_openviking_commit_response(raw: dict[str, Any]) -> BackendCommitResult:
status = _result_status(raw)
refs = [_produced_ref(BackendType.OPENVIKING, item) for item in _extract_ref_items(raw)]
return BackendCommitResult(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.COMMIT_SESSION,
status=status,
native_id=raw.get("native_id") or raw.get("session_id"),
native_uri=raw.get("native_uri") or raw.get("uri"),
retryable=_retryable_from_raw(BackendType.OPENVIKING, raw),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
latency_ms=raw.get("latency_ms"),
refs=refs,
metadata=safe_backend_metadata(raw.get("metadata") or raw),
)
def normalize_evermemos_commit_response(raw: dict[str, Any]) -> BackendCommitResult:
status = _result_status(raw)
refs = [_produced_ref(BackendType.EVERMEMOS, item) for item in _extract_ref_items(raw)]
return BackendCommitResult(
backend_type=BackendType.EVERMEMOS,
operation=BackendOperation.COMMIT_SESSION,
status=status,
native_id=raw.get("native_id") or raw.get("session_id"),
native_uri=raw.get("native_uri") or raw.get("uri"),
retryable=_retryable_from_raw(BackendType.EVERMEMOS, raw),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
latency_ms=raw.get("latency_ms"),
refs=refs,
metadata=safe_backend_metadata(raw.get("metadata") or raw),
)
def normalize_openviking_ingest_response(raw: dict[str, Any]) -> BackendWriteResult:
return _write_result(BackendType.OPENVIKING, raw)
def normalize_evermemos_ingest_response(raw: dict[str, Any]) -> BackendWriteResult:
return _write_result(BackendType.EVERMEMOS, raw)
def normalize_openviking_retrieve_response(raw: dict[str, Any]) -> BackendRetrieveResult:
return _retrieve_result(BackendType.OPENVIKING, raw)
def normalize_evermemos_retrieve_response(raw: dict[str, Any]) -> BackendRetrieveResult:
return _retrieve_result(BackendType.EVERMEMOS, raw)
def map_backend_error_to_retryable(
backend_type: BackendType,
status_code: int | None = None,
error_code: str | None = None,
error_message: str | None = None,
) -> bool:
"""Map backend errors into retryable/non-retryable categories.
Unknown errors default to retryable because adapter contracts are still
unstable and transient backend/API rollout failures are more likely during
integration.
"""
if status_code in {429, 500, 502, 503, 504}:
return True
if status_code in {400, 401, 403, 404, 422}:
return False
text = f"{error_code or ''} {error_message or ''}".lower()
if "timeout" in text or "network_error" in text or "connection" in text:
return True
if "validation" in text or "unauthorized" in text or "forbidden" in text or "not_found" in text:
return False
return True
def _write_result(backend_type: BackendType, raw: dict[str, Any]) -> BackendWriteResult:
data = raw.get("result") or raw.get("data") or {}
if not isinstance(data, dict):
data = {}
native_id = (
raw.get("native_id")
or raw.get("id")
or raw.get("memory_id")
or raw.get("session_id")
or data.get("native_id")
or data.get("id")
or data.get("memory_id")
or data.get("session_id")
)
native_uri = (
raw.get("native_uri")
or raw.get("uri")
or raw.get("url")
or data.get("native_uri")
or data.get("uri")
or data.get("url")
)
if not native_uri and backend_type == BackendType.OPENVIKING and native_id:
native_uri = f"viking://sessions/{native_id}"
return BackendWriteResult(
backend_type=backend_type,
operation=BackendOperation.INGEST_TURN,
status=_result_status(raw),
native_id=native_id,
native_uri=native_uri,
retryable=_retryable_from_raw(backend_type, raw),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
latency_ms=raw.get("latency_ms"),
metadata=safe_backend_metadata(raw.get("metadata") or raw),
)
def _retrieve_result(backend_type: BackendType, raw: dict[str, Any]) -> BackendRetrieveResult:
if not isinstance(raw, dict) or not raw:
return BackendRetrieveResult(
backend_type=backend_type,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "malformed_or_empty_response"},
)
return BackendRetrieveResult(
backend_type=backend_type,
operation=BackendOperation.RETRIEVE_CONTEXT,
status=_result_status(raw),
native_id=raw.get("native_id") or raw.get("session_id"),
native_uri=raw.get("native_uri") or raw.get("uri"),
retryable=_retryable_from_raw(backend_type, raw),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
latency_ms=raw.get("latency_ms"),
items=[_retrieve_item(backend_type, item) for item in _extract_retrieve_items(raw)],
metadata=safe_backend_metadata(raw.get("metadata") or raw),
)
def _retrieve_item(backend_type: BackendType, item: dict[str, Any]) -> BackendRetrieveItem:
metadata = safe_backend_metadata(item.get("metadata") if isinstance(item.get("metadata"), dict) else item)
return BackendRetrieveItem(
text=item.get("text") or item.get("summary") or item.get("abstract"),
source_backend=backend_type,
ref_id=item.get("ref_id") or item.get("id") or item.get("memory_id") or item.get("profile_id") or item.get("session_id") or item.get("uri"),
score=float(item.get("score") or 0.0),
memory_type=item.get("memory_type") or item.get("ref_type") or item.get("type") or item.get("kind"),
metadata=metadata,
)
def _produced_ref(backend_type: BackendType, item: dict[str, Any]) -> BackendProducedRef:
ref_type, mapping_metadata = map_backend_ref_type(backend_type, item.get("ref_type") or item.get("type") or item.get("kind"))
metadata = {
**safe_backend_metadata(item.get("metadata") if isinstance(item.get("metadata"), dict) else item),
**mapping_metadata,
}
return BackendProducedRef(
ref_type=ref_type,
native_id=item.get("native_id") or item.get("id") or item.get("memory_id") or item.get("profile_id") or item.get("session_id"),
native_uri=item.get("native_uri") or item.get("uri") or item.get("url"),
metadata=metadata,
)
def _extract_ref_items(raw: dict[str, Any]) -> list[dict[str, Any]]:
data = raw.get("result") or raw.get("data") or raw
candidates = (
data.get("refs")
or data.get("produced_refs")
or data.get("created_refs")
or data.get("memories")
or data.get("items")
or []
)
return [item for item in candidates if isinstance(item, dict)]
def _extract_retrieve_items(raw: dict[str, Any]) -> list[dict[str, Any]]:
data = raw.get("result") or raw.get("data") or raw
if not isinstance(data, dict):
return []
candidates = (
data.get("items")
or data.get("results")
or data.get("memories")
or data.get("resources")
or data.get("contexts")
or []
)
return [item for item in candidates if isinstance(item, dict)]
def _result_status(raw: dict[str, Any]) -> BackendResultStatus:
status = str(raw.get("status") or "success").lower()
if status in {"ok", "created", "accepted"}:
return BackendResultStatus.SUCCESS
try:
return BackendResultStatus(status)
except ValueError:
return BackendResultStatus.SUCCESS if not raw.get("error") and not raw.get("error_message") else BackendResultStatus.FAILED
def _retryable_from_raw(backend_type: BackendType, raw: dict[str, Any]) -> bool:
if "retryable" in raw:
return bool(raw["retryable"])
if raw.get("error") or raw.get("error_message") or raw.get("error_code") or raw.get("status_code"):
return map_backend_error_to_retryable(
backend_type,
status_code=raw.get("status_code"),
error_code=raw.get("error_code"),
error_message=raw.get("error") or raw.get("error_message"),
)
return False

View File

@ -0,0 +1,66 @@
"""Backend-specific ref type mapping for Memory Gateway v2."""
from __future__ import annotations
from .schemas_v2 import BackendType, MemoryRefType
OPENVIKING_REF_TYPE_MAP = {
"session_archive": MemoryRefType.SESSION_ARCHIVE,
"context_resource": MemoryRefType.CONTEXT_RESOURCE,
"resource": MemoryRefType.CONTEXT_RESOURCE,
"session_summary": MemoryRefType.SESSION_ARCHIVE,
}
EVERMEMOS_REF_TYPE_MAP = {
"message_memory": MemoryRefType.MESSAGE_MEMORY,
"episodic_memory": MemoryRefType.EPISODIC_MEMORY,
"episode": MemoryRefType.EPISODIC_MEMORY,
"profile": MemoryRefType.PROFILE,
"long_term_memory": MemoryRefType.LONG_TERM_MEMORY,
"memory": MemoryRefType.LONG_TERM_MEMORY,
"preference": MemoryRefType.PROFILE,
}
OBSIDIAN_REF_TYPE_MAP = {
"review_draft": MemoryRefType.DRAFT_REVIEW,
"draft_review": MemoryRefType.DRAFT_REVIEW,
}
def map_backend_ref_type(
backend_type: BackendType,
backend_ref_type: str | None,
) -> tuple[MemoryRefType, dict[str, str]]:
"""Map backend-native ref type to a Gateway MemoryRefType.
Unknown values fall back to a backend-appropriate default and preserve the
original value in returned metadata for later inspection.
"""
raw_type = (backend_ref_type or "").strip()
normalized = raw_type.lower()
if backend_type == BackendType.OPENVIKING:
mapped = OPENVIKING_REF_TYPE_MAP.get(normalized, MemoryRefType.SESSION_ARCHIVE)
elif backend_type == BackendType.EVERMEMOS:
mapped = EVERMEMOS_REF_TYPE_MAP.get(normalized, MemoryRefType.LONG_TERM_MEMORY)
elif backend_type == BackendType.OBSIDIAN:
mapped = OBSIDIAN_REF_TYPE_MAP.get(normalized, MemoryRefType.DRAFT_REVIEW)
else:
mapped = MemoryRefType.LONG_TERM_MEMORY
metadata: dict[str, str] = {}
if raw_type and raw_type not in {mapped.value, normalized}:
metadata["original_ref_type"] = raw_type
elif raw_type and normalized not in _known_backend_ref_types(backend_type):
metadata["original_ref_type"] = raw_type
return mapped, metadata
def _known_backend_ref_types(backend_type: BackendType) -> set[str]:
if backend_type == BackendType.OPENVIKING:
return set(OPENVIKING_REF_TYPE_MAP)
if backend_type == BackendType.EVERMEMOS:
return set(EVERMEMOS_REF_TYPE_MAP)
if backend_type == BackendType.OBSIDIAN:
return set(OBSIDIAN_REF_TYPE_MAP)
return set()

View File

@ -18,16 +18,16 @@ def load_config(config_path: Optional[str] = None) -> Config:
if not config_file.exists(): if not config_file.exists():
# 返回默认配置 # 返回默认配置
return Config() return _apply_env_overrides(Config())
try: try:
with open(config_file, "r", encoding="utf-8") as f: with open(config_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
if data is None: if data is None:
return Config() return _apply_env_overrides(Config())
return Config( config = Config(
server=ServerConfig(**data.get("server", {})), server=ServerConfig(**data.get("server", {})),
openviking=OpenVikingConfig(**data.get("openviking", {})), openviking=OpenVikingConfig(**data.get("openviking", {})),
evermemos=EverMemOSConfig(**data.get("evermemos", {})), evermemos=EverMemOSConfig(**data.get("evermemos", {})),
@ -37,9 +37,10 @@ def load_config(config_path: Optional[str] = None) -> Config:
obsidian=ObsidianConfig(**data.get("obsidian", {})), obsidian=ObsidianConfig(**data.get("obsidian", {})),
storage=StorageConfig(**data.get("storage", {})), storage=StorageConfig(**data.get("storage", {})),
) )
return _apply_env_overrides(config)
except (ValidationError, yaml.YAMLError) as e: except (ValidationError, yaml.YAMLError) as e:
print(f"配置文件解析错误: {e}") print(f"配置文件解析错误: {e}")
return Config() return _apply_env_overrides(Config())
def get_config() -> Config: def get_config() -> Config:
@ -57,3 +58,42 @@ def set_config(config: Config) -> None:
_config: Optional[Config] = None _config: Optional[Config] = None
def _apply_env_overrides(config: Config) -> Config:
openviking_updates = _backend_env_updates("OPENVIKING")
evermemos_updates = _backend_env_updates("EVERMEMOS")
if openviking_updates:
config.openviking = config.openviking.model_copy(update=openviking_updates)
if evermemos_updates:
config.evermemos = config.evermemos.model_copy(update=evermemos_updates)
return config
def _backend_env_updates(prefix: str) -> dict:
updates = {}
env_map = {
"ENABLED": "enabled",
"MODE": "mode",
"BASE_URL": "url",
"URL": "url",
"API_KEY": "api_key",
"TOKEN": "api_key",
"TIMEOUT": "timeout",
"TIMEOUT_SECONDS": "timeout",
"VERIFY_SSL": "verify_ssl",
"INGEST_PATH": "ingest_path",
}
for env_name, field_name in env_map.items():
value = os.environ.get(f"{prefix}_{env_name}")
if value is None:
continue
if field_name == "enabled":
updates[field_name] = value.lower() in {"1", "true", "yes", "on"}
elif field_name == "timeout":
updates[field_name] = int(value)
elif field_name == "verify_ssl":
updates[field_name] = value.lower() not in {"0", "false", "no", "off"}
else:
updates[field_name] = value
return updates

View File

@ -1,12 +1,21 @@
"""Client for the external EverMemOS consolidation service.""" """Client for the external EverMemOS consolidation service."""
from __future__ import annotations from __future__ import annotations
from json import JSONDecodeError
from typing import Any from typing import Any
import httpx import httpx
from .backend_contracts import BackendCommitResult, BackendOperation, BackendResultStatus, BackendRetrieveResult, BackendWriteResult
from .backend_normalization import (
map_backend_error_to_retryable,
normalize_evermemos_commit_response,
normalize_evermemos_ingest_response,
normalize_evermemos_retrieve_response,
)
from .config import get_config from .config import get_config
from .schemas import AccessContext, EpisodeRecord, MemoryRecord from .schemas import AccessContext, EpisodeRecord, MemoryRecord
from .schemas_v2 import BackendType
class EverMemOSError(RuntimeError): class EverMemOSError(RuntimeError):
@ -26,15 +35,25 @@ class EverMemOSClient:
base_url: str | None = None, base_url: str | None = None,
api_key: str | None = None, api_key: str | None = None,
timeout: int | None = None, timeout: int | None = None,
enabled: bool | None = None,
mode: str | None = None,
verify_ssl: bool | None = None,
health_path: str | None = None, health_path: str | None = None,
ingest_path: str | None = None,
consolidate_path: str | None = None, consolidate_path: str | None = None,
transport: httpx.BaseTransport | None = None,
) -> None: ) -> None:
config = get_config().evermemos config = get_config().evermemos
self.base_url = (base_url or config.url).rstrip("/") self.base_url = (base_url if base_url is not None else config.url).rstrip("/")
self.api_key = api_key if api_key is not None else config.api_key self.api_key = api_key if api_key is not None else config.api_key
self.timeout = timeout or config.timeout self.timeout = timeout or config.timeout
self.enabled = config.enabled if enabled is None else enabled
self.mode = mode or config.mode
self.verify_ssl = config.verify_ssl if verify_ssl is None else verify_ssl
self.health_path = health_path or config.health_path self.health_path = health_path or config.health_path
self.ingest_path = ingest_path or config.ingest_path
self.consolidate_path = consolidate_path or config.consolidate_path self.consolidate_path = consolidate_path or config.consolidate_path
self.transport = transport
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@ -46,13 +65,195 @@ class EverMemOSClient:
def health(self) -> dict[str, Any]: def health(self) -> dict[str, Any]:
url = self.base_url + self.health_path url = self.base_url + self.health_path
try: try:
with httpx.Client(timeout=self.timeout, headers=self._headers()) as client: health_timeout = httpx.Timeout(min(self.timeout, 2.0), connect=min(self.timeout, 0.5))
with httpx.Client(timeout=health_timeout, headers=self._headers()) as client:
response = client.get(url) response = client.get(url)
response.raise_for_status() response.raise_for_status()
return {"status": "ok", "url": self.base_url, "response": response.json()} return {"status": "ok", "url": self.base_url, "response": response.json()}
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return {"status": "error", "url": self.base_url, "error": str(exc)} return {"status": "error", "url": self.base_url, "error": str(exc)}
def ingest_message(self, payload: dict[str, Any]) -> BackendWriteResult:
"""v2 adapter placeholder for message-level EverMemOS ingestion.
Mapping spec: `backend_adapter_mapping.AdapterMappingSpec` maps
EverMemOS ingest_turn to this method and requires BackendWriteResult.
Payloads must contain only control-plane fields; raw request bodies are
not persisted by the Gateway control-plane store.
TODO(v2): bind this to EverMemOS `/api/v1/memories` or its stable
message ingestion API after the external contract settles.
"""
runtime_payload = self._build_ingest_payload(payload)
if self._use_real_api:
return self._ingest_message_real(runtime_payload)
raw = {
"status": "skipped",
"memory_id": runtime_payload.get("turn_id"),
"metadata": {
"reason": "evermemos_v2_ingest_adapter_not_configured",
"schema_version": "evermemos.fixture.ingest.v2",
},
}
return self._normalize_ingest_response(raw)
@property
def _use_real_api(self) -> bool:
# Real ingest is strictly gated by mode=real. The legacy `enabled`
# field is retained for config compatibility, but must not trigger
# network traffic by itself.
return self.mode == "real"
def _ingest_message_real(self, runtime_payload: dict[str, Any]) -> BackendWriteResult:
if not self.base_url:
return self._failed_ingest_result(
error_code="config_error",
error_message="EverMemOS real ingest is enabled but base_url is missing",
retryable=False,
)
try:
with httpx.Client(
base_url=self.base_url,
headers=self._headers(),
timeout=self.timeout,
verify=self.verify_ssl,
transport=self.transport,
) as client:
response = client.post(self.ingest_path, json=runtime_payload)
if response.status_code >= 400:
return self._failed_ingest_result(
error_code=f"http_{response.status_code}",
error_message=f"EverMemOS ingest failed with HTTP {response.status_code}",
retryable=self._map_error(response),
)
try:
raw = response.json()
except (JSONDecodeError, ValueError):
return self._failed_ingest_result(
error_code="invalid_json",
error_message="EverMemOS ingest returned invalid JSON",
retryable=True,
)
if not isinstance(raw, dict):
return self._failed_ingest_result(
error_code="unexpected_response",
error_message="EverMemOS ingest returned an unexpected response shape",
retryable=True,
)
return self._normalize_ingest_response(raw)
except httpx.TimeoutException as exc:
return self._failed_ingest_result("timeout", self._safe_error_message(exc), retryable=self._map_error(exc))
except httpx.RequestError as exc:
return self._failed_ingest_result("network_error", self._safe_error_message(exc), retryable=self._map_error(exc))
except Exception as exc: # noqa: BLE001
return self._failed_ingest_result("unexpected_error", self._safe_error_message(exc), retryable=self._map_error(exc))
def extract_profile_long_term_v2(self, payload: dict[str, Any]) -> BackendCommitResult:
"""v2 adapter placeholder for profile / long-term extraction.
Mapping spec: commit_session returns BackendCommitResult and should
produce native episodic/profile/long-term refs once the real API is stable.
"""
runtime_payload = self._build_commit_payload(payload)
raw = {
"status": "success",
"session_id": runtime_payload.get("session_id"),
"metadata": {
"reason": "evermemos_v2_commit_fixture",
"schema_version": "evermemos.fixture.commit.v2",
},
"data": {
"produced_refs": [
{
"ref_type": "profile",
"profile_id": f"em_profile:{runtime_payload.get('user_id') or 'unknown'}",
"metadata": {"schema_version": "evermemos.fixture.profile.v2"},
},
{
"ref_type": "long_term_memory",
"memory_id": f"em_long_term:{runtime_payload.get('session_id')}",
"metadata": {"schema_version": "evermemos.fixture.long_term.v2"},
},
]
},
}
return self._normalize_commit_response(raw)
def retrieve_context_v2(self, payload: dict[str, Any]) -> BackendRetrieveResult:
"""v2 adapter placeholder for episodic/profile/long-term retrieval.
Mapping spec: retrieve_context returns BackendRetrieveResult with
normalized context items, not raw backend payload dumps.
"""
raw = {
"status": "success",
"metadata": {
"reason": "evermemos_v2_retrieve_fixture",
"schema_version": "evermemos.fixture.retrieve.v2",
},
"data": {
"items": [
{
"text": "EverMemOS fixture profile context.",
"profile_id": f"em_profile:{payload.get('user_id') or 'unknown'}",
"score": 0.72,
"memory_type": "profile",
"metadata": {"schema_version": "evermemos.fixture.retrieve.item.v2"},
},
{
"text": "EverMemOS fixture long-term memory context.",
"memory_id": f"em_long_term:{payload.get('session_id') or 'unknown'}",
"score": 0.69,
"memory_type": "long_term_memory",
"metadata": {"schema_version": "evermemos.fixture.retrieve.item.v2"},
},
]
},
}
return self._normalize_retrieve_response(raw)
def _build_ingest_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
# Runtime-only adapter payload. It may include conversation content for
# the current request lifecycle; callers must not persist it to SQLite.
return dict(payload)
def _build_commit_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
return dict(payload)
def _normalize_ingest_response(self, raw: dict[str, Any]) -> BackendWriteResult:
return normalize_evermemos_ingest_response(raw)
def _normalize_commit_response(self, raw: dict[str, Any]) -> BackendCommitResult:
return normalize_evermemos_commit_response(raw)
def _normalize_retrieve_response(self, raw: dict[str, Any]) -> BackendRetrieveResult:
return normalize_evermemos_retrieve_response(raw)
def _map_error(self, exc_or_response: Any) -> bool:
status_code = getattr(exc_or_response, "status_code", None)
error_code = getattr(exc_or_response, "error_code", None)
error_message = str(exc_or_response) if exc_or_response is not None else None
return map_backend_error_to_retryable(
BackendType.EVERMEMOS,
status_code=status_code,
error_code=error_code,
error_message=error_message,
)
def _failed_ingest_result(self, error_code: str, error_message: str, retryable: bool) -> BackendWriteResult:
return BackendWriteResult(
backend_type=BackendType.EVERMEMOS,
operation=BackendOperation.INGEST_TURN,
status=BackendResultStatus.FAILED,
retryable=retryable,
error_code=error_code,
error_message=error_message,
metadata={"error_code": error_code},
)
def _safe_error_message(self, exc: Exception) -> str:
return exc.__class__.__name__
def consolidate_session( def consolidate_session(
self, self,
session_id: str, session_id: str,
@ -110,4 +311,3 @@ class EverMemOSClient:
"conflicts": data.get("conflicts") or [], "conflicts": data.get("conflicts") or [],
"review_drafts": data.get("review_drafts") or [], "review_drafts": data.get("review_drafts") or [],
} }

View File

@ -13,6 +13,7 @@ conflicting candidates.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import logging import logging
from typing import Any from typing import Any
@ -37,6 +38,18 @@ class ConsolidateRequest(BaseModel):
existing_memories: list[dict[str, Any]] = Field(default_factory=list) existing_memories: list[dict[str, Any]] = Field(default_factory=list)
class MemoryIngestRequest(BaseModel):
workspace_id: str | None = None
user_id: str
session_id: str
turn_id: str
role: str = "user"
content: str
metadata: dict[str, Any] = Field(default_factory=dict)
source_type: str | None = None
source_event_id: str | None = None
app = FastAPI(title="Local EverMemOS POC Service", version="0.1.0") app = FastAPI(title="Local EverMemOS POC Service", version="0.1.0")
@ -50,6 +63,35 @@ async def health() -> dict[str, Any]:
} }
@app.post("/api/v1/memories")
async def ingest_memory(request: MemoryIngestRequest) -> dict[str, Any]:
"""Accept message-level ingest for local real-adapter smoke tests.
This POC endpoint intentionally does not persist raw conversation content.
It only returns a stable backend reference that Memory Gateway can store as
control-plane metadata.
"""
seed = "|".join(
[
request.workspace_id or "",
request.user_id,
request.session_id,
request.turn_id,
request.source_event_id or "",
]
)
memory_id = "em_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
return {
"status": "success",
"memory_id": memory_id,
"native_uri": f"evermemos://memories/{memory_id}",
"metadata": {
"schema_version": "evermemos.local.ingest.v1",
"source_channel": request.metadata.get("source_channel") or request.metadata.get("channel"),
},
}
@app.post("/v1/sessions/consolidate") @app.post("/v1/sessions/consolidate")
async def consolidate_session(request: ConsolidateRequest) -> dict[str, Any]: async def consolidate_session(request: ConsolidateRequest) -> dict[str, Any]:
repo = InMemoryRepository() repo = InMemoryRepository()
@ -105,4 +147,3 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -0,0 +1,25 @@
"""Human-review backend skeleton for Memory Gateway v2.
Obsidian remains a human-in-the-loop review backend only. This skeleton does
not write files or call external APIs; it preserves the adapter contract until
the review draft integration is explicitly designed.
"""
from __future__ import annotations
from typing import Any
from .backend_contracts import BackendOperation, BackendResultStatus, BackendWriteResult
from .schemas_v2 import BackendType
class ObsidianReviewClient:
def create_review_draft_v2(self, payload: dict[str, Any]) -> BackendWriteResult:
"""Return a skipped review-draft result until the real adapter exists."""
return BackendWriteResult(
backend_type=BackendType.OBSIDIAN,
operation=BackendOperation.CREATE_REVIEW_DRAFT,
status=BackendResultStatus.SKIPPED,
native_id=payload.get("event_id") or payload.get("gateway_id"),
retryable=False,
metadata={"reason": "obsidian_review_adapter_not_configured"},
)

View File

@ -5,12 +5,21 @@ import json
import logging import logging
import mimetypes import mimetypes
import tempfile import tempfile
from json import JSONDecodeError
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import httpx import httpx
from .backend_contracts import BackendCommitResult, BackendOperation, BackendResultStatus, BackendRetrieveResult, BackendWriteResult
from .backend_normalization import (
map_backend_error_to_retryable,
normalize_openviking_commit_response,
normalize_openviking_ingest_response,
normalize_openviking_retrieve_response,
)
from .config import get_config from .config import get_config
from .schemas_v2 import BackendType
from .types import MemoryEntry, ResourceEntry, SearchResult from .types import MemoryEntry, ResourceEntry, SearchResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,16 +32,26 @@ class OpenVikingClient:
self, self,
base_url: Optional[str] = None, base_url: Optional[str] = None,
api_key: Optional[str] = None, api_key: Optional[str] = None,
timeout: int = 30, timeout: int | None = None,
account: str = "default", account: str = "default",
user: str = "default", user: str = "default",
enabled: bool | None = None,
mode: str | None = None,
verify_ssl: bool | None = None,
ingest_path: str | None = None,
transport: httpx.AsyncBaseTransport | None = None,
): ):
self.config = get_config() self.config = get_config()
self.base_url = base_url or self.config.openviking.url self.base_url = base_url if base_url is not None else self.config.openviking.url
self.api_key = api_key or self.config.openviking.api_key or "your-secret-root-key" self.api_key = api_key if api_key is not None else (self.config.openviking.api_key or "your-secret-root-key")
self.timeout = timeout self.timeout = timeout if timeout is not None else self.config.openviking.timeout
self.account = account self.account = account
self.user = user self.user = user
self.enabled = self.config.openviking.enabled if enabled is None else enabled
self.mode = mode or self.config.openviking.mode
self.verify_ssl = self.config.openviking.verify_ssl if verify_ssl is None else verify_ssl
self.ingest_path = ingest_path or self.config.openviking.ingest_path
self.transport = transport
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None
def _get_headers(self) -> dict[str, str]: def _get_headers(self) -> dict[str, str]:
@ -49,6 +68,8 @@ class OpenVikingClient:
base_url=self.base_url, base_url=self.base_url,
headers=self._get_headers(), headers=self._get_headers(),
timeout=self.timeout, timeout=self.timeout,
verify=self.verify_ssl,
transport=self.transport,
) )
return self._client return self._client
@ -67,6 +88,180 @@ class OpenVikingClient:
logger.error(f"OpenViking 健康检查失败: {e}") logger.error(f"OpenViking 健康检查失败: {e}")
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
async def ingest_conversation_turn(self, payload: dict[str, Any]) -> BackendWriteResult:
"""v2 adapter placeholder for OpenViking session archive ingestion.
Mapping spec: `backend_adapter_mapping.AdapterMappingSpec` maps
OpenViking ingest_turn to this method and requires BackendWriteResult.
Payloads must contain only control-plane fields; conversation content
is not persisted by the Gateway control-plane store.
TODO(v2): bind this to OpenViking's stable session/message archive API
once that contract is finalized. Until then the gateway records a
skipped backend ref instead of inventing an unstable HTTP contract.
"""
runtime_payload = self._build_ingest_payload(payload)
if self._use_real_api:
return await self._ingest_conversation_turn_real(runtime_payload)
raw = {
"status": "skipped",
"session_id": runtime_payload.get("session_id"),
"uri": f"viking://sessions/{runtime_payload.get('session_id')}",
"metadata": {
"reason": "openviking_v2_ingest_adapter_not_configured",
"schema_version": "openviking.fixture.ingest.v2",
},
}
return self._normalize_ingest_response(raw)
@property
def _use_real_api(self) -> bool:
# Real ingest is strictly gated by mode=real. The legacy `enabled`
# field is retained for config compatibility, but must not trigger
# network traffic by itself.
return self.mode == "real"
async def _ingest_conversation_turn_real(self, runtime_payload: dict[str, Any]) -> BackendWriteResult:
if not self.base_url:
return self._failed_ingest_result(
error_code="config_error",
error_message="OpenViking real ingest is enabled but base_url is missing",
retryable=False,
)
try:
client = await self._get_client()
response = await client.post(
self._format_ingest_path(runtime_payload),
json=runtime_payload,
)
if response.status_code >= 400:
return self._failed_ingest_result(
error_code=f"http_{response.status_code}",
error_message=f"OpenViking ingest failed with HTTP {response.status_code}",
retryable=self._map_error(response),
)
try:
raw = response.json()
except (JSONDecodeError, ValueError):
return self._failed_ingest_result(
error_code="invalid_json",
error_message="OpenViking ingest returned invalid JSON",
retryable=True,
)
if not isinstance(raw, dict):
return self._failed_ingest_result(
error_code="unexpected_response",
error_message="OpenViking ingest returned an unexpected response shape",
retryable=True,
)
return self._normalize_ingest_response(raw)
except httpx.TimeoutException as exc:
return self._failed_ingest_result("timeout", self._safe_error_message(exc), retryable=self._map_error(exc))
except httpx.RequestError as exc:
return self._failed_ingest_result("network_error", self._safe_error_message(exc), retryable=self._map_error(exc))
except Exception as exc: # noqa: BLE001
return self._failed_ingest_result("unexpected_error", self._safe_error_message(exc), retryable=self._map_error(exc))
async def commit_session_v2(self, payload: dict[str, Any]) -> BackendCommitResult:
"""v2 adapter placeholder for OpenViking session commit.
Mapping spec: commit_session returns BackendCommitResult and should
produce a native session/archive ref once the real API is stable.
"""
runtime_payload = self._build_commit_payload(payload)
raw = {
"status": "success",
"session_id": runtime_payload.get("session_id"),
"metadata": {
"reason": "openviking_v2_commit_fixture",
"schema_version": "openviking.fixture.commit.v2",
},
"result": {
"refs": [
{
"type": "session_summary",
"id": f"ov_session_summary:{runtime_payload.get('session_id')}",
"uri": f"viking://sessions/{runtime_payload.get('session_id')}/summary",
"metadata": {"schema_version": "openviking.fixture.ref.v2"},
}
]
},
}
return self._normalize_commit_response(raw)
async def retrieve_context_v2(self, payload: dict[str, Any]) -> BackendRetrieveResult:
"""v2 adapter placeholder for OpenViking runtime context retrieval.
Mapping spec: retrieve_context returns BackendRetrieveResult with
runtime context items, not raw backend payload dumps.
"""
raw = {
"status": "ok",
"session_id": payload.get("session_id"),
"metadata": {
"reason": "openviking_v2_retrieve_fixture",
"schema_version": "openviking.fixture.retrieve.v2",
},
"result": {
"items": [
{
"text": "OpenViking fixture runtime context.",
"ref_id": f"ov_context:{payload.get('session_id') or 'unknown'}",
"score": 0.75,
"memory_type": "context_resource",
"metadata": {"schema_version": "openviking.fixture.retrieve.item.v2"},
}
]
},
}
return self._normalize_retrieve_response(raw)
def _build_ingest_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
# Runtime-only adapter payload. It may include conversation content for
# the current request lifecycle; callers must not persist it to SQLite.
return dict(payload)
def _format_ingest_path(self, payload: dict[str, Any]) -> str:
session_id = str(payload.get("session_id") or "unknown")
return self.ingest_path.format(session_id=session_id)
def _build_commit_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
return dict(payload)
def _normalize_ingest_response(self, raw: dict[str, Any]) -> BackendWriteResult:
return normalize_openviking_ingest_response(raw)
def _normalize_commit_response(self, raw: dict[str, Any]) -> BackendCommitResult:
return normalize_openviking_commit_response(raw)
def _normalize_retrieve_response(self, raw: dict[str, Any]) -> BackendRetrieveResult:
return normalize_openviking_retrieve_response(raw)
def _map_error(self, exc_or_response: Any) -> bool:
status_code = getattr(exc_or_response, "status_code", None)
error_code = getattr(exc_or_response, "error_code", None)
error_message = str(exc_or_response) if exc_or_response is not None else None
return map_backend_error_to_retryable(
BackendType.OPENVIKING,
status_code=status_code,
error_code=error_code,
error_message=error_message,
)
def _failed_ingest_result(self, error_code: str, error_message: str, retryable: bool) -> BackendWriteResult:
return BackendWriteResult(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.INGEST_TURN,
status=BackendResultStatus.FAILED,
retryable=retryable,
error_code=error_code,
error_message=error_message,
metadata={"error_code": error_code},
)
def _safe_error_message(self, exc: Exception) -> str:
return exc.__class__.__name__
async def search( async def search(
self, self,
query: str, query: str,

View File

@ -7,12 +7,14 @@ from __future__ import annotations
import json import json
import sqlite3 import sqlite3
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Iterable, Optional, Protocol from typing import Iterable, Optional, Protocol
from .backend_contracts import BackendOperation, CommitJob, OutboxEvent, OutboxEventStatus
from .config import get_config from .config import get_config
from .schemas import AuditLog, EpisodeRecord, MemoryRecord, ProfileRecord, UserRecord from .schemas import AuditLog, EpisodeRecord, MemoryRecord, ProfileRecord, UserRecord
from .schemas_v2 import BackendRefStatus, BackendType, MemoryRef, MemoryRefType
class MetadataRepository(Protocol): class MetadataRepository(Protocol):
@ -28,6 +30,61 @@ class MetadataRepository(Protocol):
def upsert_profile(self, profile: ProfileRecord) -> ProfileRecord: ... def upsert_profile(self, profile: ProfileRecord) -> ProfileRecord: ...
def add_audit(self, audit: AuditLog) -> AuditLog: ... def add_audit(self, audit: AuditLog) -> AuditLog: ...
def list_audit(self, limit: int = 100) -> list[AuditLog]: ... def list_audit(self, limit: int = 100) -> list[AuditLog]: ...
def save_memory_ref(self, ref: MemoryRef) -> MemoryRef: ...
def get_memory_ref(self, ref_id: str) -> MemoryRef | None: ...
def list_memory_refs(
self,
gateway_id: str | None = None,
workspace_id: str | None = None,
user_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
namespace: str | None = None,
backend_type: BackendType | str | None = None,
ref_type: MemoryRefType | str | None = None,
status: BackendRefStatus | str | None = None,
limit: int = 100,
) -> list[MemoryRef]: ...
def save_outbox_event(self, event: OutboxEvent) -> OutboxEvent: ...
def list_outbox_events(
self,
status: OutboxEventStatus | str | None = None,
backend_type: BackendType | str | None = None,
operation: BackendOperation | str | None = None,
gateway_id: str | None = None,
payload_ref: str | None = None,
limit: int = 100,
) -> list[OutboxEvent]: ...
def list_outbox_events_by_job(self, job_id: str, limit: int = 100) -> list[OutboxEvent]: ...
def claim_outbox_event(self, event_id: str) -> OutboxEvent | None: ...
def claim_pending_outbox_events(
self,
limit: int,
worker_id: str,
lease_seconds: int,
) -> list[OutboxEvent]: ...
def release_expired_processing_events(self, now: datetime | None = None) -> list[OutboxEvent]: ...
def update_outbox_event_status(
self,
event_id: str,
status: OutboxEventStatus | str,
last_error: str | None = None,
) -> OutboxEvent | None: ...
def save_commit_job(self, job: CommitJob) -> CommitJob: ...
def get_commit_job(self, job_id: str) -> CommitJob | None: ...
def update_commit_job_status(
self,
job_id: str,
status: str,
error_message: str | None = None,
created_refs_count: int | None = None,
) -> CommitJob | None: ...
def count_memory_refs(
self,
gateway_id: str | None = None,
session_id: str | None = None,
status: BackendRefStatus | str | None = None,
) -> int: ...
def _json_dump_model(model) -> str: def _json_dump_model(model) -> str:
@ -38,6 +95,14 @@ def _json_load_model(model_cls, payload: str):
return model_cls.model_validate(json.loads(payload)) return model_cls.model_validate(json.loads(payload))
def _enum_value(value):
return value.value if hasattr(value, "value") else value
def _safe_timedelta(seconds: int) -> timedelta:
return timedelta(seconds=max(1, int(seconds)))
class InMemoryRepository: class InMemoryRepository:
def __init__(self) -> None: def __init__(self) -> None:
self.users: dict[str, UserRecord] = {} self.users: dict[str, UserRecord] = {}
@ -45,6 +110,9 @@ class InMemoryRepository:
self.episodes: dict[str, EpisodeRecord] = {} self.episodes: dict[str, EpisodeRecord] = {}
self.profiles: dict[str, ProfileRecord] = {} self.profiles: dict[str, ProfileRecord] = {}
self.audit_logs: list[AuditLog] = [] self.audit_logs: list[AuditLog] = []
self.memory_refs: dict[str, MemoryRef] = {}
self.outbox_events: dict[str, OutboxEvent] = {}
self.commit_jobs: dict[str, CommitJob] = {}
def create_user(self, user: UserRecord) -> UserRecord: def create_user(self, user: UserRecord) -> UserRecord:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@ -102,6 +170,210 @@ class InMemoryRepository:
def list_audit(self, limit: int = 100) -> list[AuditLog]: def list_audit(self, limit: int = 100) -> list[AuditLog]:
return self.audit_logs[-limit:] return self.audit_logs[-limit:]
def save_memory_ref(self, ref: MemoryRef) -> MemoryRef:
now = datetime.now(timezone.utc)
existing = self.memory_refs.get(ref.id)
if existing:
ref.created_at = existing.created_at
ref.updated_at = now
self.memory_refs[ref.id] = ref
return ref
def get_memory_ref(self, ref_id: str) -> MemoryRef | None:
return self.memory_refs.get(ref_id)
def list_memory_refs(
self,
gateway_id: str | None = None,
workspace_id: str | None = None,
user_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
namespace: str | None = None,
backend_type: BackendType | str | None = None,
ref_type: MemoryRefType | str | None = None,
status: BackendRefStatus | str | None = None,
limit: int = 100,
) -> list[MemoryRef]:
refs = list(self.memory_refs.values())
def matches(ref: MemoryRef) -> bool:
return (
(workspace_id is None or ref.workspace_id == workspace_id)
and (gateway_id is None or ref.gateway_id == gateway_id)
and (user_id is None or ref.user_id == user_id)
and (agent_id is None or ref.agent_id == agent_id)
and (session_id is None or ref.session_id == session_id)
and (namespace is None or ref.namespace == namespace)
and (backend_type is None or ref.backend_type.value == _enum_value(backend_type))
and (ref_type is None or ref.ref_type.value == _enum_value(ref_type))
and (status is None or ref.status.value == _enum_value(status))
)
refs = [ref for ref in refs if matches(ref)]
refs.sort(key=lambda ref: ref.created_at, reverse=True)
return refs[:limit]
def save_outbox_event(self, event: OutboxEvent) -> OutboxEvent:
now = datetime.now(timezone.utc)
existing = self.outbox_events.get(event.id)
if existing:
event.created_at = existing.created_at
event.updated_at = now
self.outbox_events[event.id] = event
return event
def list_outbox_events(
self,
status: OutboxEventStatus | str | None = None,
backend_type: BackendType | str | None = None,
operation: BackendOperation | str | None = None,
gateway_id: str | None = None,
payload_ref: str | None = None,
limit: int = 100,
) -> list[OutboxEvent]:
events = list(self.outbox_events.values())
def matches(event: OutboxEvent) -> bool:
return (
(status is None or event.status.value == _enum_value(status))
and (backend_type is None or event.backend_type.value == _enum_value(backend_type))
and (operation is None or event.operation.value == _enum_value(operation))
and (gateway_id is None or event.gateway_id == gateway_id)
and (payload_ref is None or event.payload_ref == payload_ref)
)
events = [event for event in events if matches(event)]
events.sort(key=lambda event: event.created_at, reverse=True)
return events[:limit]
def list_outbox_events_by_job(self, job_id: str, limit: int = 100) -> list[OutboxEvent]:
return self.list_outbox_events(payload_ref=f"commit_job:{job_id}", limit=limit)
def claim_outbox_event(self, event_id: str) -> OutboxEvent | None:
event = self.outbox_events.get(event_id)
now = datetime.now(timezone.utc)
if not event or event.status != OutboxEventStatus.PENDING:
return None
if event.next_retry_at and event.next_retry_at > now:
return None
event.status = OutboxEventStatus.PROCESSING
event.locked_by = "inline"
event.locked_at = now
event.lease_expires_at = now + _safe_timedelta(300)
event.updated_at = now
self.outbox_events[event.id] = event
return event
def claim_pending_outbox_events(
self,
limit: int,
worker_id: str,
lease_seconds: int,
) -> list[OutboxEvent]:
now = datetime.now(timezone.utc)
candidates = [
event
for event in self.outbox_events.values()
if event.status == OutboxEventStatus.PENDING
and (event.next_retry_at is None or event.next_retry_at <= now)
]
candidates.sort(key=lambda event: event.created_at)
claimed: list[OutboxEvent] = []
for event in candidates[:limit]:
event.status = OutboxEventStatus.PROCESSING
event.locked_by = worker_id
event.locked_at = now
event.lease_expires_at = now + _safe_timedelta(lease_seconds)
event.updated_at = now
self.outbox_events[event.id] = event
claimed.append(event)
return claimed
def release_expired_processing_events(self, now: datetime | None = None) -> list[OutboxEvent]:
now = now or datetime.now(timezone.utc)
released: list[OutboxEvent] = []
for event in list(self.outbox_events.values()):
if (
event.status == OutboxEventStatus.PROCESSING
and event.lease_expires_at is not None
and event.lease_expires_at <= now
):
event.status = OutboxEventStatus.PENDING
event.locked_by = None
event.locked_at = None
event.lease_expires_at = None
event.updated_at = now
self.outbox_events[event.id] = event
released.append(event)
return released
def update_outbox_event_status(
self,
event_id: str,
status: OutboxEventStatus | str,
last_error: str | None = None,
) -> OutboxEvent | None:
event = self.outbox_events.get(event_id)
if not event:
return None
event.status = OutboxEventStatus(_enum_value(status))
event.last_error = last_error
event.updated_at = datetime.now(timezone.utc)
if event.status != OutboxEventStatus.PROCESSING:
event.locked_by = None
event.locked_at = None
event.lease_expires_at = None
if event.status in {OutboxEventStatus.FAILED, OutboxEventStatus.DEAD_LETTER}:
event.attempt_count += 1
self.outbox_events[event.id] = event
return event
def save_commit_job(self, job: CommitJob) -> CommitJob:
now = datetime.now(timezone.utc)
existing = self.commit_jobs.get(job.job_id)
if existing:
job.created_at = existing.created_at
job.updated_at = now
self.commit_jobs[job.job_id] = job
return job
def count_memory_refs(
self,
gateway_id: str | None = None,
session_id: str | None = None,
status: BackendRefStatus | str | None = None,
) -> int:
return len(self.list_memory_refs(gateway_id=gateway_id, session_id=session_id, status=status, limit=100000))
def get_commit_job(self, job_id: str) -> CommitJob | None:
return self.commit_jobs.get(job_id)
def update_commit_job_status(
self,
job_id: str,
status: str,
error_message: str | None = None,
created_refs_count: int | None = None,
) -> CommitJob | None:
job = self.commit_jobs.get(job_id)
if not job:
return None
from .schemas_v2 import OperationStatus
job.status = OperationStatus(_enum_value(status))
job.error_message = error_message
if created_refs_count is not None:
job.created_refs_count = created_refs_count
now = datetime.now(timezone.utc)
job.updated_at = now
if job.status.value == "running" and job.started_at is None:
job.started_at = now
if job.status.value in {"success", "failed", "partial_success"}:
job.finished_at = now
self.commit_jobs[job.job_id] = job
return job
class SQLiteRepository: class SQLiteRepository:
def __init__(self, db_path: str | Path) -> None: def __init__(self, db_path: str | Path) -> None:
@ -171,8 +443,121 @@ class SQLiteRepository:
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
CREATE TABLE IF NOT EXISTS memory_refs (
id TEXT PRIMARY KEY,
gateway_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
agent_id TEXT,
session_id TEXT,
turn_id TEXT,
namespace TEXT,
backend_type TEXT NOT NULL,
ref_type TEXT NOT NULL,
native_id TEXT,
native_uri TEXT,
provenance_id TEXT,
idempotency_key TEXT,
content_hash TEXT,
source_type TEXT,
source_event_id TEXT,
status TEXT NOT NULL,
error_message TEXT,
metadata_json TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_refs_gateway ON memory_refs(gateway_id);
CREATE INDEX IF NOT EXISTS idx_memory_refs_scope ON memory_refs(workspace_id, user_id, agent_id, session_id);
CREATE INDEX IF NOT EXISTS idx_memory_refs_backend ON memory_refs(backend_type, ref_type, status);
CREATE INDEX IF NOT EXISTS idx_memory_refs_namespace ON memory_refs(namespace);
CREATE TABLE IF NOT EXISTS outbox_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
gateway_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
agent_id TEXT,
session_id TEXT,
backend_type TEXT NOT NULL,
operation TEXT NOT NULL,
payload_ref TEXT,
status TEXT NOT NULL,
attempt_count INTEGER NOT NULL,
max_attempts INTEGER NOT NULL,
next_retry_at TEXT,
last_error TEXT,
locked_by TEXT,
locked_at TEXT,
lease_expires_at TEXT,
metadata_json TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_outbox_events_status ON outbox_events(status, next_retry_at);
CREATE INDEX IF NOT EXISTS idx_outbox_events_backend ON outbox_events(backend_type, operation);
CREATE INDEX IF NOT EXISTS idx_outbox_events_gateway ON outbox_events(gateway_id);
CREATE TABLE IF NOT EXISTS commit_jobs (
job_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
agent_id TEXT,
session_id TEXT NOT NULL,
namespace TEXT,
status TEXT NOT NULL,
requested_by TEXT,
created_refs_count INTEGER NOT NULL,
error_message TEXT,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_commit_jobs_session ON commit_jobs(session_id);
CREATE INDEX IF NOT EXISTS idx_commit_jobs_status ON commit_jobs(status);
""" """
) )
self._ensure_memory_ref_columns(conn)
self._ensure_outbox_event_columns(conn)
conn.execute(
"""
DELETE FROM memory_refs
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM memory_refs
GROUP BY gateway_id, backend_type, ref_type
)
"""
)
conn.execute(
"""
DROP INDEX IF EXISTS uq_memory_refs_gateway_backend_ref_type
"""
)
def _ensure_memory_ref_columns(self, conn: sqlite3.Connection) -> None:
columns = {row["name"] for row in conn.execute("PRAGMA table_info(memory_refs)").fetchall()}
additions = {
"idempotency_key": "TEXT",
"content_hash": "TEXT",
}
for column, column_type in additions.items():
if column not in columns:
conn.execute(f"ALTER TABLE memory_refs ADD COLUMN {column} {column_type}")
def _ensure_outbox_event_columns(self, conn: sqlite3.Connection) -> None:
columns = {row["name"] for row in conn.execute("PRAGMA table_info(outbox_events)").fetchall()}
additions = {
"locked_by": "TEXT",
"locked_at": "TEXT",
"lease_expires_at": "TEXT",
}
for column, column_type in additions.items():
if column not in columns:
conn.execute(f"ALTER TABLE outbox_events ADD COLUMN {column} {column_type}")
def create_user(self, user: UserRecord) -> UserRecord: def create_user(self, user: UserRecord) -> UserRecord:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@ -316,6 +701,397 @@ class SQLiteRepository:
).fetchall() ).fetchall()
return [_json_load_model(AuditLog, row["payload"]) for row in rows] return [_json_load_model(AuditLog, row["payload"]) for row in rows]
def save_memory_ref(self, ref: MemoryRef) -> MemoryRef:
existing = None
with self._connect() as conn:
row = conn.execute("SELECT payload FROM memory_refs WHERE id = ?", (ref.id,)).fetchone()
if row:
existing = _json_load_model(MemoryRef, row["payload"])
now = datetime.now(timezone.utc)
if existing:
ref.created_at = existing.created_at
ref.updated_at = now
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO memory_refs(
id, gateway_id, workspace_id, user_id, agent_id, session_id, turn_id,
namespace, backend_type, ref_type, native_id, native_uri, provenance_id,
idempotency_key, content_hash, source_type, source_event_id, status, error_message, metadata_json,
payload, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
ref.id,
ref.gateway_id,
ref.workspace_id,
ref.user_id,
ref.agent_id,
ref.session_id,
ref.turn_id,
ref.namespace,
ref.backend_type.value,
ref.ref_type.value,
ref.native_id,
ref.native_uri,
ref.provenance_id,
ref.idempotency_key,
ref.content_hash,
ref.source_type,
ref.source_event_id,
ref.status.value,
ref.error_message,
json.dumps(ref.metadata, ensure_ascii=False),
_json_dump_model(ref),
ref.created_at.isoformat(),
ref.updated_at.isoformat(),
),
)
return ref
def get_memory_ref(self, ref_id: str) -> MemoryRef | None:
with self._connect() as conn:
row = conn.execute("SELECT payload FROM memory_refs WHERE id = ?", (ref_id,)).fetchone()
return _json_load_model(MemoryRef, row["payload"]) if row else None
def list_memory_refs(
self,
gateway_id: str | None = None,
workspace_id: str | None = None,
user_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
namespace: str | None = None,
backend_type: BackendType | str | None = None,
ref_type: MemoryRefType | str | None = None,
status: BackendRefStatus | str | None = None,
limit: int = 100,
) -> list[MemoryRef]:
clauses: list[str] = []
params: list[str | int] = []
filters = {
"gateway_id": gateway_id,
"workspace_id": workspace_id,
"user_id": user_id,
"agent_id": agent_id,
"session_id": session_id,
"namespace": namespace,
"backend_type": _enum_value(backend_type) if backend_type is not None else None,
"ref_type": _enum_value(ref_type) if ref_type is not None else None,
"status": _enum_value(status) if status is not None else None,
}
for key, value in filters.items():
if value is not None:
clauses.append(f"{key} = ?")
params.append(value)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(limit)
with self._connect() as conn:
rows = conn.execute(
f"SELECT payload FROM memory_refs {where} ORDER BY created_at DESC LIMIT ?",
params,
).fetchall()
return [_json_load_model(MemoryRef, row["payload"]) for row in rows]
def save_outbox_event(self, event: OutboxEvent) -> OutboxEvent:
existing = None
with self._connect() as conn:
row = conn.execute("SELECT payload FROM outbox_events WHERE id = ?", (event.id,)).fetchone()
if row:
existing = _json_load_model(OutboxEvent, row["payload"])
now = datetime.now(timezone.utc)
if existing:
event.created_at = existing.created_at
event.updated_at = now
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO outbox_events(
id, event_type, gateway_id, workspace_id, user_id, agent_id, session_id,
backend_type, operation, payload_ref, status, attempt_count, max_attempts,
next_retry_at, last_error, locked_by, locked_at, lease_expires_at,
metadata_json, payload, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event.id,
event.event_type,
event.gateway_id,
event.workspace_id,
event.user_id,
event.agent_id,
event.session_id,
event.backend_type.value,
event.operation.value,
event.payload_ref,
event.status.value,
event.attempt_count,
event.max_attempts,
event.next_retry_at.isoformat() if event.next_retry_at else None,
event.last_error,
event.locked_by,
event.locked_at.isoformat() if event.locked_at else None,
event.lease_expires_at.isoformat() if event.lease_expires_at else None,
json.dumps(event.metadata, ensure_ascii=False),
_json_dump_model(event),
event.created_at.isoformat(),
event.updated_at.isoformat(),
),
)
return event
def list_outbox_events(
self,
status: OutboxEventStatus | str | None = None,
backend_type: BackendType | str | None = None,
operation: BackendOperation | str | None = None,
gateway_id: str | None = None,
payload_ref: str | None = None,
limit: int = 100,
) -> list[OutboxEvent]:
clauses: list[str] = []
params: list[str | int] = []
filters = {
"status": _enum_value(status) if status is not None else None,
"backend_type": _enum_value(backend_type) if backend_type is not None else None,
"operation": _enum_value(operation) if operation is not None else None,
"gateway_id": gateway_id,
"payload_ref": payload_ref,
}
for key, value in filters.items():
if value is not None:
clauses.append(f"{key} = ?")
params.append(value)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(limit)
with self._connect() as conn:
rows = conn.execute(
f"SELECT payload FROM outbox_events {where} ORDER BY created_at DESC LIMIT ?",
params,
).fetchall()
return [_json_load_model(OutboxEvent, row["payload"]) for row in rows]
def list_outbox_events_by_job(self, job_id: str, limit: int = 100) -> list[OutboxEvent]:
return self.list_outbox_events(payload_ref=f"commit_job:{job_id}", limit=limit)
def claim_outbox_event(self, event_id: str) -> OutboxEvent | None:
with self._connect() as conn:
row = conn.execute("SELECT payload FROM outbox_events WHERE id = ?", (event_id,)).fetchone()
if not row:
return None
event = _json_load_model(OutboxEvent, row["payload"])
now = datetime.now(timezone.utc)
if event.status != OutboxEventStatus.PENDING:
return None
if event.next_retry_at and event.next_retry_at > now:
return None
event.status = OutboxEventStatus.PROCESSING
event.locked_by = "inline"
event.locked_at = now
event.lease_expires_at = now + _safe_timedelta(300)
event.updated_at = now
with self._connect() as conn:
cursor = conn.execute(
"""
UPDATE outbox_events
SET status = ?, locked_by = ?, locked_at = ?, lease_expires_at = ?,
payload = ?, metadata_json = ?, updated_at = ?
WHERE id = ?
AND status = ?
AND (next_retry_at IS NULL OR next_retry_at <= ?)
""",
(
event.status.value,
event.locked_by,
event.locked_at.isoformat() if event.locked_at else None,
event.lease_expires_at.isoformat() if event.lease_expires_at else None,
_json_dump_model(event),
json.dumps(event.metadata, ensure_ascii=False),
event.updated_at.isoformat(),
event.id,
OutboxEventStatus.PENDING.value,
now.isoformat(),
),
)
return event if cursor.rowcount else None
def claim_pending_outbox_events(
self,
limit: int,
worker_id: str,
lease_seconds: int,
) -> list[OutboxEvent]:
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
with self._connect() as conn:
rows = conn.execute(
"""
SELECT payload FROM outbox_events
WHERE status = ?
AND (next_retry_at IS NULL OR next_retry_at <= ?)
ORDER BY created_at ASC
LIMIT ?
""",
(OutboxEventStatus.PENDING.value, now_iso, limit),
).fetchall()
claimed: list[OutboxEvent] = []
with self._connect() as conn:
for row in rows:
event = _json_load_model(OutboxEvent, row["payload"])
if event.status != OutboxEventStatus.PENDING:
continue
event.status = OutboxEventStatus.PROCESSING
event.locked_by = worker_id
event.locked_at = now
event.lease_expires_at = now + _safe_timedelta(lease_seconds)
event.updated_at = now
cursor = conn.execute(
"""
UPDATE outbox_events
SET status = ?, locked_by = ?, locked_at = ?, lease_expires_at = ?,
payload = ?, metadata_json = ?, updated_at = ?
WHERE id = ?
AND status = ?
AND (next_retry_at IS NULL OR next_retry_at <= ?)
""",
(
event.status.value,
event.locked_by,
event.locked_at.isoformat() if event.locked_at else None,
event.lease_expires_at.isoformat() if event.lease_expires_at else None,
_json_dump_model(event),
json.dumps(event.metadata, ensure_ascii=False),
event.updated_at.isoformat(),
event.id,
OutboxEventStatus.PENDING.value,
now_iso,
),
)
if cursor.rowcount:
claimed.append(event)
return claimed
def release_expired_processing_events(self, now: datetime | None = None) -> list[OutboxEvent]:
now = now or datetime.now(timezone.utc)
with self._connect() as conn:
rows = conn.execute(
"""
SELECT payload FROM outbox_events
WHERE status = ?
AND lease_expires_at IS NOT NULL
AND lease_expires_at <= ?
""",
(OutboxEventStatus.PROCESSING.value, now.isoformat()),
).fetchall()
released: list[OutboxEvent] = []
for row in rows:
event = _json_load_model(OutboxEvent, row["payload"])
event.status = OutboxEventStatus.PENDING
event.locked_by = None
event.locked_at = None
event.lease_expires_at = None
event.updated_at = now
released.append(self.save_outbox_event(event))
return released
def update_outbox_event_status(
self,
event_id: str,
status: OutboxEventStatus | str,
last_error: str | None = None,
) -> OutboxEvent | None:
with self._connect() as conn:
row = conn.execute("SELECT payload FROM outbox_events WHERE id = ?", (event_id,)).fetchone()
if not row:
return None
event = _json_load_model(OutboxEvent, row["payload"])
event.status = OutboxEventStatus(_enum_value(status))
event.last_error = last_error
event.updated_at = datetime.now(timezone.utc)
if event.status != OutboxEventStatus.PROCESSING:
event.locked_by = None
event.locked_at = None
event.lease_expires_at = None
if event.status in {OutboxEventStatus.FAILED, OutboxEventStatus.DEAD_LETTER}:
event.attempt_count += 1
return self.save_outbox_event(event)
def save_commit_job(self, job: CommitJob) -> CommitJob:
existing = None
with self._connect() as conn:
row = conn.execute("SELECT payload FROM commit_jobs WHERE job_id = ?", (job.job_id,)).fetchone()
if row:
existing = _json_load_model(CommitJob, row["payload"])
now = datetime.now(timezone.utc)
if existing:
job.created_at = existing.created_at
job.updated_at = now
with self._connect() as conn:
conn.execute(
"""
INSERT OR REPLACE INTO commit_jobs(
job_id, workspace_id, user_id, agent_id, session_id, namespace,
status, requested_by, created_refs_count, error_message, payload,
created_at, updated_at, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job.job_id,
job.workspace_id,
job.user_id,
job.agent_id,
job.session_id,
job.namespace,
job.status.value,
job.requested_by,
job.created_refs_count,
job.error_message,
_json_dump_model(job),
job.created_at.isoformat(),
job.updated_at.isoformat(),
job.started_at.isoformat() if job.started_at else None,
job.finished_at.isoformat() if job.finished_at else None,
),
)
return job
def get_commit_job(self, job_id: str) -> CommitJob | None:
with self._connect() as conn:
row = conn.execute("SELECT payload FROM commit_jobs WHERE job_id = ?", (job_id,)).fetchone()
return _json_load_model(CommitJob, row["payload"]) if row else None
def update_commit_job_status(
self,
job_id: str,
status: str,
error_message: str | None = None,
created_refs_count: int | None = None,
) -> CommitJob | None:
from .schemas_v2 import OperationStatus
job = self.get_commit_job(job_id)
if not job:
return None
job.status = OperationStatus(_enum_value(status))
job.error_message = error_message
if created_refs_count is not None:
job.created_refs_count = created_refs_count
now = datetime.now(timezone.utc)
job.updated_at = now
if job.status.value == "running" and job.started_at is None:
job.started_at = now
if job.status.value in {"success", "failed", "partial_success"}:
job.finished_at = now
return self.save_commit_job(job)
def count_memory_refs(
self,
gateway_id: str | None = None,
session_id: str | None = None,
status: BackendRefStatus | str | None = None,
) -> int:
return len(self.list_memory_refs(gateway_id=gateway_id, session_id=session_id, status=status, limit=100000))
def build_repository() -> MetadataRepository: def build_repository() -> MetadataRepository:
config = get_config() config = get_config()
@ -325,4 +1101,3 @@ def build_repository() -> MetadataRepository:
repository = build_repository() repository = build_repository()

View File

@ -0,0 +1,228 @@
"""Schemas for the Memory Gateway v2 control-plane API."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Literal, Optional
from uuid import uuid4
from pydantic import BaseModel, Field
from .schemas import utc_now
class OperationStatus(str, Enum):
ACCEPTED = "accepted"
RUNNING = "running"
SUCCESS = "success"
PARTIAL_SUCCESS = "partial_success"
FAILED = "failed"
PENDING = "pending"
SKIPPED = "skipped"
class BackendRefStatus(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
class BackendType(str, Enum):
OPENVIKING = "openviking"
EVERMEMOS = "evermemos"
OBSIDIAN = "obsidian"
class MemoryRefType(str, Enum):
SESSION_ARCHIVE = "session_archive"
CONTEXT_RESOURCE = "context_resource"
MESSAGE_MEMORY = "message_memory"
EPISODIC_MEMORY = "episodic_memory"
PROFILE = "profile"
LONG_TERM_MEMORY = "long_term_memory"
DRAFT_REVIEW = "draft_review"
class TraceContext(BaseModel):
trace_id: Optional[str] = None
span_id: Optional[str] = None
parent_span_id: Optional[str] = None
request_id: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class IngestPolicy(BaseModel):
allow_openviking: bool = True
allow_evermemos: bool = True
allow_obsidian_review: bool = False
redact_sensitive: bool = True
require_human_review: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
class IngestRequest(BaseModel):
workspace_id: str
user_id: str
agent_id: str
session_id: str
turn_id: str
request_id: Optional[str] = None
idempotency_key: Optional[str] = None
namespace: str
source_type: str = "conversation"
source_event_id: Optional[str] = None
role: Literal["system", "user", "assistant", "tool", "agent"] = "user"
content: str
policy: IngestPolicy = Field(default_factory=IngestPolicy)
trace: TraceContext = Field(default_factory=TraceContext)
metadata: dict[str, Any] = Field(default_factory=dict)
class MemoryRef(BaseModel):
id: str = Field(default_factory=lambda: f"ref_{uuid4().hex[:16]}")
gateway_id: str
workspace_id: str
user_id: str
agent_id: Optional[str] = None
session_id: Optional[str] = None
turn_id: Optional[str] = None
namespace: Optional[str] = None
backend_type: BackendType
ref_type: MemoryRefType
native_id: Optional[str] = None
native_uri: Optional[str] = None
provenance_id: Optional[str] = None
idempotency_key: Optional[str] = None
content_hash: Optional[str] = None
source_type: Optional[str] = None
source_event_id: Optional[str] = None
status: BackendRefStatus = BackendRefStatus.PENDING
error_message: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class MemoryRefView(MemoryRef):
pass
class IngestResponse(BaseModel):
status: OperationStatus
gateway_id: str
provenance_id: str
request_id: Optional[str] = None
turn_id: str
refs: list[MemoryRefView] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class CommitRequest(BaseModel):
workspace_id: str
user_id: str
agent_id: Optional[str] = None
namespace: Optional[str] = None
request_id: Optional[str] = None
idempotency_key: Optional[str] = None
policy: IngestPolicy = Field(default_factory=IngestPolicy)
metadata: dict[str, Any] = Field(default_factory=dict)
class CommitResponse(BaseModel):
status: OperationStatus = OperationStatus.ACCEPTED
job_id: str
session_id: str
message: str = "commit accepted"
refs: list[MemoryRefView] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class OutboxSummary(BaseModel):
total_events: int = 0
pending_events: int = 0
processing_events: int = 0
success_events: int = 0
skipped_events: int = 0
dead_letter_events: int = 0
class CommitJobView(BaseModel):
job_id: str
workspace_id: str
user_id: str
agent_id: Optional[str] = None
session_id: str
namespace: Optional[str] = None
status: OperationStatus
created_refs_count: int = 0
error_message: Optional[str] = None
created_at: datetime
updated_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
outbox_summary: OutboxSummary = Field(default_factory=OutboxSummary)
class OutboxProcessResponse(BaseModel):
status: OperationStatus
worker_id: str
processed_count: int = 0
outbox_summary: OutboxSummary = Field(default_factory=OutboxSummary)
class RetrieveRequest(BaseModel):
workspace_id: str
user_id: str
agent_id: Optional[str] = None
session_id: Optional[str] = None
namespace: Optional[str] = None
query: str
limit: int = Field(default=10, ge=1, le=100)
metadata: dict[str, Any] = Field(default_factory=dict)
class ContextItem(BaseModel):
text: Optional[str] = None
source_backend: BackendType
ref_id: Optional[str] = None
score: float = 0.0
memory_type: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ContextConflict(BaseModel):
ref_ids: list[str] = Field(default_factory=list)
reason: str
metadata: dict[str, Any] = Field(default_factory=dict)
class RetrieveResponse(BaseModel):
status: OperationStatus
items: list[ContextItem] = Field(default_factory=list)
refs: list[MemoryRefView] = Field(default_factory=list)
conflicts: list[ContextConflict] = Field(default_factory=list)
trace_id: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class FeedbackRequest(BaseModel):
workspace_id: str
user_id: str
agent_id: Optional[str] = None
session_id: Optional[str] = None
namespace: Optional[str] = None
memory_ref_id: Optional[str] = None
feedback_type: Literal["useful", "not_useful", "incorrect", "duplicate", "outdated", "review_approved", "review_rejected"]
comment: Optional[str] = None
source_type: str = "manual"
source_event_id: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class FeedbackResponse(BaseModel):
status: OperationStatus
feedback_id: str
memory_ref_id: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@ -572,8 +572,10 @@ app.include_router(mcp_router, prefix="/mcp", tags=["mcp"])
# Generic Memory Gateway v1 routes are imported lazily here to avoid changing # Generic Memory Gateway v1 routes are imported lazily here to avoid changing
# the existing legacy /api and /mcp startup path. # the existing legacy /api and /mcp startup path.
from .api_v1 import router as api_v1_router # noqa: E402 from .api_v1 import router as api_v1_router # noqa: E402
from .api_v2 import router as api_v2_router # noqa: E402
app.include_router(api_v1_router) app.include_router(api_v1_router)
app.include_router(api_v2_router)
@app.post("/api/search", dependencies=[Depends(verify_api_key)]) @app.post("/api/search", dependencies=[Depends(verify_api_key)])

View File

@ -0,0 +1,970 @@
"""Service orchestration for the Memory Gateway v2 control plane."""
from __future__ import annotations
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable
from uuid import uuid4
from fastapi import HTTPException, status
from .backend_contracts import (
BackendOperation,
BackendCommitResult,
BackendProducedRef,
BackendResultStatus,
BackendWriteResult,
CommitJob,
OutboxEvent,
OutboxEventStatus,
)
from .evermemos_client import EverMemOSClient
from .openviking_client import get_openviking_client
from .repositories import MetadataRepository, repository
from .schemas import AuditLog
from .schemas_v2 import (
BackendRefStatus,
BackendType,
CommitJobView,
CommitRequest,
CommitResponse,
ContextItem,
FeedbackRequest,
FeedbackResponse,
IngestRequest,
IngestResponse,
MemoryRef,
MemoryRefView,
MemoryRefType,
OperationStatus,
OutboxProcessResponse,
OutboxSummary,
RetrieveRequest,
RetrieveResponse,
)
OpenVikingClientFactory = Callable[[], Awaitable[Any]]
class MemoryGatewayV2Service:
def __init__(
self,
repo: MetadataRepository = repository,
openviking_client_factory: OpenVikingClientFactory = get_openviking_client,
evermemos_client: Any | None = None,
) -> None:
self.repo = repo
self.openviking_client_factory = openviking_client_factory
self.evermemos_client = evermemos_client
async def ingest_conversation_turn(self, request: IngestRequest) -> IngestResponse:
normalized = self._normalize_ingest_request(request)
gateway_id = self._build_gateway_id(normalized)
provenance_id = self._build_provenance_id(normalized, gateway_id)
content_hash = self._content_hash(normalized.content)
self._check_namespace_access(normalized)
payload = self._apply_safety_policy(normalized)
refs: list[MemoryRef] = []
if normalized.policy.allow_openviking:
refs.append(
await self._write_openviking_turn(
normalized,
payload,
gateway_id=gateway_id,
provenance_id=provenance_id,
content_hash=content_hash,
)
)
else:
refs.append(
self._save_ref(
normalized,
gateway_id,
provenance_id,
BackendType.OPENVIKING,
MemoryRefType.SESSION_ARCHIVE,
BackendRefStatus.SKIPPED,
content_hash=content_hash,
metadata=self._control_metadata(normalized, content_hash, {"reason": "policy_disabled"}),
)
)
if normalized.policy.allow_evermemos:
refs.append(
await self._write_evermemos_message(
normalized,
payload,
gateway_id=gateway_id,
provenance_id=provenance_id,
content_hash=content_hash,
)
)
else:
refs.append(
self._save_ref(
normalized,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
MemoryRefType.MESSAGE_MEMORY,
BackendRefStatus.SKIPPED,
content_hash=content_hash,
metadata=self._control_metadata(normalized, content_hash, {"reason": "policy_disabled"}),
)
)
status_value = self._aggregate_ref_status(refs)
errors = [ref.error_message for ref in refs if ref.error_message]
self.repo.add_audit(
AuditLog(
actor_user_id=normalized.user_id,
actor_agent_id=normalized.agent_id,
action="v2_ingest_conversation_turn",
target_type="conversation_turn",
target_id=normalized.turn_id,
namespace=normalized.namespace,
metadata={
"gateway_id": gateway_id,
"provenance_id": provenance_id,
"idempotency_basis": self._idempotency_basis(normalized),
"content_hash": content_hash,
"status": status_value.value,
"source_type": normalized.source_type,
"source_event_id": normalized.source_event_id,
},
)
)
return IngestResponse(
status=status_value,
gateway_id=gateway_id,
provenance_id=provenance_id,
request_id=normalized.request_id,
turn_id=normalized.turn_id,
refs=self._view_refs(refs),
errors=errors,
metadata={"backend_count": len(refs)},
)
async def commit_session(self, session_id: str, request: CommitRequest) -> CommitResponse:
# TODO(v2): add a worker that consumes these outbox events and writes
# resulting backend refs. This method intentionally only records
# control-plane intent.
job_id = f"job_{uuid4().hex[:16]}"
gateway_id = self._commit_gateway_id(session_id, request)
job = CommitJob(
job_id=job_id,
workspace_id=request.workspace_id,
user_id=request.user_id,
agent_id=request.agent_id,
session_id=session_id,
namespace=request.namespace,
requested_by=request.user_id,
)
self.repo.save_commit_job(job)
self._create_commit_outbox_events(gateway_id, job, request)
self.repo.add_audit(
AuditLog(
actor_user_id=request.user_id,
actor_agent_id=request.agent_id,
action="v2_commit_session_accepted",
target_type="session",
target_id=session_id,
namespace=request.namespace,
metadata={
"job_id": job_id,
"gateway_id": gateway_id,
"workspace_id": request.workspace_id,
"outbox_events": 2,
},
)
)
return CommitResponse(
job_id=job_id,
session_id=session_id,
metadata={"gateway_id": gateway_id, "outbox_events": 2},
)
async def retrieve_context(self, request: RetrieveRequest) -> RetrieveResponse:
# TODO(v2): expand namespace ACL, fan out concurrently to OpenViking and
# EverMemOS, then apply lightweight merge/rerank before returning.
refs = self.repo.list_memory_refs(
workspace_id=request.workspace_id,
user_id=request.user_id,
agent_id=request.agent_id,
session_id=request.session_id,
namespace=request.namespace,
limit=request.limit,
)
items = [
ContextItem(
text=None,
source_backend=ref.backend_type,
ref_id=ref.id,
score=0.0,
memory_type=ref.ref_type.value,
metadata={
"status": ref.status.value,
"native_id": ref.native_id,
"native_uri": ref.native_uri,
},
)
for ref in refs
]
trace_id = request.metadata.get("trace_id") if request.metadata else None
return RetrieveResponse(
status=OperationStatus.SUCCESS,
items=items,
refs=self._view_refs(refs),
conflicts=[],
trace_id=trace_id,
metadata={"skeleton": True},
)
async def record_memory_feedback(self, request: FeedbackRequest) -> FeedbackResponse:
# TODO(v2): persist review/feedback state in a dedicated table and route
# accepted corrections back to the owning backend adapter.
feedback_id = f"fb_{uuid4().hex[:16]}"
self.repo.add_audit(
AuditLog(
actor_user_id=request.user_id,
actor_agent_id=request.agent_id,
action=f"v2_feedback:{request.feedback_type}",
target_type="memory_ref",
target_id=request.memory_ref_id,
namespace=request.namespace,
metadata={
"feedback_id": feedback_id,
"workspace_id": request.workspace_id,
"comment": request.comment,
"source_type": request.source_type,
"source_event_id": request.source_event_id,
},
)
)
return FeedbackResponse(
status=OperationStatus.ACCEPTED,
feedback_id=feedback_id,
memory_ref_id=request.memory_ref_id,
)
async def process_pending_outbox_events(
self,
limit: int = 100,
worker_id: str | None = None,
lease_seconds: int = 300,
) -> list[OutboxEvent]:
worker_id = worker_id or f"worker_{uuid4().hex[:12]}"
self.repo.release_expired_processing_events()
events = self.repo.claim_pending_outbox_events(
limit=limit,
worker_id=worker_id,
lease_seconds=lease_seconds,
)
processed: list[OutboxEvent] = []
for event in events:
try:
result = await self._execute_outbox_event(event)
except Exception as exc: # noqa: BLE001
result = BackendCommitResult(
backend_type=event.backend_type,
operation=event.operation,
status=BackendResultStatus.FAILED,
retryable=True,
error_code="adapter_exception",
error_message=str(exc),
)
processed.append(self._apply_outbox_result(event, result))
return processed
async def process_pending_outbox_events_summary(
self,
limit: int = 100,
worker_id: str | None = None,
lease_seconds: int = 300,
) -> OutboxProcessResponse:
worker_id = worker_id or f"worker_{uuid4().hex[:12]}"
processed = await self.process_pending_outbox_events(
limit=limit,
worker_id=worker_id,
lease_seconds=lease_seconds,
)
return OutboxProcessResponse(
status=OperationStatus.SUCCESS,
worker_id=worker_id,
processed_count=len(processed),
outbox_summary=self._outbox_summary(),
)
async def process_commit_job(self, job_id: str) -> CommitJob:
job = self.repo.get_commit_job(job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Commit job not found")
self.repo.update_commit_job_status(job_id, OperationStatus.RUNNING.value)
self.repo.release_expired_processing_events()
events = self.repo.list_outbox_events_by_job(job_id)
for event in events:
if event.status == OutboxEventStatus.PENDING:
await self.process_outbox_event(event.id)
events = self.repo.list_outbox_events_by_job(job_id)
final_status = self._aggregate_commit_job_status(events)
created_refs_count = self.repo.count_memory_refs(session_id=job.session_id, status=BackendRefStatus.SUCCESS)
error_message = self._commit_job_error_message(events)
updated = self.repo.update_commit_job_status(
job_id,
final_status.value,
error_message=error_message,
created_refs_count=created_refs_count,
)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Commit job not found")
return updated
def get_commit_job_view(self, job_id: str) -> CommitJobView:
job = self.repo.get_commit_job(job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Commit job not found")
return CommitJobView(
job_id=job.job_id,
workspace_id=job.workspace_id,
user_id=job.user_id,
agent_id=job.agent_id,
session_id=job.session_id,
namespace=job.namespace,
status=job.status,
created_refs_count=job.created_refs_count,
error_message=job.error_message,
created_at=job.created_at,
updated_at=job.updated_at,
started_at=job.started_at,
finished_at=job.finished_at,
outbox_summary=self._outbox_summary(self.repo.list_outbox_events_by_job(job_id)),
)
async def process_outbox_event(self, event_id: str) -> OutboxEvent | None:
event = self.repo.claim_outbox_event(event_id)
if not event:
return None
try:
result = await self._execute_outbox_event(event)
except Exception as exc: # noqa: BLE001
result = BackendCommitResult(
backend_type=event.backend_type,
operation=event.operation,
status=BackendResultStatus.FAILED,
retryable=True,
error_code="adapter_exception",
error_message=str(exc),
)
return self._apply_outbox_result(event, result)
def list_memory_refs(
self,
workspace_id: str | None = None,
user_id: str | None = None,
agent_id: str | None = None,
session_id: str | None = None,
namespace: str | None = None,
backend_type: BackendType | str | None = None,
ref_type: MemoryRefType | str | None = None,
status: BackendRefStatus | str | None = None,
limit: int = 100,
) -> list[MemoryRef]:
return self.repo.list_memory_refs(
workspace_id=workspace_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
namespace=namespace,
backend_type=backend_type,
ref_type=ref_type,
status=status,
limit=limit,
)
async def _execute_outbox_event(self, event: OutboxEvent) -> BackendCommitResult | BackendWriteResult:
payload = self._outbox_payload(event)
if event.operation != BackendOperation.COMMIT_SESSION:
return BackendCommitResult(
backend_type=event.backend_type,
operation=event.operation,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "unsupported_operation"},
)
if event.backend_type == BackendType.OPENVIKING:
client = await self.openviking_client_factory()
if not hasattr(client, "commit_session_v2"):
return BackendCommitResult(
backend_type=BackendType.OPENVIKING,
operation=BackendOperation.COMMIT_SESSION,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "adapter_method_missing"},
)
result = await client.commit_session_v2(payload)
return result
if event.backend_type == BackendType.EVERMEMOS:
client = self.evermemos_client or EverMemOSClient()
if not hasattr(client, "extract_profile_long_term_v2"):
return BackendCommitResult(
backend_type=BackendType.EVERMEMOS,
operation=BackendOperation.COMMIT_SESSION,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "adapter_method_missing"},
)
result = client.extract_profile_long_term_v2(payload)
if hasattr(result, "__await__"):
result = await result
return result
return BackendCommitResult(
backend_type=event.backend_type,
operation=event.operation,
status=BackendResultStatus.SKIPPED,
metadata={"reason": "unsupported_backend"},
)
def _outbox_payload(self, event: OutboxEvent) -> dict[str, Any]:
return {
"event_id": event.id,
"gateway_id": event.gateway_id,
"workspace_id": event.workspace_id,
"user_id": event.user_id,
"agent_id": event.agent_id,
"session_id": event.session_id,
"backend_type": event.backend_type.value,
"operation": event.operation.value,
"payload_ref": event.payload_ref,
"metadata": self._safe_control_metadata(event.metadata),
}
def _apply_outbox_result(self, event: OutboxEvent, result: BackendCommitResult | BackendWriteResult) -> OutboxEvent:
result_data = self._backend_result_to_dict(result)
safe_result = self._backend_control_metadata(result_data)
event.metadata = self._safe_control_metadata({**event.metadata, "backend_result": safe_result})
event.last_error = result.error_message
event.updated_at = datetime.now(timezone.utc)
if result.status == BackendResultStatus.SUCCESS:
self._write_commit_memory_refs(event, result)
event.status = OutboxEventStatus.SUCCESS
self._clear_outbox_lock(event)
return self.repo.save_outbox_event(event)
if result.status == BackendResultStatus.SKIPPED:
event.status = OutboxEventStatus.SKIPPED
self._clear_outbox_lock(event)
return self.repo.save_outbox_event(event)
if result.status == BackendResultStatus.FAILED:
return self._handle_failed_outbox_event(event, result)
event.status = OutboxEventStatus.PENDING
self._clear_outbox_lock(event)
return self.repo.save_outbox_event(event)
def _handle_failed_outbox_event(self, event: OutboxEvent, result: BackendCommitResult | BackendWriteResult) -> OutboxEvent:
event.attempt_count += 1
event.last_error = result.error_message
if result.retryable and event.attempt_count < event.max_attempts:
event.status = OutboxEventStatus.PENDING
event.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=min(60, 2**event.attempt_count))
else:
event.status = OutboxEventStatus.DEAD_LETTER
event.next_retry_at = None
self._clear_outbox_lock(event)
return self.repo.save_outbox_event(event)
def _clear_outbox_lock(self, event: OutboxEvent) -> None:
event.locked_by = None
event.locked_at = None
event.lease_expires_at = None
def _write_commit_memory_refs(self, event: OutboxEvent, result: BackendCommitResult | BackendWriteResult) -> list[MemoryRef]:
produced_refs = result.refs if isinstance(result, BackendCommitResult) and result.refs else []
if produced_refs:
refs: list[MemoryRef] = []
for index, produced_ref in enumerate(produced_refs):
saved = self._write_commit_memory_ref(event, result, produced_ref, index=index)
if saved:
refs.append(saved)
return refs
fallback = BackendProducedRef(
ref_type=self._commit_ref_type(event, result),
native_id=result.native_id,
native_uri=result.native_uri,
metadata={},
)
saved = self._write_commit_memory_ref(event, result, fallback, index=0)
return [saved] if saved else []
def _write_commit_memory_ref(
self,
event: OutboxEvent,
result: BackendCommitResult | BackendWriteResult,
produced_ref: BackendProducedRef,
index: int,
) -> MemoryRef | None:
stable_key = self._produced_ref_stable_key(produced_ref, index)
if not produced_ref.native_id and not produced_ref.native_uri and not stable_key:
return None
ref_type = produced_ref.ref_type
ref_id = self._memory_ref_id(event.gateway_id, event.backend_type, ref_type, produced_ref.native_id, produced_ref.native_uri, stable_key)
existing = self.repo.get_memory_ref(ref_id)
safe_produced_metadata = self._safe_control_metadata(produced_ref.metadata)
ref = MemoryRef(
id=ref_id,
gateway_id=event.gateway_id,
workspace_id=event.workspace_id,
user_id=event.user_id,
agent_id=event.agent_id,
session_id=event.session_id,
namespace=event.metadata.get("namespace"),
backend_type=event.backend_type,
ref_type=ref_type,
native_id=produced_ref.native_id,
native_uri=produced_ref.native_uri,
provenance_id="prov_"
+ hashlib.sha256(f"{event.id}|{ref_type.value}|{produced_ref.native_id}|{produced_ref.native_uri}".encode("utf-8")).hexdigest()[:24],
source_type="commit_session",
source_event_id=event.id,
status=BackendRefStatus.SUCCESS,
error_message=None,
metadata={
"schema_version": "memory-gateway.commit-ref.v2",
"job_id": self._job_id_from_payload_ref(event.payload_ref),
"outbox_event_id": event.id,
"operation": event.operation.value,
"produced_ref_index": index,
"stable_key": stable_key,
"produced_ref": safe_produced_metadata,
"backend_result": self._backend_control_metadata(self._backend_result_to_dict(result)),
},
)
if existing:
ref.created_at = existing.created_at
return self.repo.save_memory_ref(ref)
def _commit_ref_type(self, event: OutboxEvent, result: BackendCommitResult | BackendWriteResult) -> MemoryRefType:
requested = result.metadata.get("ref_type") if isinstance(result.metadata, dict) else None
if requested:
try:
return MemoryRefType(requested)
except ValueError:
pass
if event.backend_type == BackendType.OPENVIKING:
return MemoryRefType.SESSION_ARCHIVE
if event.backend_type == BackendType.EVERMEMOS:
return MemoryRefType.LONG_TERM_MEMORY
return MemoryRefType.DRAFT_REVIEW
def _job_id_from_payload_ref(self, payload_ref: str | None) -> str | None:
if payload_ref and payload_ref.startswith("commit_job:"):
return payload_ref.split(":", 1)[1]
return None
def _aggregate_commit_job_status(self, events: list[OutboxEvent]) -> OperationStatus:
if not events:
return OperationStatus.FAILED
statuses = {event.status for event in events}
if statuses.issubset({OutboxEventStatus.SUCCESS, OutboxEventStatus.SKIPPED}):
return OperationStatus.SUCCESS
if statuses == {OutboxEventStatus.DEAD_LETTER} or statuses == {OutboxEventStatus.FAILED}:
return OperationStatus.FAILED
if OutboxEventStatus.PENDING in statuses or OutboxEventStatus.PROCESSING in statuses:
if OutboxEventStatus.SUCCESS in statuses:
return OperationStatus.PARTIAL_SUCCESS
return OperationStatus.FAILED
if OutboxEventStatus.SUCCESS in statuses:
return OperationStatus.PARTIAL_SUCCESS
return OperationStatus.FAILED
def _commit_job_error_message(self, events: list[OutboxEvent]) -> str | None:
errors = [event.last_error for event in events if event.last_error]
return "; ".join(errors) if errors else None
def _outbox_summary(self, events: list[OutboxEvent] | None = None) -> OutboxSummary:
events = events if events is not None else self.repo.list_outbox_events(limit=100000)
counts = {status: 0 for status in OutboxEventStatus}
for event in events:
counts[event.status] = counts.get(event.status, 0) + 1
return OutboxSummary(
total_events=len(events),
pending_events=counts.get(OutboxEventStatus.PENDING, 0),
processing_events=counts.get(OutboxEventStatus.PROCESSING, 0),
success_events=counts.get(OutboxEventStatus.SUCCESS, 0),
skipped_events=counts.get(OutboxEventStatus.SKIPPED, 0),
dead_letter_events=counts.get(OutboxEventStatus.DEAD_LETTER, 0),
)
def _safe_control_metadata(self, metadata: dict[str, Any] | None) -> dict[str, Any]:
if not metadata:
return {}
blocked = {"content", "raw_request", "messages", "conversation", "transcript"}
safe: dict[str, Any] = {}
for key, value in metadata.items():
if key in blocked:
continue
if isinstance(value, dict):
nested = self._safe_control_metadata(value)
if nested:
safe[key] = nested
elif isinstance(value, (str, int, float, bool)) or value is None:
safe[key] = value
elif isinstance(value, list):
safe[key] = [item for item in value if isinstance(item, (str, int, float, bool))]
return safe
def _normalize_ingest_request(self, request: IngestRequest) -> IngestRequest:
data = request.model_copy(deep=True)
data.namespace = data.namespace.strip("/")
data.content = data.content.strip()
if not data.namespace:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="namespace is required")
if not data.content:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required")
return data
def _build_gateway_id(self, request: IngestRequest) -> str:
seed = self._idempotency_basis(request)
return "gw_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
def _build_provenance_id(self, request: IngestRequest, gateway_id: str) -> str:
seed = f"{gateway_id}|{self._idempotency_basis(request)}"
return "prov_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
def _idempotency_basis(self, request: IngestRequest) -> str:
if request.idempotency_key:
return f"idempotency_key:{request.workspace_id}:{request.idempotency_key}"
if request.source_event_id:
return f"source_event_id:{request.workspace_id}:{request.source_type}:{request.source_event_id}"
return f"turn:{request.workspace_id}:{request.session_id}:{request.turn_id}"
def _content_hash(self, content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _check_namespace_access(self, request: IngestRequest) -> None:
# TODO(v2): enforce workspace/user/agent namespace ACL tree.
if not request.workspace_id or not request.user_id or not request.agent_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="workspace, user, and agent are required")
def _apply_safety_policy(self, request: IngestRequest) -> dict[str, Any]:
# TODO(v2): apply configurable redaction/safety rules before fan-out.
return {
"workspace_id": request.workspace_id,
"user_id": request.user_id,
"agent_id": request.agent_id,
"session_id": request.session_id,
"turn_id": request.turn_id,
"namespace": request.namespace,
"source_type": request.source_type,
"source_event_id": request.source_event_id,
"role": request.role,
"content": request.content,
"metadata": request.metadata,
"trace": request.trace.model_dump(mode="json"),
}
async def _write_openviking_turn(
self,
request: IngestRequest,
payload: dict[str, Any],
gateway_id: str,
provenance_id: str,
content_hash: str,
) -> MemoryRef:
try:
client = await self.openviking_client_factory()
if not hasattr(client, "ingest_conversation_turn"):
return self._save_ref(
request,
gateway_id,
provenance_id,
BackendType.OPENVIKING,
MemoryRefType.SESSION_ARCHIVE,
BackendRefStatus.SKIPPED,
content_hash=content_hash,
metadata=self._control_metadata(request, content_hash, {"reason": "adapter_method_missing"}),
)
result = await client.ingest_conversation_turn(payload)
return self._ref_from_backend_result(
request,
gateway_id,
provenance_id,
BackendType.OPENVIKING,
MemoryRefType.SESSION_ARCHIVE,
result,
content_hash,
)
except Exception as exc: # noqa: BLE001
return self._save_ref(
request,
gateway_id,
provenance_id,
BackendType.OPENVIKING,
MemoryRefType.SESSION_ARCHIVE,
BackendRefStatus.FAILED,
content_hash=content_hash,
error_message=str(exc),
metadata=self._control_metadata(request, content_hash),
)
async def _write_evermemos_message(
self,
request: IngestRequest,
payload: dict[str, Any],
gateway_id: str,
provenance_id: str,
content_hash: str,
) -> MemoryRef:
try:
client = self.evermemos_client or EverMemOSClient()
if not hasattr(client, "ingest_message"):
return self._save_ref(
request,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
MemoryRefType.MESSAGE_MEMORY,
BackendRefStatus.SKIPPED,
content_hash=content_hash,
metadata=self._control_metadata(request, content_hash, {"reason": "adapter_method_missing"}),
)
result = client.ingest_message(payload)
if hasattr(result, "__await__"):
result = await result
return self._ref_from_backend_result(
request,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
MemoryRefType.MESSAGE_MEMORY,
result,
content_hash,
)
except Exception as exc: # noqa: BLE001
return self._save_ref(
request,
gateway_id,
provenance_id,
BackendType.EVERMEMOS,
MemoryRefType.MESSAGE_MEMORY,
BackendRefStatus.FAILED,
content_hash=content_hash,
error_message=str(exc),
metadata=self._control_metadata(request, content_hash),
)
def _ref_from_backend_result(
self,
request: IngestRequest,
gateway_id: str,
provenance_id: str,
backend_type: BackendType,
ref_type: MemoryRefType,
result: Any,
content_hash: str,
) -> MemoryRef:
data = self._backend_result_to_dict(result)
raw_status = str(data.get("status") or "success")
ref_status = BackendRefStatus.SUCCESS
if raw_status in {BackendRefStatus.PENDING.value, BackendRefStatus.FAILED.value, BackendRefStatus.SKIPPED.value}:
ref_status = BackendRefStatus(raw_status)
native_id = data.get("native_id") or data.get("id") or data.get("memory_id") or data.get("session_id")
native_uri = data.get("native_uri") or data.get("uri") or data.get("url")
return self._save_ref(
request,
gateway_id,
provenance_id,
backend_type,
ref_type,
ref_status,
native_id=native_id,
native_uri=native_uri,
error_message=data.get("error") or data.get("error_message"),
content_hash=content_hash,
metadata=self._control_metadata(
request,
content_hash,
{"backend_response": self._backend_control_metadata(data)},
),
)
def _save_ref(
self,
request: IngestRequest,
gateway_id: str,
provenance_id: str,
backend_type: BackendType,
ref_type: MemoryRefType,
ref_status: BackendRefStatus,
native_id: str | None = None,
native_uri: str | None = None,
error_message: str | None = None,
content_hash: str | None = None,
metadata: dict[str, Any] | None = None,
) -> MemoryRef:
ref_id = self._memory_ref_id(gateway_id, backend_type, ref_type)
existing = self.repo.get_memory_ref(ref_id)
ref = MemoryRef(
id=ref_id,
gateway_id=gateway_id,
workspace_id=request.workspace_id,
user_id=request.user_id,
agent_id=request.agent_id,
session_id=request.session_id,
turn_id=request.turn_id,
namespace=request.namespace,
backend_type=backend_type,
ref_type=ref_type,
native_id=native_id,
native_uri=native_uri,
provenance_id=provenance_id,
idempotency_key=request.idempotency_key,
content_hash=content_hash,
source_type=request.source_type,
source_event_id=request.source_event_id,
status=ref_status,
error_message=error_message,
metadata=metadata or {},
)
if existing:
ref.created_at = existing.created_at
return self.repo.save_memory_ref(ref)
def _memory_ref_id(
self,
gateway_id: str,
backend_type: BackendType,
ref_type: MemoryRefType,
native_id: str | None = None,
native_uri: str | None = None,
stable_key: str | None = None,
) -> str:
native_key = f"|{native_id or ''}|{native_uri or ''}|{stable_key or ''}" if native_id or native_uri or stable_key else ""
seed = f"{gateway_id}|{backend_type.value}|{ref_type.value}{native_key}"
return "ref_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
def _produced_ref_stable_key(self, produced_ref: BackendProducedRef, index: int) -> str | None:
for key in ("stable_key", "backend_ref_key", "idempotency_key"):
value = produced_ref.metadata.get(key)
if isinstance(value, str) and value:
return value
if not produced_ref.native_id and not produced_ref.native_uri:
return f"produced_ref_index:{index}"
return None
def _aggregate_ref_status(self, refs: list[MemoryRef]) -> OperationStatus:
if not refs:
return OperationStatus.SKIPPED
statuses = {ref.status for ref in refs}
if statuses == {BackendRefStatus.SUCCESS}:
return OperationStatus.SUCCESS
if BackendRefStatus.SUCCESS in statuses and (BackendRefStatus.FAILED in statuses or BackendRefStatus.SKIPPED in statuses):
return OperationStatus.PARTIAL_SUCCESS
if statuses == {BackendRefStatus.FAILED}:
return OperationStatus.FAILED
if BackendRefStatus.FAILED in statuses:
return OperationStatus.PARTIAL_SUCCESS
if statuses == {BackendRefStatus.SKIPPED}:
return OperationStatus.SKIPPED
return OperationStatus.PENDING
def _view_refs(self, refs: list[MemoryRef]) -> list[MemoryRefView]:
return [MemoryRefView.model_validate(ref.model_dump(mode="json")) for ref in refs]
def _backend_control_metadata(self, data: dict[str, Any]) -> dict[str, Any]:
allowed_keys = {
"status",
"reason",
"native_id",
"native_uri",
"id",
"uri",
"url",
"memory_id",
"session_id",
"retryable",
"error_code",
"error",
"error_message",
"latency_ms",
}
metadata = {key: value for key, value in data.items() if key in allowed_keys}
nested_metadata = data.get("metadata")
if isinstance(nested_metadata, dict):
for key in ("reason", "backend_request_id", "latency_ms", "schema_version"):
if key in nested_metadata:
metadata[key] = nested_metadata[key]
return metadata
def _control_metadata(
self,
request: IngestRequest,
content_hash: str,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
metadata: dict[str, Any] = {
"schema_version": "memory-gateway.control-ref.v2",
"idempotency_basis": self._idempotency_basis(request),
"content_hash": content_hash,
}
source_channel = request.metadata.get("source_channel") or request.metadata.get("channel")
if source_channel:
metadata["source_channel"] = source_channel
if request.trace.trace_id:
metadata["trace_id"] = request.trace.trace_id
if request.trace.request_id:
metadata["trace_request_id"] = request.trace.request_id
if extra:
metadata.update(extra)
return metadata
def _backend_result_to_dict(self, result: Any) -> dict[str, Any]:
if isinstance(result, BackendWriteResult):
data = result.model_dump(mode="json")
if result.status == BackendResultStatus.FAILED and result.retryable:
data["retryable"] = True
return data
if hasattr(result, "model_dump"):
return result.model_dump(mode="json")
return result if isinstance(result, dict) else {"raw": str(result)}
def _commit_gateway_id(self, session_id: str, request: CommitRequest) -> str:
basis = request.idempotency_key or request.request_id or session_id
seed = f"commit:{request.workspace_id}:{session_id}:{basis}"
return "gwc_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
def _create_commit_outbox_events(self, gateway_id: str, job: CommitJob, request: CommitRequest) -> None:
metadata = {
"schema_version": "memory-gateway.outbox.v2",
"job_id": job.job_id,
"namespace": request.namespace,
"idempotency_key": request.idempotency_key,
"request_id": request.request_id,
}
for backend_type in (BackendType.OPENVIKING, BackendType.EVERMEMOS):
event = OutboxEvent(
id=self._outbox_event_id(gateway_id, backend_type, BackendOperation.COMMIT_SESSION),
event_type="commit_session",
gateway_id=gateway_id,
workspace_id=request.workspace_id,
user_id=request.user_id,
agent_id=request.agent_id,
session_id=job.session_id,
backend_type=backend_type,
operation=BackendOperation.COMMIT_SESSION,
payload_ref=f"commit_job:{job.job_id}",
metadata=metadata,
)
self.repo.save_outbox_event(event)
def _outbox_event_id(self, gateway_id: str, backend_type: BackendType, operation: BackendOperation) -> str:
seed = f"{gateway_id}|{backend_type.value}|{operation.value}"
return "outbox_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
v2_service = MemoryGatewayV2Service()

View File

@ -12,18 +12,25 @@ class ServerConfig(BaseModel):
class OpenVikingConfig(BaseModel): class OpenVikingConfig(BaseModel):
"""OpenViking 后端配置""" """OpenViking 后端配置"""
enabled: bool = False
mode: Literal["offline", "skeleton", "real"] = "offline"
url: str = "http://localhost:1933" url: str = "http://localhost:1933"
api_key: str = "" api_key: str = ""
timeout: int = 30 timeout: int = 30
verify_ssl: bool = True
ingest_path: str = "/api/v1/sessions/{session_id}/messages"
class EverMemOSConfig(BaseModel): class EverMemOSConfig(BaseModel):
"""External EverMemOS consolidation service configuration.""" """External EverMemOS consolidation service configuration."""
enabled: bool = True enabled: bool = False
mode: Literal["offline", "skeleton", "real"] = "offline"
url: str = "http://127.0.0.1:1995" url: str = "http://127.0.0.1:1995"
api_key: str = "" api_key: str = ""
timeout: int = 30 timeout: int = 30
verify_ssl: bool = True
health_path: str = "/health" health_path: str = "/health"
ingest_path: str = "/api/v1/memories"
consolidate_path: str = "/v1/sessions/consolidate" consolidate_path: str = "/v1/sessions/consolidate"
fallback_to_local: bool = True fallback_to_local: bool = True

View File

@ -0,0 +1,49 @@
"""Lightweight v2 outbox worker entrypoint.
Usage:
python -m memory_gateway.worker_v2 --limit 100 --worker-id local-worker --lease-seconds 300
"""
from __future__ import annotations
import argparse
import asyncio
import json
from typing import Sequence
from uuid import uuid4
from .services_v2 import v2_service
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Process Memory Gateway v2 outbox events once.")
parser.add_argument("--limit", type=int, default=100, help="Maximum pending events to claim and process.")
parser.add_argument("--worker-id", default=None, help="Stable worker id recorded in outbox lease fields.")
parser.add_argument("--lease-seconds", type=int, default=300, help="Lease duration for claimed events.")
return parser
async def run_once(limit: int, worker_id: str | None, lease_seconds: int) -> dict[str, object]:
worker_id = worker_id or f"worker_{uuid4().hex[:12]}"
response = await v2_service.process_pending_outbox_events_summary(
limit=limit,
worker_id=worker_id,
lease_seconds=lease_seconds,
)
return response.model_dump(mode="json")
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
payload = asyncio.run(
run_once(
limit=args.limit,
worker_id=args.worker_id,
lease_seconds=args.lease_seconds,
)
)
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,102 @@
import asyncio
import os
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
import memory_gateway.api_v2 as api_v2
from memory_gateway.evermemos_client import EverMemOSClient
from memory_gateway.openviking_client import OpenVikingClient
from memory_gateway.repositories import InMemoryRepository
from memory_gateway.schemas_v2 import BackendRefStatus, BackendType, IngestRequest, IngestResponse, OperationStatus
from memory_gateway.server_auth import verify_api_key_compat
from memory_gateway.services_v2 import MemoryGatewayV2Service
pytestmark = pytest.mark.skipif(
os.environ.get("RUN_REAL_BACKEND_TESTS") != "1",
reason="real backend ingest test is opt-in; set RUN_REAL_BACKEND_TESTS=1",
)
def _env(name: str) -> str:
value = os.environ.get(name)
if not value:
pytest.skip(f"{name} is required for real backend ingest test")
return value
def test_real_openviking_and_evermemos_ingest_writes_memory_refs():
openviking_base_url = _env("OPENVIKING_BASE_URL")
evermemos_base_url = _env("EVERMEMOS_BASE_URL")
openviking_api_key = os.environ.get("OPENVIKING_API_KEY", "")
evermemos_api_key = os.environ.get("EVERMEMOS_API_KEY", "")
openviking_ingest_path = os.environ.get("OPENVIKING_INGEST_PATH")
evermemos_ingest_path = os.environ.get("EVERMEMOS_INGEST_PATH")
async def openviking_factory():
return OpenVikingClient(
mode="real",
base_url=openviking_base_url,
api_key=openviking_api_key,
ingest_path=openviking_ingest_path,
)
repo = InMemoryRepository()
service = MemoryGatewayV2Service(
repo=repo,
openviking_client_factory=openviking_factory,
evermemos_client=EverMemOSClient(
mode="real",
base_url=evermemos_base_url,
api_key=evermemos_api_key,
ingest_path=evermemos_ingest_path,
),
)
run_id = uuid4().hex[:12]
response = asyncio.run(post_ingest(service, run_id))
refs = repo.list_memory_refs(session_id=f"real_ingest_sess_{run_id}", limit=10)
assert {ref.backend_type for ref in refs} == {BackendType.OPENVIKING, BackendType.EVERMEMOS}
assert all(ref.content_hash for ref in refs)
openviking_ref = next(ref for ref in refs if ref.backend_type == BackendType.OPENVIKING)
evermemos_ref = next(ref for ref in refs if ref.backend_type == BackendType.EVERMEMOS)
assert openviking_ref.status == BackendRefStatus.SUCCESS
if evermemos_ref.status == BackendRefStatus.SUCCESS:
assert response.status == OperationStatus.SUCCESS
assert evermemos_ref.native_id
assert evermemos_ref.native_uri
else:
assert evermemos_ref.status == BackendRefStatus.FAILED
assert response.status == OperationStatus.PARTIAL_SUCCESS
assert evermemos_ref.error_message
async def post_ingest(service: MemoryGatewayV2Service, run_id: str):
api_v2.v2_service = service
app = FastAPI()
app.dependency_overrides[verify_api_key_compat] = lambda: None
app.include_router(api_v2.router)
request = IngestRequest(
workspace_id=os.environ.get("REAL_BACKEND_WORKSPACE_ID", "ws_real_ingest"),
user_id=os.environ.get("REAL_BACKEND_USER_ID", "user_real_ingest"),
agent_id=os.environ.get("REAL_BACKEND_AGENT_ID", "agent_real_ingest"),
session_id=f"real_ingest_sess_{run_id}",
turn_id=f"real_ingest_turn_{run_id}",
request_id=f"real_ingest_req_{run_id}",
idempotency_key=f"real_ingest_idem_{run_id}",
namespace=os.environ.get("REAL_BACKEND_NAMESPACE", "workspace/ws_real_ingest/user/user_real_ingest"),
source_type="integration_test",
source_event_id=f"real_ingest_evt_{run_id}",
role="user",
content=f"Memory Gateway real ingest smoke test {run_id}",
metadata={"source_channel": "integration_test"},
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.post("/v2/conversations/ingest", json=request.model_dump(mode="json"))
response.raise_for_status()
return IngestResponse.model_validate(response.json())

View File

@ -1,8 +1,10 @@
import asyncio
import sys import sys
import types import types
import pytest
from fastapi import HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
def install_test_stubs() -> None: def install_test_stubs() -> None:
@ -59,8 +61,8 @@ def install_test_stubs() -> None:
install_test_stubs() install_test_stubs()
from memory_gateway.server import app import memory_gateway.server as server
from memory_gateway.types import Config, ObsidianConfig, SearchResult, ServerConfig from memory_gateway.types import CommitSummaryRequest, Config, ObsidianConfig, SearchRequest, SearchResult, ServerConfig
class FakeOVClient: class FakeOVClient:
@ -117,9 +119,13 @@ async def fake_summarize_with_llm(content, **kwargs):
} }
def build_headers(api_key: str | None): class FakeUploadFile:
return {"x-api-key": api_key} if api_key is not None else {} def __init__(self, filename: str, content: bytes) -> None:
self.filename = filename
self._content = content
async def read(self) -> bytes:
return self._content
def test_health_requires_api_key(monkeypatch): def test_health_requires_api_key(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
@ -131,14 +137,15 @@ def test_health_requires_api_key(monkeypatch):
fake_get_openviking_client, fake_get_openviking_client,
) )
monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm) monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm)
monkeypatch.setattr("memory_gateway.server.v1_service.evermemos_health", lambda: {"status": "disabled"})
with TestClient(app) as client: with pytest.raises(HTTPException) as exc_info:
response = client.get("/health") server.verify_api_key()
assert response.status_code == 401 assert exc_info.value.status_code == 401
response = client.get("/health", headers=build_headers("secret")) server.verify_api_key("secret")
assert response.status_code == 200 payload = asyncio.run(server.health_check())
assert response.json()["openviking"]["status"] == "ok" assert payload["openviking"]["status"] == "ok"
def test_mcp_rpc_lists_tools_with_api_key(monkeypatch): def test_mcp_rpc_lists_tools_with_api_key(monkeypatch):
@ -151,18 +158,11 @@ def test_mcp_rpc_lists_tools_with_api_key(monkeypatch):
fake_get_openviking_client, fake_get_openviking_client,
) )
with TestClient(app) as client: server.verify_api_key("secret")
response = client.post( tools = asyncio.run(server.list_tools())
"/mcp/rpc", assert len(tools) >= 7
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, assert any(tool.name == "commit_summary" for tool in tools)
headers=build_headers("secret"), assert any(tool.name == "memory_search" for tool in tools)
)
assert response.status_code == 200
payload = response.json()
assert payload["jsonrpc"] == "2.0"
assert len(payload["result"]["tools"]) >= 7
assert any(tool["name"] == "commit_summary" for tool in payload["result"]["tools"])
assert any(tool["name"] == "memory_search" for tool in payload["result"]["tools"])
def test_search_passes_through_gateway(monkeypatch): def test_search_passes_through_gateway(monkeypatch):
@ -175,12 +175,9 @@ def test_search_passes_through_gateway(monkeypatch):
fake_get_openviking_client, fake_get_openviking_client,
) )
with TestClient(app) as client: payload = asyncio.run(server.api_search(SearchRequest(query="phishing")))
response = client.post("/api/search", json={"query": "phishing"}) assert payload["total"] == 1
assert response.status_code == 200 assert payload["results"][0]["abstract"] == "phishing"
payload = response.json()
assert payload["total"] == 1
assert payload["results"][0]["abstract"] == "phishing"
def test_summary_endpoint_builds_generic_artifact(monkeypatch): def test_summary_endpoint_builds_generic_artifact(monkeypatch):
@ -194,28 +191,26 @@ def test_summary_endpoint_builds_generic_artifact(monkeypatch):
) )
monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm) monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm)
with TestClient(app) as client: payload = asyncio.run(
response = client.post( server.api_commit_summary(
"/api/summary", CommitSummaryRequest(
json={ title="Demo investigation summary",
"title": "Demo investigation summary", content="结论:这是一次高价值沉淀。\n- 证据:命中历史 case。\n- 建议:后续复用该处置路径。",
"content": "结论:这是一次高价值沉淀。\n- 证据:命中历史 case。\n- 建议:后续复用该处置路径。", namespace="demo",
"namespace": "demo", memory_type="knowledge",
"memory_type": "knowledge", tags=["demo", "summary"],
"tags": ["demo", "summary"], persist_as="none",
"persist_as": "none", )
},
) )
assert response.status_code == 200 )
payload = response.json() assert payload["status"] == "ok"
assert payload["status"] == "ok" assert payload["artifact"]["title"] == "Demo investigation summary"
assert payload["artifact"]["title"] == "Demo investigation summary" assert payload["artifact"]["namespace"] == "demo"
assert payload["artifact"]["namespace"] == "demo" assert payload["artifact"]["memory_type"] == "knowledge"
assert payload["artifact"]["memory_type"] == "knowledge" assert payload["artifact"]["summary"].startswith("LLM summary:")
assert payload["artifact"]["summary"].startswith("LLM summary:") assert payload["artifact"]["llm"]["provider"] == "fake"
assert payload["artifact"]["llm"]["provider"] == "fake" assert payload["memory_result"] is None
assert payload["memory_result"] is None assert payload["resource_result"] is None
assert payload["resource_result"] is None
def test_knowledge_upload_converts_saves_and_commits(monkeypatch, tmp_path): def test_knowledge_upload_converts_saves_and_commits(monkeypatch, tmp_path):
@ -230,21 +225,27 @@ def test_knowledge_upload_converts_saves_and_commits(monkeypatch, tmp_path):
monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm) monkeypatch.setattr("memory_gateway.server.summarize_with_llm", fake_summarize_with_llm)
monkeypatch.setattr("memory_gateway.server.convert_file_to_markdown", lambda path: "# Uploaded Doc\n\nImportant uploaded knowledge.") monkeypatch.setattr("memory_gateway.server.convert_file_to_markdown", lambda path: "# Uploaded Doc\n\nImportant uploaded knowledge.")
with TestClient(app) as client: async def fake_to_thread(func, *args, **kwargs):
response = client.post( return func(*args, **kwargs)
"/api/knowledge/upload",
data={ monkeypatch.setattr("memory_gateway.server.asyncio.to_thread", fake_to_thread)
"title": "Uploaded Knowledge",
"namespace": "demo", upload = FakeUploadFile(filename="sample.txt", content=b"hello")
"knowledge_type": "playbook", payload = asyncio.run(
"tags": "demo,upload", server.api_upload_knowledge(
"persist_as": "resource", file=upload,
}, title="Uploaded Knowledge",
files={"file": ("sample.txt", b"hello", "text/plain")}, namespace="demo",
) knowledge_type="playbook",
tags="demo,upload",
source=None,
obsidian_dir=None,
resource_uri=None,
persist_as="resource",
max_summary_chars=1000,
)
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok" assert payload["status"] == "ok"
assert payload["artifact"]["schema_version"] == "memory-gateway.knowledge_upload.v1" assert payload["artifact"]["schema_version"] == "memory-gateway.knowledge_upload.v1"
assert payload["artifact"]["knowledge_type"] == "playbook" assert payload["artifact"]["knowledge_type"] == "playbook"

1925
tests/test_v2_api.py Normal file

File diff suppressed because it is too large Load Diff