| | """
|
| | MnemoCore REST API
|
| | ==================
|
| | FastAPI server for MnemoCore (Phase 3.5.1+).
|
| | Fully Async I/O with Redis backing.
|
| | """
|
| |
|
| | from contextlib import asynccontextmanager
|
| | from typing import Optional, Dict, Any, List
|
| | from datetime import datetime, timezone
|
| | import sys
|
| | import os
|
| | import asyncio
|
| | import secrets
|
| | from datetime import datetime, timezone
|
| |
|
| | from fastapi import FastAPI, HTTPException, Request, Security, Depends
|
| | from fastapi.responses import JSONResponse
|
| | from fastapi.middleware.cors import CORSMiddleware
|
| | from fastapi.security.api_key import APIKeyHeader
|
| | from starlette.middleware.base import BaseHTTPMiddleware
|
| | from pydantic import BaseModel, Field, field_validator
|
| | from loguru import logger
|
| |
|
| |
|
| | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| |
|
| | from mnemocore.core.engine import HAIMEngine
|
| | from mnemocore.core.config import get_config
|
| | from mnemocore.core.container import build_container, Container
|
| | from mnemocore.api.middleware import (
|
| | SecurityHeadersMiddleware,
|
| | RateLimiter,
|
| | StoreRateLimiter,
|
| | QueryRateLimiter,
|
| | ConceptRateLimiter,
|
| | AnalogyRateLimiter,
|
| | rate_limit_exception_handler,
|
| | RATE_LIMIT_CONFIGS
|
| | )
|
| | from mnemocore.api.models import (
|
| | StoreRequest,
|
| | QueryRequest,
|
| | ConceptRequest,
|
| | AnalogyRequest,
|
| | StoreResponse,
|
| | QueryResponse,
|
| | QueryResult,
|
| | DeleteResponse,
|
| | ConceptResponse,
|
| | AnalogyResponse,
|
| | AnalogyResult,
|
| | HealthResponse,
|
| | RootResponse,
|
| | ErrorResponse
|
| | )
|
| | from mnemocore.core.logging_config import configure_logging
|
| | from mnemocore.core.exceptions import (
|
| | MnemoCoreError,
|
| | RecoverableError,
|
| | IrrecoverableError,
|
| | ValidationError,
|
| | NotFoundError,
|
| | MemoryNotFoundError,
|
| | is_debug_mode,
|
| | )
|
| |
|
| |
|
| | configure_logging()
|
| |
|
| |
|
| | from prometheus_client import make_asgi_app
|
| | from mnemocore.core.metrics import (
|
| | API_REQUEST_COUNT,
|
| | API_REQUEST_LATENCY,
|
| | track_async_latency,
|
| | STORAGE_OPERATION_COUNT,
|
| | extract_trace_context,
|
| | get_trace_id,
|
| | init_opentelemetry,
|
| | update_memory_count,
|
| | update_queue_length,
|
| | OTEL_AVAILABLE
|
| | )
|
| |
|
| |
|
| | if OTEL_AVAILABLE:
|
| | init_opentelemetry(service_name="mnemocore", exporter="console")
|
| | logger.info("OpenTelemetry tracing initialized")
|
| |
|
| | metrics_app = make_asgi_app()
|
| |
|
| |
|
| |
|
| | class TraceContextMiddleware(BaseHTTPMiddleware):
|
| | """
|
| | Middleware to extract and propagate trace context via X-Trace-ID header.
|
| | Integrates with OpenTelemetry for distributed tracing.
|
| | """
|
| |
|
| | async def dispatch(self, request: Request, call_next):
|
| |
|
| | headers = dict(request.headers)
|
| | trace_id = headers.get("x-trace-id")
|
| |
|
| | if trace_id:
|
| |
|
| | from mnemocore.core.metrics import set_trace_id
|
| | set_trace_id(trace_id)
|
| | else:
|
| |
|
| | extracted_id = extract_trace_context(headers)
|
| | if extracted_id:
|
| | trace_id = extracted_id
|
| |
|
| |
|
| | response = await call_next(request)
|
| |
|
| |
|
| | if trace_id:
|
| | response.headers["X-Trace-ID"] = trace_id
|
| |
|
| | return response
|
| |
|
| |
|
| |
|
| |
|
| | @asynccontextmanager
|
| | async def lifespan(app: FastAPI):
|
| |
|
| | config = get_config()
|
| | security = config.security if config else None
|
| | _api_key = (security.api_key if security else None) or os.getenv("HAIM_API_KEY", "")
|
| | if not _api_key:
|
| | logger.critical("No API Key configured! Set HAIM_API_KEY env var or security.api_key in config.")
|
| | sys.exit(1)
|
| |
|
| |
|
| | logger.info("Building dependency container...")
|
| | container = build_container(config)
|
| | app.state.container = container
|
| |
|
| |
|
| | logger.info("Checking Redis connection...")
|
| | if container.redis_storage:
|
| | if not await container.redis_storage.check_health():
|
| | logger.warning("Redis connection failed. Running in degraded mode (local only).")
|
| | else:
|
| | logger.warning("Redis storage not available.")
|
| |
|
| |
|
| | logger.info("Initializing HAIMEngine...")
|
| | from mnemocore.core.tier_manager import TierManager
|
| | tier_manager = TierManager(config=config, qdrant_store=container.qdrant_store)
|
| | engine = HAIMEngine(
|
| | persist_path=config.paths.memory_file,
|
| | config=config,
|
| | tier_manager=tier_manager,
|
| | working_memory=container.working_memory,
|
| | episodic_store=container.episodic_store,
|
| | semantic_store=container.semantic_store,
|
| | )
|
| | await engine.initialize()
|
| | app.state.engine = engine
|
| |
|
| | from mnemocore.agent_interface import CognitiveMemoryClient
|
| | app.state.cognitive_client = CognitiveMemoryClient(
|
| | engine=engine,
|
| | wm=container.working_memory,
|
| | episodic=container.episodic_store,
|
| | semantic=container.semantic_store,
|
| | procedural=container.procedural_store,
|
| | meta=container.meta_memory,
|
| | )
|
| |
|
| | yield
|
| |
|
| |
|
| | logger.info("Closing HAIMEngine...")
|
| | await app.state.engine.close()
|
| |
|
| | logger.info("Closing Redis...")
|
| | if container.redis_storage:
|
| | await container.redis_storage.close()
|
| |
|
| | app = FastAPI(
|
| | title="MnemoCore API",
|
| | description="MnemoCore - Infrastructure for Persistent Cognitive Memory - REST API (Async)",
|
| | version="3.5.2",
|
| | lifespan=lifespan
|
| | )
|
| |
|
| | from mnemocore.core.reliability import (
|
| | CircuitBreakerError,
|
| | storage_circuit_breaker,
|
| | vector_circuit_breaker
|
| | )
|
| |
|
| |
|
| | @app.exception_handler(CircuitBreakerError)
|
| | async def circuit_breaker_exception_handler(request: Request, exc: CircuitBreakerError):
|
| | logger.error(f"Service Unavailable (Circuit Open): {exc}")
|
| | return JSONResponse(
|
| | status_code=503,
|
| | content={"detail": "Service Unavailable: Storage backend is down or overloaded.", "error": str(exc)},
|
| | )
|
| |
|
| |
|
| | @app.exception_handler(MnemoCoreError)
|
| | async def mnemocore_exception_handler(request: Request, exc: MnemoCoreError):
|
| | """
|
| | Centralized exception handler for all MnemoCore errors.
|
| | Returns JSON with error details and stacktrace only in DEBUG mode.
|
| | """
|
| |
|
| | if exc.recoverable:
|
| | logger.warning(f"Recoverable error: {exc}")
|
| | else:
|
| | logger.error(f"Irrecoverable error: {exc}")
|
| |
|
| |
|
| | if isinstance(exc, NotFoundError):
|
| | status_code = 404
|
| | elif isinstance(exc, ValidationError):
|
| | status_code = 400
|
| | elif isinstance(exc, RecoverableError):
|
| | status_code = 503
|
| | else:
|
| | status_code = 500
|
| |
|
| |
|
| | response_data = exc.to_dict(include_traceback=is_debug_mode())
|
| |
|
| | return JSONResponse(
|
| | status_code=status_code,
|
| | content=response_data,
|
| | )
|
| |
|
| |
|
| |
|
| | app.add_middleware(SecurityHeadersMiddleware)
|
| |
|
| |
|
| | app.add_middleware(TraceContextMiddleware)
|
| |
|
| |
|
| | config = get_config()
|
| | cors_origins = config.security.cors_origins if hasattr(config, "security") else ["*"]
|
| |
|
| | app.add_middleware(
|
| | CORSMiddleware,
|
| | allow_origins=cors_origins,
|
| | allow_credentials=True,
|
| | allow_methods=["*"],
|
| | allow_headers=["*"],
|
| | )
|
| |
|
| |
|
| | app.mount("/metrics", metrics_app)
|
| |
|
| |
|
| | API_KEY_NAME = "X-API-Key"
|
| | api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
| |
|
| | async def get_api_key(api_key: str = Security(api_key_header)):
|
| | config = get_config()
|
| |
|
| | security = config.security if config else None
|
| | expected_key = (security.api_key if security else None) or os.getenv("HAIM_API_KEY", "")
|
| |
|
| | if not expected_key:
|
| |
|
| | logger.error("API Key not configured during request processing.")
|
| | raise HTTPException(
|
| | status_code=500,
|
| | detail="Server Misconfiguration: API Key not set"
|
| | )
|
| |
|
| | if not api_key or not secrets.compare_digest(api_key, expected_key):
|
| | raise HTTPException(
|
| | status_code=403,
|
| | detail="Invalid or missing API Key"
|
| | )
|
| | return api_key
|
| |
|
| |
|
| | def get_engine(request: Request) -> HAIMEngine:
|
| | return request.app.state.engine
|
| |
|
| |
|
| | def get_container(request: Request) -> Container:
|
| | return request.app.state.container
|
| |
|
| |
|
| |
|
| |
|
| | @app.get("/", response_model=RootResponse)
|
| | async def root():
|
| | return {
|
| | "status": "ok",
|
| | "service": "MnemoCore",
|
| | "version": "3.5.1",
|
| | "phase": "Async I/O",
|
| | "timestamp": datetime.now(timezone.utc).isoformat()
|
| | }
|
| |
|
| |
|
| | @app.get("/health", response_model=HealthResponse)
|
| | async def health(container: Container = Depends(get_container), engine: HAIMEngine = Depends(get_engine)):
|
| |
|
| | redis_connected = False
|
| | if container.redis_storage:
|
| | redis_connected = await container.redis_storage.check_health()
|
| |
|
| |
|
| | storage_cb_state = storage_circuit_breaker.state
|
| | vector_cb_state = vector_circuit_breaker.state
|
| |
|
| | is_healthy = redis_connected and storage_cb_state == "closed" and vector_cb_state == "closed"
|
| |
|
| | return {
|
| | "status": "healthy" if is_healthy else "degraded",
|
| | "redis_connected": redis_connected,
|
| | "storage_circuit_breaker": storage_cb_state,
|
| | "qdrant_circuit_breaker": vector_cb_state,
|
| | "engine_ready": engine is not None,
|
| | "timestamp": datetime.now(timezone.utc).isoformat()
|
| | }
|
| |
|
| |
|
| | @app.post(
|
| | "/store",
|
| | response_model=StoreResponse,
|
| | dependencies=[Depends(get_api_key), Depends(StoreRateLimiter())]
|
| | )
|
| | @track_async_latency(API_REQUEST_LATENCY, {"method": "POST", "endpoint": "/store"})
|
| | async def store_memory(
|
| | req: StoreRequest,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | container: Container = Depends(get_container)
|
| | ):
|
| | """Store a new memory (Async + Dual Write). Rate limit: 100/minute."""
|
| | API_REQUEST_COUNT.labels(method="POST", endpoint="/store", status="200").inc()
|
| |
|
| | metadata = req.metadata or {}
|
| | if req.agent_id:
|
| | metadata["agent_id"] = req.agent_id
|
| |
|
| |
|
| | mem_id = await engine.store(req.content, metadata=metadata)
|
| |
|
| |
|
| |
|
| | node = await engine.get_memory(mem_id)
|
| | if node and container.redis_storage:
|
| | redis_data = {
|
| | "id": node.id,
|
| | "content": node.content,
|
| | "metadata": node.metadata,
|
| | "ltp_strength": node.ltp_strength,
|
| | "created_at": node.created_at.isoformat()
|
| | }
|
| | try:
|
| | await container.redis_storage.store_memory(mem_id, redis_data, ttl=req.ttl)
|
| |
|
| |
|
| | await container.redis_storage.publish_event("memory.created", {"id": mem_id})
|
| | except Exception as e:
|
| | logger.exception(f"Failed async write for {mem_id}")
|
| |
|
| |
|
| | return {
|
| | "ok": True,
|
| | "memory_id": mem_id,
|
| | "message": f"Stored memory: {mem_id}"
|
| | }
|
| |
|
| |
|
| | @app.post(
|
| | "/query",
|
| | response_model=QueryResponse,
|
| | dependencies=[Depends(get_api_key), Depends(QueryRateLimiter())]
|
| | )
|
| | @track_async_latency(API_REQUEST_LATENCY, {"method": "POST", "endpoint": "/query"})
|
| | async def query_memory(
|
| | req: QueryRequest,
|
| | engine: HAIMEngine = Depends(get_engine)
|
| | ):
|
| | """Query memories by semantic similarity (Async Wrapper). Rate limit: 500/minute."""
|
| | API_REQUEST_COUNT.labels(method="POST", endpoint="/query", status="200").inc()
|
| |
|
| |
|
| | metadata_filter = {"agent_id": req.agent_id} if req.agent_id else None
|
| | results = await engine.query(req.query, top_k=req.top_k, metadata_filter=metadata_filter)
|
| |
|
| | formatted = []
|
| | for mem_id, score in results:
|
| |
|
| |
|
| | node = await engine.get_memory(mem_id)
|
| | if node:
|
| | formatted.append({
|
| | "id": mem_id,
|
| | "content": node.content,
|
| | "score": float(score),
|
| | "metadata": node.metadata,
|
| | "tier": getattr(node, "tier", "unknown")
|
| | })
|
| |
|
| | return {
|
| | "ok": True,
|
| | "query": req.query,
|
| | "results": formatted
|
| | }
|
| |
|
| |
|
| | @app.get("/memory/{memory_id}", dependencies=[Depends(get_api_key)])
|
| | async def get_memory(
|
| | memory_id: str,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | container: Container = Depends(get_container)
|
| | ):
|
| | """Get a specific memory by ID."""
|
| |
|
| | if not memory_id or len(memory_id) > 256:
|
| | raise ValidationError(
|
| | field="memory_id",
|
| | reason="Memory ID must be between 1 and 256 characters",
|
| | value=memory_id
|
| | )
|
| |
|
| |
|
| | cached = None
|
| | if container.redis_storage:
|
| | cached = await container.redis_storage.retrieve_memory(memory_id)
|
| |
|
| | if cached:
|
| | return {
|
| | "source": "redis",
|
| | **cached
|
| | }
|
| |
|
| |
|
| | node = await engine.get_memory(memory_id)
|
| | if not node:
|
| | raise MemoryNotFoundError(memory_id)
|
| |
|
| | return {
|
| | "source": "engine",
|
| | "id": node.id,
|
| | "content": node.content,
|
| | "metadata": node.metadata,
|
| | "created_at": node.created_at.isoformat(),
|
| | "epistemic_value": getattr(node, "epistemic_value", 0.0),
|
| | "ltp_strength": getattr(node, "ltp_strength", 0.0),
|
| | "tier": getattr(node, "tier", "unknown")
|
| | }
|
| |
|
| |
|
| | @app.delete(
|
| | "/memory/{memory_id}",
|
| | response_model=DeleteResponse,
|
| | dependencies=[Depends(get_api_key)]
|
| | )
|
| | async def delete_memory(
|
| | memory_id: str,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | container: Container = Depends(get_container)
|
| | ):
|
| | """Delete a memory via Engine."""
|
| |
|
| | if not memory_id or len(memory_id) > 256:
|
| | raise ValidationError(
|
| | field="memory_id",
|
| | reason="Memory ID must be between 1 and 256 characters",
|
| | value=memory_id
|
| | )
|
| |
|
| |
|
| | node = await engine.get_memory(memory_id)
|
| | if not node:
|
| | raise MemoryNotFoundError(memory_id)
|
| |
|
| |
|
| | await engine.delete_memory(memory_id)
|
| |
|
| |
|
| | if container.redis_storage:
|
| | await container.redis_storage.delete_memory(memory_id)
|
| |
|
| | return {"ok": True, "deleted": memory_id}
|
| |
|
| |
|
| |
|
| | class ObserveRequest(BaseModel):
|
| | agent_id: str
|
| | content: str
|
| | kind: str = "observation"
|
| | importance: float = 0.5
|
| | tags: Optional[List[str]] = None
|
| |
|
| | @app.post("/wm/observe", dependencies=[Depends(get_api_key)])
|
| | async def observe_context(req: ObserveRequest, request: Request):
|
| | """Push an observation explicitly into Working Memory."""
|
| | client = request.app.state.cognitive_client
|
| | if not client:
|
| | raise HTTPException(status_code=503, detail="Cognitive Client unavailable")
|
| | item_id = client.observe(
|
| | agent_id=req.agent_id,
|
| | content=req.content,
|
| | kind=req.kind,
|
| | importance=req.importance,
|
| | tags=req.tags
|
| | )
|
| | return {"ok": True, "item_id": item_id}
|
| |
|
| | @app.get("/wm/context/{agent_id}", dependencies=[Depends(get_api_key)])
|
| | async def get_working_context(agent_id: str, limit: int = 16, request: Request = None):
|
| | """Read active Working Memory context."""
|
| | client = request.app.state.cognitive_client
|
| | items = client.get_working_context(agent_id, limit=limit)
|
| | return {"ok": True, "items": [
|
| | {"id": i.id, "content": i.content, "kind": i.kind, "importance": i.importance}
|
| | for i in items
|
| | ]}
|
| |
|
| | class EpisodeStartRequest(BaseModel):
|
| | agent_id: str
|
| | goal: str
|
| | context: Optional[str] = None
|
| |
|
| | @app.post("/episodes/start", dependencies=[Depends(get_api_key)])
|
| | async def start_episode(req: EpisodeStartRequest, request: Request):
|
| | """Start a new episode chain."""
|
| | client = request.app.state.cognitive_client
|
| | ep_id = client.start_episode(req.agent_id, goal=req.goal, context=req.context)
|
| | return {"ok": True, "episode_id": ep_id}
|
| |
|
| |
|
| |
|
| | @app.post(
|
| | "/concept",
|
| | response_model=ConceptResponse,
|
| | dependencies=[Depends(get_api_key), Depends(ConceptRateLimiter())]
|
| | )
|
| | async def define_concept(req: ConceptRequest, engine: HAIMEngine = Depends(get_engine)):
|
| | """Define a concept with attributes. Rate limit: 100/minute."""
|
| | await engine.define_concept(req.name, req.attributes)
|
| | return {"ok": True, "concept": req.name}
|
| |
|
| |
|
| | @app.post(
|
| | "/analogy",
|
| | response_model=AnalogyResponse,
|
| | dependencies=[Depends(get_api_key), Depends(AnalogyRateLimiter())]
|
| | )
|
| | async def solve_analogy(req: AnalogyRequest, engine: HAIMEngine = Depends(get_engine)):
|
| | """Solve an analogy. Rate limit: 100/minute."""
|
| | results = await engine.reason_by_analogy(
|
| | req.source_concept,
|
| | req.source_value,
|
| | req.target_concept
|
| | )
|
| | return {
|
| | "ok": True,
|
| | "analogy": f"{req.source_concept}:{req.source_value} :: {req.target_concept}:?",
|
| | "results": [{"value": v, "score": float(s)} for v, s in results[:10]]
|
| | }
|
| |
|
| |
|
| | @app.get("/stats", dependencies=[Depends(get_api_key)])
|
| | async def get_stats(engine: HAIMEngine = Depends(get_engine)):
|
| | """Get aggregate engine stats."""
|
| | return await engine.get_stats()
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | @app.post("/maintenance/cleanup", dependencies=[Depends(get_api_key)])
|
| | async def cleanup_maintenance(threshold: float = 0.1, engine: HAIMEngine = Depends(get_engine)):
|
| | """Remove decayed synapses and stale index nodes."""
|
| | await engine.cleanup_decay(threshold=threshold)
|
| | return {"ok": True, "message": f"Synapse cleanup triggered with threshold {threshold}"}
|
| |
|
| |
|
| | @app.post("/maintenance/consolidate", dependencies=[Depends(get_api_key)])
|
| | async def consolidate_maintenance(engine: HAIMEngine = Depends(get_engine)):
|
| | """Trigger manual semantic consolidation pulse."""
|
| | if not engine._semantic_worker:
|
| | raise HTTPException(status_code=503, detail="Consolidation worker not initialized")
|
| |
|
| | stats = await engine._semantic_worker.run_once()
|
| | return {"ok": True, "stats": stats}
|
| |
|
| |
|
| | @app.post("/maintenance/sweep", dependencies=[Depends(get_api_key)])
|
| | async def sweep_maintenance(engine: HAIMEngine = Depends(get_engine)):
|
| | """Trigger manual immunology sweep."""
|
| | if not engine._immunology:
|
| | raise HTTPException(status_code=503, detail="Immunology loop not initialized")
|
| |
|
| | stats = await engine._immunology.sweep()
|
| | return {"ok": True, "stats": stats}
|
| |
|
| |
|
| |
|
| | @app.get("/rate-limits")
|
| | async def get_rate_limits():
|
| | """Get current rate limit configuration."""
|
| | return {
|
| | "limits": {
|
| | category: {
|
| | "requests": cfg["requests"],
|
| | "window_seconds": cfg["window"],
|
| | "requests_per_minute": cfg["requests"],
|
| | "description": cfg["description"]
|
| | }
|
| | for category, cfg in RATE_LIMIT_CONFIGS.items()
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | class RLMQueryRequest(BaseModel):
|
| | """Request model for Phase 4.5 recursive memory query."""
|
| | query: str = Field(..., min_length=1, max_length=4096, description="The query to synthesize (can be complex/multi-topic)")
|
| | context_text: Optional[str] = Field(None, max_length=500000, description="Optional large external text (Ripple environment)")
|
| | project_id: Optional[str] = Field(None, max_length=128, description="Optional project scope for isolation masking")
|
| | max_depth: Optional[int] = Field(None, ge=0, le=5, description="Max recursion depth (0-5, default 3)")
|
| | max_sub_queries: Optional[int] = Field(None, ge=1, le=10, description="Max sub-queries to decompose into (1-10, default 5)")
|
| | top_k: Optional[int] = Field(None, ge=1, le=50, description="Final results to return (default 10)")
|
| |
|
| |
|
| | class RLMQueryResponse(BaseModel):
|
| | """Response model for Phase 4.5 recursive memory query."""
|
| | ok: bool
|
| | query: str
|
| | sub_queries: List[str]
|
| | results: List[Dict[str, Any]]
|
| | synthesis: str
|
| | max_depth_hit: int
|
| | elapsed_ms: float
|
| | ripple_snippets: List[str]
|
| | stats: Dict[str, Any]
|
| |
|
| |
|
| | @app.post(
|
| | "/rlm/query",
|
| | response_model=RLMQueryResponse,
|
| | dependencies=[Depends(get_api_key), Depends(QueryRateLimiter())],
|
| | tags=["Phase 4.5"],
|
| | summary="Recursive Synthesis Query",
|
| | description=(
|
| | "Phase 4.5: Recursive Language Model (RLM) query. "
|
| | "Decomposes complex queries into sub-questions, searches MnemoCore in parallel, "
|
| | "recursively analyzes low-confidence clusters, and synthesizes a final answer. "
|
| | "Implements the MIT CSAIL RLM paradigm to eliminate Context Rot."
|
| | ),
|
| | )
|
| | @track_async_latency(API_REQUEST_LATENCY, {"method": "POST", "endpoint": "/rlm/query"})
|
| | async def rlm_query(
|
| | req: RLMQueryRequest,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | ):
|
| | """
|
| | Phase 4.5 Recursive Synthesis Engine.
|
| |
|
| | Instead of a single flat search, this endpoint:
|
| | 1. Decomposes your query into focused sub-questions
|
| | 2. Searches MnemoCore in PARALLEL for each sub-question
|
| | 3. Recursively drills into low-confidence clusters
|
| | 4. Synthesizes all results into a coherent answer
|
| |
|
| | Rate limit: 500/minute (shared with /query).
|
| | """
|
| | API_REQUEST_COUNT.labels(method="POST", endpoint="/rlm/query", status="200").inc()
|
| |
|
| | from mnemocore.core.recursive_synthesizer import RecursiveSynthesizer, SynthesizerConfig
|
| | from mnemocore.core.ripple_context import RippleContext
|
| |
|
| |
|
| | synth_config = SynthesizerConfig(
|
| | max_depth=req.max_depth if req.max_depth is not None else 3,
|
| | max_sub_queries=req.max_sub_queries if req.max_sub_queries is not None else 5,
|
| | final_top_k=req.top_k if req.top_k is not None else 10,
|
| | )
|
| |
|
| |
|
| | ripple_ctx = None
|
| | if req.context_text and req.context_text.strip():
|
| | ripple_ctx = RippleContext(text=req.context_text, source_label="api_context")
|
| |
|
| |
|
| |
|
| | synthesizer = RecursiveSynthesizer(engine=engine, config=synth_config)
|
| | result = await synthesizer.synthesize(
|
| | query=req.query,
|
| | ripple_context=ripple_ctx,
|
| | project_id=req.project_id,
|
| | )
|
| |
|
| | return {
|
| | "ok": True,
|
| | "query": result.query,
|
| | "sub_queries": result.sub_queries,
|
| | "results": result.results,
|
| | "synthesis": result.synthesis,
|
| | "max_depth_hit": result.max_depth_hit,
|
| | "elapsed_ms": result.total_elapsed_ms,
|
| | "ripple_snippets": result.ripple_snippets,
|
| | "stats": result.stats,
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | @app.get(
|
| | "/memory/{memory_id}/lineage",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Trust"],
|
| | summary="Get full provenance lineage for a memory",
|
| | )
|
| | async def get_memory_lineage(
|
| | memory_id: str,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | ):
|
| | """
|
| | Return the complete provenance lineage of a memory:
|
| | origin (who created it, how, when) and all transformation events
|
| | (consolidated, verified, contradicted, archived, β¦).
|
| | """
|
| | node = await engine.get_memory(memory_id)
|
| | if not node:
|
| | raise MemoryNotFoundError(memory_id)
|
| |
|
| | prov = getattr(node, "provenance", None)
|
| | if prov is None:
|
| | return {
|
| | "ok": True,
|
| | "memory_id": memory_id,
|
| | "provenance": None,
|
| | "message": "No provenance record attached to this memory.",
|
| | }
|
| |
|
| | return {
|
| | "ok": True,
|
| | "memory_id": memory_id,
|
| | "provenance": prov.to_dict(),
|
| | }
|
| |
|
| |
|
| | @app.get(
|
| | "/memory/{memory_id}/confidence",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Trust"],
|
| | summary="Get confidence envelope for a memory",
|
| | )
|
| | async def get_memory_confidence(
|
| | memory_id: str,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | ):
|
| | """
|
| | Return a structured confidence envelope for a memory, combining:
|
| | - Bayesian reliability (BayesianLTP posterior mean)
|
| | - access_count (evidence strength)
|
| | - staleness (days since last verification)
|
| | - source_type trust weight
|
| | - contradiction flag
|
| |
|
| | Level: high | medium | low | contradicted | stale
|
| | """
|
| | from mnemocore.core.confidence import build_confidence_envelope
|
| |
|
| | node = await engine.get_memory(memory_id)
|
| | if not node:
|
| | raise MemoryNotFoundError(memory_id)
|
| |
|
| | prov = getattr(node, "provenance", None)
|
| | envelope = build_confidence_envelope(node, prov)
|
| |
|
| | return {
|
| | "ok": True,
|
| | "memory_id": memory_id,
|
| | "confidence": envelope,
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | @app.get(
|
| | "/proactive",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Autonomy"],
|
| | summary="Retrieve contextually relevant memories without explicit query",
|
| | )
|
| | async def get_proactive_memories(
|
| | agent_id: Optional[str] = None,
|
| | limit: int = 10,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | ):
|
| | """
|
| | Proactive recall stub (Phase 5.0 / Agent 3).
|
| | Returns the most recently active high-LTP memories as a stand-in
|
| | until the full ProactiveRecallDaemon is implemented.
|
| | """
|
| | nodes = await engine.tier_manager.get_hot_snapshot() if hasattr(engine, "tier_manager") else []
|
| | sorted_nodes = sorted(nodes, key=lambda n: n.ltp_strength, reverse=True)[:limit]
|
| |
|
| | from mnemocore.core.confidence import build_confidence_envelope
|
| | results = []
|
| | for n in sorted_nodes:
|
| | prov = getattr(n, "provenance", None)
|
| | results.append({
|
| | "id": n.id,
|
| | "content": n.content,
|
| | "ltp_strength": round(n.ltp_strength, 4),
|
| | "confidence": build_confidence_envelope(n, prov),
|
| | "tier": getattr(n, "tier", "hot"),
|
| | })
|
| |
|
| | return {"ok": True, "proactive_results": results, "count": len(results)}
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | @app.get(
|
| | "/contradictions",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Lifecycle"],
|
| | summary="List active contradiction groups requiring resolution",
|
| | )
|
| | async def list_contradictions(
|
| | unresolved_only: bool = True,
|
| | ):
|
| | """
|
| | Returns all detected contradiction groups from the ContradictionRegistry.
|
| | By default only unresolved contradictions are returned.
|
| | """
|
| | from mnemocore.core.contradiction import get_contradiction_detector
|
| | detector = get_contradiction_detector()
|
| | records = detector.registry.list_all(unresolved_only=unresolved_only)
|
| | return {
|
| | "ok": True,
|
| | "count": len(records),
|
| | "contradictions": [r.to_dict() for r in records],
|
| | }
|
| |
|
| |
|
| | class ResolveContradictionRequest(BaseModel):
|
| | note: Optional[str] = None
|
| |
|
| |
|
| | @app.post(
|
| | "/contradictions/{group_id}/resolve",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Lifecycle"],
|
| | summary="Mark a contradiction group as resolved",
|
| | )
|
| | async def resolve_contradiction(group_id: str, req: ResolveContradictionRequest):
|
| | """Manually resolve a detected contradiction."""
|
| | from mnemocore.core.contradiction import get_contradiction_detector
|
| | detector = get_contradiction_detector()
|
| | success = detector.registry.resolve(group_id, note=req.note)
|
| | if not success:
|
| | raise HTTPException(status_code=404, detail=f"Contradiction group {group_id!r} not found.")
|
| | return {"ok": True, "resolved_group_id": group_id}
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | @app.get(
|
| | "/memory/{memory_id}/emotional-tag",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Autonomy"],
|
| | summary="Get emotional (valence/arousal) tag for a memory",
|
| | )
|
| | async def get_emotional_tag_ep(
|
| | memory_id: str,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | ):
|
| | """Return the valence/arousal emotional metadata for a memory."""
|
| | from mnemocore.core.emotional_tag import get_emotional_tag
|
| | node = await engine.get_memory(memory_id)
|
| | if not node:
|
| | raise MemoryNotFoundError(memory_id)
|
| | tag = get_emotional_tag(node)
|
| | return {
|
| | "ok": True,
|
| | "memory_id": memory_id,
|
| | "emotional_tag": {
|
| | "valence": tag.valence,
|
| | "arousal": tag.arousal,
|
| | "salience": round(tag.salience(), 4),
|
| | },
|
| | }
|
| |
|
| |
|
| | class EmotionalTagPatchRequest(BaseModel):
|
| | valence: float
|
| | arousal: float
|
| |
|
| |
|
| | @app.patch(
|
| | "/memory/{memory_id}/emotional-tag",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Autonomy"],
|
| | summary="Attach or update emotional tag on a memory",
|
| | )
|
| | async def patch_emotional_tag(
|
| | memory_id: str,
|
| | req: EmotionalTagPatchRequest,
|
| | engine: HAIMEngine = Depends(get_engine),
|
| | ):
|
| | from mnemocore.core.emotional_tag import EmotionalTag, attach_emotional_tag
|
| | node = await engine.get_memory(memory_id)
|
| | if not node:
|
| | raise MemoryNotFoundError(memory_id)
|
| | tag = EmotionalTag(valence=req.valence, arousal=req.arousal)
|
| | attach_emotional_tag(node, tag)
|
| | return {"ok": True, "memory_id": memory_id, "emotional_tag": tag.to_metadata_dict()}
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | _prediction_store_instance = None
|
| |
|
| |
|
| | def _get_prediction_store(engine: HAIMEngine = Depends(get_engine)):
|
| | from mnemocore.core.prediction_store import PredictionStore
|
| | global _prediction_store_instance
|
| | if _prediction_store_instance is None:
|
| | _prediction_store_instance = PredictionStore(engine=engine)
|
| | return _prediction_store_instance
|
| |
|
| |
|
| | class CreatePredictionRequest(BaseModel):
|
| | content: str
|
| | confidence: float = 0.5
|
| | deadline_days: Optional[float] = None
|
| | related_memory_ids: Optional[List[str]] = None
|
| | tags: Optional[List[str]] = None
|
| |
|
| |
|
| | class VerifyPredictionRequest(BaseModel):
|
| | success: bool
|
| | notes: Optional[str] = None
|
| |
|
| |
|
| | @app.post(
|
| | "/predictions",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Prediction"],
|
| | summary="Store a new forward-looking prediction",
|
| | )
|
| | async def create_prediction(req: CreatePredictionRequest):
|
| | from mnemocore.core.prediction_store import PredictionStore
|
| | global _prediction_store_instance
|
| | if _prediction_store_instance is None:
|
| | _prediction_store_instance = PredictionStore()
|
| | pred_id = _prediction_store_instance.create(
|
| | content=req.content,
|
| | confidence=req.confidence,
|
| | deadline_days=req.deadline_days,
|
| | related_memory_ids=req.related_memory_ids,
|
| | tags=req.tags,
|
| | )
|
| | pred = _prediction_store_instance.get(pred_id)
|
| | return {"ok": True, "prediction": pred.to_dict()}
|
| |
|
| |
|
| | @app.get(
|
| | "/predictions",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Prediction"],
|
| | summary="List all predictions",
|
| | )
|
| | async def list_predictions(status: Optional[str] = None):
|
| | from mnemocore.core.prediction_store import PredictionStore
|
| | global _prediction_store_instance
|
| | if _prediction_store_instance is None:
|
| | _prediction_store_instance = PredictionStore()
|
| | return {
|
| | "ok": True,
|
| | "predictions": [
|
| | p.to_dict()
|
| | for p in _prediction_store_instance.list_all(status=status)
|
| | ],
|
| | }
|
| |
|
| |
|
| | @app.post(
|
| | "/predictions/{pred_id}/verify",
|
| | dependencies=[Depends(get_api_key)],
|
| | tags=["Phase 5.0 β Prediction"],
|
| | summary="Verify or falsify a prediction",
|
| | )
|
| | async def verify_prediction(pred_id: str, req: VerifyPredictionRequest):
|
| | from mnemocore.core.prediction_store import PredictionStore
|
| | global _prediction_store_instance
|
| | if _prediction_store_instance is None:
|
| | _prediction_store_instance = PredictionStore()
|
| | pred = await _prediction_store_instance.verify(pred_id, success=req.success, notes=req.notes)
|
| | if pred is None:
|
| | raise HTTPException(status_code=404, detail=f"Prediction {pred_id!r} not found.")
|
| | return {"ok": True, "prediction": pred.to_dict()}
|
| |
|
| |
|
| | if __name__ == "__main__":
|
| | import uvicorn
|
| | uvicorn.run(app, host="0.0.0.0", port=8100)
|
| |
|