memgentic-api 0.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memgentic_api-0.4.4/.gitignore +58 -0
- memgentic_api-0.4.4/PKG-INFO +14 -0
- memgentic_api-0.4.4/memgentic_api/__init__.py +3 -0
- memgentic_api-0.4.4/memgentic_api/auth.py +33 -0
- memgentic_api-0.4.4/memgentic_api/deps.py +42 -0
- memgentic_api-0.4.4/memgentic_api/main.py +291 -0
- memgentic_api-0.4.4/memgentic_api/routes/__init__.py +0 -0
- memgentic_api-0.4.4/memgentic_api/routes/collections.py +242 -0
- memgentic_api-0.4.4/memgentic_api/routes/graph.py +50 -0
- memgentic_api-0.4.4/memgentic_api/routes/import_export.py +98 -0
- memgentic_api-0.4.4/memgentic_api/routes/ingestion.py +112 -0
- memgentic_api-0.4.4/memgentic_api/routes/memories.py +523 -0
- memgentic_api-0.4.4/memgentic_api/routes/skills.py +558 -0
- memgentic_api-0.4.4/memgentic_api/routes/sources.py +32 -0
- memgentic_api-0.4.4/memgentic_api/routes/stats.py +109 -0
- memgentic_api-0.4.4/memgentic_api/routes/uploads.py +304 -0
- memgentic_api-0.4.4/memgentic_api/routes/websocket.py +38 -0
- memgentic_api-0.4.4/memgentic_api/schemas.py +451 -0
- memgentic_api-0.4.4/pyproject.toml +37 -0
- memgentic_api-0.4.4/tests/__init__.py +0 -0
- memgentic_api-0.4.4/tests/conftest.py +192 -0
- memgentic_api-0.4.4/tests/test_batch_api.py +102 -0
- memgentic_api-0.4.4/tests/test_collections_api.py +227 -0
- memgentic_api-0.4.4/tests/test_health.py +15 -0
- memgentic_api-0.4.4/tests/test_import_export.py +96 -0
- memgentic_api-0.4.4/tests/test_ingestion_api.py +40 -0
- memgentic_api-0.4.4/tests/test_memories.py +218 -0
- memgentic_api-0.4.4/tests/test_pins_api.py +96 -0
- memgentic_api-0.4.4/tests/test_security.py +148 -0
- memgentic_api-0.4.4/tests/test_skills_api.py +285 -0
- memgentic_api-0.4.4/tests/test_sources.py +37 -0
- memgentic_api-0.4.4/tests/test_stats.py +81 -0
- memgentic_api-0.4.4/tests/test_uploads_api.py +171 -0
- memgentic_api-0.4.4/tests/test_websocket.py +146 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.eggs/
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# UV
|
|
14
|
+
# uv.lock is tracked for reproducible builds
|
|
15
|
+
|
|
16
|
+
# Environment
|
|
17
|
+
.env
|
|
18
|
+
|
|
19
|
+
# Claude Code
|
|
20
|
+
.claude/
|
|
21
|
+
|
|
22
|
+
# IDE
|
|
23
|
+
.vscode/
|
|
24
|
+
.idea/
|
|
25
|
+
*.swp
|
|
26
|
+
*.swo
|
|
27
|
+
|
|
28
|
+
# OS
|
|
29
|
+
.DS_Store
|
|
30
|
+
Thumbs.db
|
|
31
|
+
|
|
32
|
+
# Memgentic data (local development)
|
|
33
|
+
*.db
|
|
34
|
+
data/
|
|
35
|
+
.memgentic/
|
|
36
|
+
|
|
37
|
+
# Backup archives
|
|
38
|
+
*.tar.gz
|
|
39
|
+
|
|
40
|
+
# Docker
|
|
41
|
+
docker-compose.override.yml
|
|
42
|
+
|
|
43
|
+
# Testing
|
|
44
|
+
.pytest_cache/
|
|
45
|
+
.coverage
|
|
46
|
+
htmlcov/
|
|
47
|
+
|
|
48
|
+
# Playwright
|
|
49
|
+
.playwright-mcp/
|
|
50
|
+
|
|
51
|
+
# Private/internal working docs (not for public repo)
|
|
52
|
+
cloud/
|
|
53
|
+
.mneme/
|
|
54
|
+
|
|
55
|
+
# Rust
|
|
56
|
+
target/
|
|
57
|
+
Cargo.lock
|
|
58
|
+
!memgentic-native/Cargo.lock
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memgentic-api
|
|
3
|
+
Version: 0.4.4
|
|
4
|
+
Summary: REST API for Memgentic — memory search, management, and real-time updates
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: fastapi>=0.130
|
|
7
|
+
Requires-Dist: memgentic[intelligence]
|
|
8
|
+
Requires-Dist: python-multipart>=0.0.20
|
|
9
|
+
Requires-Dist: slowapi>=0.1
|
|
10
|
+
Requires-Dist: uvicorn[standard]>=0.34
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: httpx>=0.28; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio>=1.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=9.0; extra == 'dev'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Optional API key authentication for local Memgentic instances.
|
|
2
|
+
|
|
3
|
+
When MEMGENTIC_API_KEY is set in the environment, all API requests must include
|
|
4
|
+
a matching X-API-Key header. When not set, the API is open (local mode).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hmac
|
|
10
|
+
|
|
11
|
+
from fastapi import HTTPException, Security
|
|
12
|
+
from fastapi.security import APIKeyHeader
|
|
13
|
+
from memgentic.config import settings
|
|
14
|
+
|
|
15
|
+
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def verify_api_key(
|
|
19
|
+
api_key: str | None = Security(_api_key_header),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Verify API key if MEMGENTIC_API_KEY is configured.
|
|
22
|
+
|
|
23
|
+
If no API key is configured, all requests are allowed (local mode).
|
|
24
|
+
If configured, requests must include a matching X-API-Key header.
|
|
25
|
+
"""
|
|
26
|
+
if not settings.api_key:
|
|
27
|
+
return # No API key configured — local open mode
|
|
28
|
+
|
|
29
|
+
if not api_key:
|
|
30
|
+
raise HTTPException(status_code=401, detail="API key required")
|
|
31
|
+
|
|
32
|
+
if not hmac.compare_digest(api_key, settings.api_key):
|
|
33
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""FastAPI dependency injection for Memgentic stores and services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, Request
|
|
8
|
+
from memgentic.processing.embedder import Embedder
|
|
9
|
+
from memgentic.processing.pipeline import IngestionPipeline
|
|
10
|
+
from memgentic.storage.metadata import MetadataStore
|
|
11
|
+
from memgentic.storage.vectors import VectorStore
|
|
12
|
+
from slowapi import Limiter
|
|
13
|
+
from slowapi.util import get_remote_address
|
|
14
|
+
|
|
15
|
+
limiter = Limiter(key_func=get_remote_address)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_metadata_store(request: Request) -> MetadataStore:
|
|
19
|
+
"""Get the shared MetadataStore from app state."""
|
|
20
|
+
return request.app.state.metadata_store
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_vector_store(request: Request) -> VectorStore:
|
|
24
|
+
"""Get the shared VectorStore from app state."""
|
|
25
|
+
return request.app.state.vector_store
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_embedder(request: Request) -> Embedder:
|
|
29
|
+
"""Get the shared Embedder from app state."""
|
|
30
|
+
return request.app.state.embedder
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_pipeline(request: Request) -> IngestionPipeline:
|
|
34
|
+
"""Get the shared IngestionPipeline from app state."""
|
|
35
|
+
return request.app.state.pipeline
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Type aliases for cleaner route signatures
|
|
39
|
+
MetadataStoreDep = Annotated[MetadataStore, Depends(get_metadata_store)]
|
|
40
|
+
VectorStoreDep = Annotated[VectorStore, Depends(get_vector_store)]
|
|
41
|
+
EmbedderDep = Annotated[Embedder, Depends(get_embedder)]
|
|
42
|
+
PipelineDep = Annotated[IngestionPipeline, Depends(get_pipeline)]
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Memgentic REST API — FastAPI application with lifespan management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from email.utils import formatdate, parsedate_to_datetime
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from fastapi import Depends, FastAPI
|
|
11
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from memgentic.config import settings
|
|
14
|
+
from slowapi import _rate_limit_exceeded_handler
|
|
15
|
+
from slowapi.errors import RateLimitExceeded
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.responses import Response
|
|
18
|
+
|
|
19
|
+
from memgentic_api.auth import verify_api_key
|
|
20
|
+
from memgentic_api.deps import limiter
|
|
21
|
+
from memgentic_api.routes import (
|
|
22
|
+
collections,
|
|
23
|
+
graph,
|
|
24
|
+
import_export,
|
|
25
|
+
ingestion,
|
|
26
|
+
memories,
|
|
27
|
+
skills,
|
|
28
|
+
sources,
|
|
29
|
+
stats,
|
|
30
|
+
uploads,
|
|
31
|
+
websocket,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = structlog.get_logger()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
38
|
+
"""Add security headers to all responses."""
|
|
39
|
+
|
|
40
|
+
async def dispatch(self, request, call_next):
|
|
41
|
+
response = await call_next(request)
|
|
42
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
43
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
44
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
45
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
46
|
+
response.headers["Content-Security-Policy"] = (
|
|
47
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
48
|
+
"img-src 'self' data:; connect-src 'self' ws: wss:"
|
|
49
|
+
)
|
|
50
|
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
51
|
+
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
|
56
|
+
"""Reject requests with bodies larger than MAX_BODY_SIZE."""
|
|
57
|
+
|
|
58
|
+
MAX_BODY_SIZE = 10 * 1024 * 1024 # 10MB
|
|
59
|
+
|
|
60
|
+
async def dispatch(self, request, call_next):
|
|
61
|
+
if request.headers.get("content-length"):
|
|
62
|
+
content_length = int(request.headers["content-length"])
|
|
63
|
+
if content_length > self.MAX_BODY_SIZE:
|
|
64
|
+
return JSONResponse(
|
|
65
|
+
status_code=413,
|
|
66
|
+
content={"detail": "Request body too large"},
|
|
67
|
+
)
|
|
68
|
+
return await call_next(request)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Path patterns for Cache-Control max-age values
|
|
72
|
+
_STATS_PATHS = ("/api/v1/stats", "/api/v1/metrics", "/api/v1/sources", "/api/v1/health/detailed")
|
|
73
|
+
_LIST_PATHS = ("/api/v1/memories",)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CachingHeadersMiddleware(BaseHTTPMiddleware):
|
|
77
|
+
"""Add ETag, Cache-Control, and Last-Modified headers to GET responses.
|
|
78
|
+
|
|
79
|
+
Also handles conditional requests: If-None-Match (ETag) and If-Modified-Since.
|
|
80
|
+
Returns 304 Not Modified when the content has not changed.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
async def dispatch(self, request, call_next):
|
|
84
|
+
# Only apply caching to GET requests
|
|
85
|
+
if request.method != "GET":
|
|
86
|
+
return await call_next(request)
|
|
87
|
+
|
|
88
|
+
# Skip WebSocket upgrade requests
|
|
89
|
+
if request.headers.get("upgrade", "").lower() == "websocket":
|
|
90
|
+
return await call_next(request)
|
|
91
|
+
|
|
92
|
+
response = await call_next(request)
|
|
93
|
+
|
|
94
|
+
# Only cache successful JSON responses
|
|
95
|
+
if response.status_code != 200:
|
|
96
|
+
return response
|
|
97
|
+
|
|
98
|
+
# Read body for ETag computation
|
|
99
|
+
body_chunks: list[bytes] = []
|
|
100
|
+
async for chunk in response.body_iterator:
|
|
101
|
+
body_chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode())
|
|
102
|
+
body = b"".join(body_chunks)
|
|
103
|
+
|
|
104
|
+
# Generate ETag from content hash
|
|
105
|
+
etag = '"' + hashlib.md5(body).hexdigest() + '"' # noqa: S324
|
|
106
|
+
|
|
107
|
+
# Check If-None-Match
|
|
108
|
+
if_none_match = request.headers.get("if-none-match")
|
|
109
|
+
if if_none_match and if_none_match == etag:
|
|
110
|
+
return Response(status_code=304, headers={"ETag": etag})
|
|
111
|
+
|
|
112
|
+
# Determine Cache-Control max-age based on path
|
|
113
|
+
path = request.url.path
|
|
114
|
+
if any(path.startswith(p) for p in _STATS_PATHS):
|
|
115
|
+
max_age = 300
|
|
116
|
+
elif any(path.startswith(p) for p in _LIST_PATHS):
|
|
117
|
+
max_age = 60
|
|
118
|
+
else:
|
|
119
|
+
max_age = 60 # Default for other GET endpoints
|
|
120
|
+
|
|
121
|
+
# Build new response with caching headers
|
|
122
|
+
new_response = Response(
|
|
123
|
+
content=body,
|
|
124
|
+
status_code=response.status_code,
|
|
125
|
+
media_type=response.media_type,
|
|
126
|
+
)
|
|
127
|
+
# Copy original headers
|
|
128
|
+
for key, value in response.headers.items():
|
|
129
|
+
if key.lower() not in ("content-length", "content-encoding", "transfer-encoding"):
|
|
130
|
+
new_response.headers[key] = value
|
|
131
|
+
|
|
132
|
+
new_response.headers["ETag"] = etag
|
|
133
|
+
new_response.headers["Cache-Control"] = f"private, max-age={max_age}"
|
|
134
|
+
new_response.headers["Last-Modified"] = formatdate(usegmt=True)
|
|
135
|
+
|
|
136
|
+
# Check If-Modified-Since
|
|
137
|
+
if_modified_since = request.headers.get("if-modified-since")
|
|
138
|
+
if if_modified_since:
|
|
139
|
+
try:
|
|
140
|
+
since_dt = parsedate_to_datetime(if_modified_since)
|
|
141
|
+
# For simplicity, compare against "now" — content was just generated
|
|
142
|
+
# A 304 is only returned when the ETag matches (above)
|
|
143
|
+
_ = since_dt # placeholder for future last-modified tracking
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
return new_response
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@asynccontextmanager
|
|
151
|
+
async def lifespan(app: FastAPI):
|
|
152
|
+
"""Initialize stores on startup, close on shutdown."""
|
|
153
|
+
from memgentic.processing.embedder import Embedder
|
|
154
|
+
from memgentic.processing.pipeline import IngestionPipeline
|
|
155
|
+
from memgentic.storage.metadata import MetadataStore
|
|
156
|
+
from memgentic.storage.vectors import VectorStore
|
|
157
|
+
|
|
158
|
+
metadata_store = MetadataStore(settings.sqlite_path)
|
|
159
|
+
vector_store = VectorStore(settings)
|
|
160
|
+
embedder = Embedder(settings)
|
|
161
|
+
|
|
162
|
+
# Optional: intelligence package for LLM client and knowledge graph
|
|
163
|
+
llm_client = None
|
|
164
|
+
graph = None
|
|
165
|
+
try:
|
|
166
|
+
from memgentic.processing.llm import LLMClient
|
|
167
|
+
|
|
168
|
+
llm_client = LLMClient(settings)
|
|
169
|
+
except ImportError:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
from memgentic.graph.knowledge import create_knowledge_graph
|
|
174
|
+
|
|
175
|
+
graph = create_knowledge_graph(settings.graph_path)
|
|
176
|
+
await graph.load()
|
|
177
|
+
logger.info("api.intelligence_loaded", graph_nodes=graph.node_count)
|
|
178
|
+
except ImportError:
|
|
179
|
+
logger.info(
|
|
180
|
+
"api.no_intelligence",
|
|
181
|
+
msg="Intelligence extras not installed. Graph and advanced search unavailable.",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
pipeline = IngestionPipeline(
|
|
185
|
+
settings,
|
|
186
|
+
metadata_store,
|
|
187
|
+
vector_store,
|
|
188
|
+
embedder,
|
|
189
|
+
llm_client=llm_client,
|
|
190
|
+
graph=graph,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
await metadata_store.initialize()
|
|
194
|
+
await vector_store.initialize()
|
|
195
|
+
|
|
196
|
+
app.state.metadata_store = metadata_store
|
|
197
|
+
app.state.vector_store = vector_store
|
|
198
|
+
app.state.embedder = embedder
|
|
199
|
+
app.state.pipeline = pipeline
|
|
200
|
+
app.state.graph = graph
|
|
201
|
+
|
|
202
|
+
logger.info("api.startup", storage=settings.storage_backend.value)
|
|
203
|
+
|
|
204
|
+
yield
|
|
205
|
+
|
|
206
|
+
if graph:
|
|
207
|
+
await graph.save()
|
|
208
|
+
await embedder.close()
|
|
209
|
+
await metadata_store.close()
|
|
210
|
+
await vector_store.close()
|
|
211
|
+
logger.info("api.shutdown")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
app = FastAPI(
|
|
215
|
+
title="Memgentic API",
|
|
216
|
+
description="Universal AI Memory Layer — search, manage, and stream memories",
|
|
217
|
+
version="0.1.0",
|
|
218
|
+
lifespan=lifespan,
|
|
219
|
+
docs_url="/docs",
|
|
220
|
+
redoc_url="/redoc",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
app.state.limiter = limiter
|
|
224
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
225
|
+
|
|
226
|
+
# Security middlewares — order matters (Starlette processes outermost first)
|
|
227
|
+
# 1. Security headers on all responses
|
|
228
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
229
|
+
|
|
230
|
+
# 2. CORS — allow dashboard and local dev
|
|
231
|
+
app.add_middleware(
|
|
232
|
+
CORSMiddleware,
|
|
233
|
+
allow_origins=["http://localhost:3000", "http://localhost:3001", "https://app.memgentic.dev"],
|
|
234
|
+
allow_credentials=True,
|
|
235
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
236
|
+
allow_headers=["Content-Type", "Authorization", "X-API-Key", "If-None-Match"],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# 3. Request size limit — reject oversized payloads early
|
|
240
|
+
app.add_middleware(RequestSizeLimitMiddleware)
|
|
241
|
+
|
|
242
|
+
# 4. HTTP caching headers (ETag, Cache-Control) for GET endpoints
|
|
243
|
+
app.add_middleware(CachingHeadersMiddleware)
|
|
244
|
+
|
|
245
|
+
# Mount routers — all require API key when MEMGENTIC_API_KEY is set
|
|
246
|
+
_auth = [Depends(verify_api_key)]
|
|
247
|
+
app.include_router(memories.router, prefix="/api/v1", tags=["memories"], dependencies=_auth)
|
|
248
|
+
app.include_router(sources.router, prefix="/api/v1", tags=["sources"], dependencies=_auth)
|
|
249
|
+
app.include_router(stats.router, prefix="/api/v1", tags=["stats"], dependencies=_auth)
|
|
250
|
+
app.include_router(
|
|
251
|
+
import_export.router, prefix="/api/v1", tags=["import/export"], dependencies=_auth
|
|
252
|
+
)
|
|
253
|
+
app.include_router(graph.router, prefix="/api/v1", tags=["graph"], dependencies=_auth)
|
|
254
|
+
app.include_router(collections.router, prefix="/api/v1", tags=["collections"], dependencies=_auth)
|
|
255
|
+
app.include_router(uploads.router, prefix="/api/v1", tags=["uploads"], dependencies=_auth)
|
|
256
|
+
app.include_router(skills.router, prefix="/api/v1", tags=["skills"], dependencies=_auth)
|
|
257
|
+
app.include_router(ingestion.router, prefix="/api/v1", tags=["ingestion"], dependencies=_auth)
|
|
258
|
+
|
|
259
|
+
# WebSocket — no auth dependency (clients authenticate via initial message if needed)
|
|
260
|
+
app.include_router(websocket.router, prefix="/api/v1", tags=["websocket"])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.get("/api/v1/health", tags=["health"])
|
|
264
|
+
async def health_check():
|
|
265
|
+
"""Health check endpoint — verifies storage connectivity."""
|
|
266
|
+
checks: dict[str, str] = {}
|
|
267
|
+
|
|
268
|
+
# Check SQLite
|
|
269
|
+
try:
|
|
270
|
+
metadata = app.state.metadata_store
|
|
271
|
+
await metadata.get_total_count()
|
|
272
|
+
checks["sqlite"] = "ok"
|
|
273
|
+
except Exception:
|
|
274
|
+
checks["sqlite"] = "error"
|
|
275
|
+
|
|
276
|
+
# Check vector store
|
|
277
|
+
try:
|
|
278
|
+
vectors = app.state.vector_store
|
|
279
|
+
await vectors.get_collection_info()
|
|
280
|
+
checks["vectors"] = "ok"
|
|
281
|
+
except Exception:
|
|
282
|
+
checks["vectors"] = "error"
|
|
283
|
+
|
|
284
|
+
overall = "ok" if all(v == "ok" for v in checks.values()) else "degraded"
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
"status": overall,
|
|
288
|
+
"version": "0.1.0",
|
|
289
|
+
"storage_backend": settings.storage_backend.value,
|
|
290
|
+
"checks": checks,
|
|
291
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Collection CRUD and membership endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
7
|
+
from memgentic.config import settings
|
|
8
|
+
from memgentic.events import EventType, MemgenticEvent, event_bus
|
|
9
|
+
from memgentic.models import Collection
|
|
10
|
+
|
|
11
|
+
from memgentic_api.deps import MetadataStoreDep, limiter
|
|
12
|
+
from memgentic_api.schemas import (
|
|
13
|
+
AddMemoryToCollectionRequest,
|
|
14
|
+
CollectionListResponse,
|
|
15
|
+
CollectionResponse,
|
|
16
|
+
CreateCollectionRequest,
|
|
17
|
+
MemoryListResponse,
|
|
18
|
+
MemoryResponse,
|
|
19
|
+
SourceResponse,
|
|
20
|
+
UpdateCollectionRequest,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger()
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _collection_to_response(collection: Collection, memory_count: int = 0) -> CollectionResponse:
|
|
28
|
+
"""Convert a core Collection model to an API CollectionResponse."""
|
|
29
|
+
return CollectionResponse(
|
|
30
|
+
id=collection.id,
|
|
31
|
+
user_id=collection.user_id,
|
|
32
|
+
name=collection.name,
|
|
33
|
+
description=collection.description,
|
|
34
|
+
color=collection.color,
|
|
35
|
+
icon=collection.icon,
|
|
36
|
+
position=collection.position,
|
|
37
|
+
memory_count=memory_count,
|
|
38
|
+
created_at=collection.created_at,
|
|
39
|
+
updated_at=collection.updated_at,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _memory_to_response(memory) -> MemoryResponse:
|
|
44
|
+
"""Convert a core Memory model to an API MemoryResponse."""
|
|
45
|
+
return MemoryResponse(
|
|
46
|
+
id=memory.id,
|
|
47
|
+
content=memory.content,
|
|
48
|
+
content_type=memory.content_type.value,
|
|
49
|
+
platform=memory.source.platform.value,
|
|
50
|
+
topics=memory.topics,
|
|
51
|
+
entities=memory.entities,
|
|
52
|
+
confidence=memory.confidence,
|
|
53
|
+
status=memory.status.value,
|
|
54
|
+
created_at=memory.created_at,
|
|
55
|
+
last_accessed=memory.last_accessed,
|
|
56
|
+
access_count=memory.access_count,
|
|
57
|
+
source=SourceResponse(
|
|
58
|
+
platform=memory.source.platform.value,
|
|
59
|
+
platform_version=memory.source.platform_version,
|
|
60
|
+
session_id=memory.source.session_id,
|
|
61
|
+
session_title=memory.source.session_title,
|
|
62
|
+
capture_method=memory.source.capture_method.value,
|
|
63
|
+
original_timestamp=memory.source.original_timestamp,
|
|
64
|
+
file_path=memory.source.file_path,
|
|
65
|
+
),
|
|
66
|
+
is_pinned=memory.is_pinned,
|
|
67
|
+
pinned_at=memory.pinned_at,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- Collection CRUD ---
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/collections")
|
|
75
|
+
@limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
|
|
76
|
+
async def list_collections(
|
|
77
|
+
request: Request,
|
|
78
|
+
metadata_store: MetadataStoreDep,
|
|
79
|
+
) -> CollectionListResponse:
|
|
80
|
+
"""List all collections with memory counts."""
|
|
81
|
+
collections = await metadata_store.get_collections()
|
|
82
|
+
responses = []
|
|
83
|
+
for coll in collections:
|
|
84
|
+
count = await metadata_store.get_collection_memory_count(coll.id)
|
|
85
|
+
responses.append(_collection_to_response(coll, memory_count=count))
|
|
86
|
+
return CollectionListResponse(collections=responses, total=len(responses))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.post("/collections", status_code=201)
|
|
90
|
+
@limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
|
|
91
|
+
async def create_collection(
|
|
92
|
+
request: Request,
|
|
93
|
+
body: CreateCollectionRequest,
|
|
94
|
+
metadata_store: MetadataStoreDep,
|
|
95
|
+
) -> CollectionResponse:
|
|
96
|
+
"""Create a new collection."""
|
|
97
|
+
collection = Collection(
|
|
98
|
+
name=body.name,
|
|
99
|
+
description=body.description,
|
|
100
|
+
color=body.color,
|
|
101
|
+
icon=body.icon,
|
|
102
|
+
)
|
|
103
|
+
await metadata_store.create_collection(collection)
|
|
104
|
+
logger.info("collections.created", id=collection.id, name=collection.name)
|
|
105
|
+
await event_bus.emit(
|
|
106
|
+
MemgenticEvent(
|
|
107
|
+
type=EventType.COLLECTION_CREATED,
|
|
108
|
+
data={
|
|
109
|
+
"id": collection.id,
|
|
110
|
+
"name": collection.name,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return _collection_to_response(collection, memory_count=0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.patch("/collections/{collection_id}")
|
|
118
|
+
@limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
|
|
119
|
+
async def update_collection(
|
|
120
|
+
request: Request,
|
|
121
|
+
collection_id: str,
|
|
122
|
+
body: UpdateCollectionRequest,
|
|
123
|
+
metadata_store: MetadataStoreDep,
|
|
124
|
+
) -> CollectionResponse:
|
|
125
|
+
"""Update a collection's metadata."""
|
|
126
|
+
existing = await metadata_store.get_collection(collection_id)
|
|
127
|
+
if not existing:
|
|
128
|
+
raise HTTPException(status_code=404, detail="Collection not found")
|
|
129
|
+
|
|
130
|
+
update_data = body.model_dump(exclude_unset=True)
|
|
131
|
+
if update_data:
|
|
132
|
+
await metadata_store.update_collection(collection_id, **update_data)
|
|
133
|
+
|
|
134
|
+
updated = await metadata_store.get_collection(collection_id)
|
|
135
|
+
count = await metadata_store.get_collection_memory_count(collection_id)
|
|
136
|
+
await event_bus.emit(
|
|
137
|
+
MemgenticEvent(
|
|
138
|
+
type=EventType.COLLECTION_UPDATED,
|
|
139
|
+
data={
|
|
140
|
+
"id": collection_id,
|
|
141
|
+
"name": updated.name,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return _collection_to_response(updated, memory_count=count)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.delete("/collections/{collection_id}", status_code=204)
|
|
149
|
+
@limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
|
|
150
|
+
async def delete_collection(
|
|
151
|
+
request: Request,
|
|
152
|
+
collection_id: str,
|
|
153
|
+
metadata_store: MetadataStoreDep,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Delete a collection and its membership links."""
|
|
156
|
+
existing = await metadata_store.get_collection(collection_id)
|
|
157
|
+
if not existing:
|
|
158
|
+
raise HTTPException(status_code=404, detail="Collection not found")
|
|
159
|
+
await metadata_store.delete_collection(collection_id)
|
|
160
|
+
logger.info("collections.deleted", id=collection_id)
|
|
161
|
+
await event_bus.emit(
|
|
162
|
+
MemgenticEvent(
|
|
163
|
+
type=EventType.COLLECTION_DELETED,
|
|
164
|
+
data={"id": collection_id},
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- Collection Membership ---
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.get("/collections/{collection_id}/memories")
|
|
173
|
+
@limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
|
|
174
|
+
async def list_collection_memories(
|
|
175
|
+
request: Request,
|
|
176
|
+
collection_id: str,
|
|
177
|
+
metadata_store: MetadataStoreDep,
|
|
178
|
+
page: int = Query(default=1, ge=1),
|
|
179
|
+
page_size: int = Query(default=20, ge=1, le=100),
|
|
180
|
+
) -> MemoryListResponse:
|
|
181
|
+
"""List memories in a collection."""
|
|
182
|
+
existing = await metadata_store.get_collection(collection_id)
|
|
183
|
+
if not existing:
|
|
184
|
+
raise HTTPException(status_code=404, detail="Collection not found")
|
|
185
|
+
|
|
186
|
+
offset = (page - 1) * page_size
|
|
187
|
+
memories = await metadata_store.get_collection_memories(
|
|
188
|
+
collection_id, limit=page_size, offset=offset
|
|
189
|
+
)
|
|
190
|
+
total = await metadata_store.get_collection_memory_count(collection_id)
|
|
191
|
+
return MemoryListResponse(
|
|
192
|
+
memories=[_memory_to_response(m) for m in memories],
|
|
193
|
+
total=total,
|
|
194
|
+
page=page,
|
|
195
|
+
page_size=page_size,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@router.post("/collections/{collection_id}/memories", status_code=201)
|
|
200
|
+
@limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
|
|
201
|
+
async def add_memory_to_collection(
|
|
202
|
+
request: Request,
|
|
203
|
+
collection_id: str,
|
|
204
|
+
body: AddMemoryToCollectionRequest,
|
|
205
|
+
metadata_store: MetadataStoreDep,
|
|
206
|
+
) -> dict:
|
|
207
|
+
"""Add a memory to a collection."""
|
|
208
|
+
existing = await metadata_store.get_collection(collection_id)
|
|
209
|
+
if not existing:
|
|
210
|
+
raise HTTPException(status_code=404, detail="Collection not found")
|
|
211
|
+
|
|
212
|
+
memory = await metadata_store.get_memory(body.memory_id)
|
|
213
|
+
if not memory:
|
|
214
|
+
raise HTTPException(status_code=404, detail="Memory not found")
|
|
215
|
+
|
|
216
|
+
await metadata_store.add_memory_to_collection(collection_id, body.memory_id)
|
|
217
|
+
logger.info(
|
|
218
|
+
"collections.memory_added",
|
|
219
|
+
collection_id=collection_id,
|
|
220
|
+
memory_id=body.memory_id,
|
|
221
|
+
)
|
|
222
|
+
return {"status": "added", "collection_id": collection_id, "memory_id": body.memory_id}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@router.delete("/collections/{collection_id}/memories/{memory_id}", status_code=204)
|
|
226
|
+
@limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
|
|
227
|
+
async def remove_memory_from_collection(
|
|
228
|
+
request: Request,
|
|
229
|
+
collection_id: str,
|
|
230
|
+
memory_id: str,
|
|
231
|
+
metadata_store: MetadataStoreDep,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Remove a memory from a collection."""
|
|
234
|
+
existing = await metadata_store.get_collection(collection_id)
|
|
235
|
+
if not existing:
|
|
236
|
+
raise HTTPException(status_code=404, detail="Collection not found")
|
|
237
|
+
await metadata_store.remove_memory_from_collection(collection_id, memory_id)
|
|
238
|
+
logger.info(
|
|
239
|
+
"collections.memory_removed",
|
|
240
|
+
collection_id=collection_id,
|
|
241
|
+
memory_id=memory_id,
|
|
242
|
+
)
|