memgentic-api 0.4.4__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Memgentic REST API — search, manage, and stream memories."""
2
+
3
+ __version__ = "0.1.0"
memgentic_api/auth.py ADDED
@@ -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")
memgentic_api/deps.py ADDED
@@ -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)]
memgentic_api/main.py ADDED
@@ -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
+ )
@@ -0,0 +1,50 @@
1
+ """Knowledge graph endpoints — nodes, edges, and neighbor queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
8
+ from memgentic.config import settings
9
+
10
+ from memgentic_api.deps import limiter
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ def get_graph(request: Request) -> Any:
16
+ """Get the shared KnowledgeGraph from app state (requires intelligence extras)."""
17
+ graph = getattr(request.app.state, "graph", None)
18
+ if graph is None:
19
+ raise HTTPException(
20
+ status_code=501,
21
+ detail="Knowledge graph requires intelligence extras. "
22
+ "Install with: pip install mneme-core[intelligence]",
23
+ )
24
+ return graph
25
+
26
+
27
+ GraphDep = Annotated[Any, Depends(get_graph)]
28
+
29
+
30
+ @router.get("/graph")
31
+ @limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
32
+ async def get_graph_data(
33
+ request: Request,
34
+ graph: GraphDep,
35
+ min_weight: int = Query(default=1, ge=1, description="Minimum edge weight to include"),
36
+ ) -> dict:
37
+ """Export full graph (nodes + edges) for dashboard visualization."""
38
+ return await graph.get_graph_data(min_weight=min_weight)
39
+
40
+
41
+ @router.get("/graph/{entity}")
42
+ @limiter.limit(lambda: f"{settings.rate_limit_default}/minute")
43
+ async def get_graph_neighbors(
44
+ request: Request,
45
+ entity: str,
46
+ graph: GraphDep,
47
+ depth: int = Query(default=2, ge=1, le=5, description="BFS depth"),
48
+ ) -> dict:
49
+ """Get neighbors of an entity up to *depth* hops."""
50
+ return await graph.query_neighbors(entity, depth=depth)