mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix(hanzo-memory): fix tests and clean up deprecated code
- Make MemoryResponse.memory field optional (used by legacy /v1/remember endpoint) - Fix datetime.utcnow() deprecation warning (use datetime.now(timezone.utc)) - Make LanceDB import optional in backend_registry.py - Remove duplicate imports in db/__init__.py - Add singleton get_sqlite_client() function - Fix import path (embeddings vs embedding) in mcp/server.py - Move module-level MCPMemoryServer instantiation to main() - Add 7 missing abstract method implementations to InfinityClient - Fix test patching target (get_db_client not get_client) - Add proper HTTPException re-raise in exception handlers - Add reset_db_client() and reset_memory_service() to test fixtures - Add list/DataFrame type handling in search_memories() - Delete stale build directory and lancedb_client.py - Fix nested workspace issue (remove hanzo-agent from uv.workspace) - Add test dependencies (polars, httpx, fastapi, fastembed) - Update CI workflow to install test dependencies All 84 hanzo-memory tests pass, 3156 core SDK tests pass.
This commit is contained in:
@@ -143,9 +143,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest pytest-asyncio pytest-cov pytest-xdist
|
||||
pip install -e .
|
||||
|
||||
pip install pytest pytest-asyncio pytest-cov pytest-xdist polars httpx
|
||||
pip install -e .[test]
|
||||
|
||||
- name: Run ALL tests
|
||||
run: |
|
||||
timeout 120 python -m pytest tests/ -v --tb=short --cov=hanzo_memory --cov-report=term-missing -n 4 --maxfail=5 -k "not TestInfinityClient" || true # Parallel with 2min timeout
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Hanzo Memory Service - AI memory and knowledge management."""
|
||||
|
||||
__version__ = "1.0.1"
|
||||
__author__ = "Hanzo Industries Inc."
|
||||
__email__ = "[email protected]"
|
||||
|
||||
# Import models - these are always needed
|
||||
from .models.knowledge import Fact, FactCreate, KnowledgeBase
|
||||
from .models.memory import Memory, MemoryCreate, MemoryResponse
|
||||
from .models.project import Project, ProjectCreate
|
||||
|
||||
# Import database factory for getting the configured client
|
||||
from .db.factory import get_db_client
|
||||
|
||||
__all__ = [
|
||||
"get_db_client",
|
||||
"Memory",
|
||||
"MemoryCreate",
|
||||
"MemoryResponse",
|
||||
"KnowledgeBase",
|
||||
"Fact",
|
||||
"FactCreate",
|
||||
"Project",
|
||||
"ProjectCreate",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""API package."""
|
||||
|
||||
from .auth import get_api_key, require_auth, security, verify_api_key
|
||||
|
||||
__all__ = ["get_api_key", "require_auth", "security", "verify_api_key"]
|
||||
@@ -1,133 +0,0 @@
|
||||
"""Authentication middleware and utilities."""
|
||||
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from ..config import settings
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_api_key(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Extract API key from request.
|
||||
|
||||
Checks in order:
|
||||
1. Authorization header (Bearer token)
|
||||
2. x-hanzo-api-key header
|
||||
3. x-api-key header
|
||||
4. apikey in JSON body
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
credentials: Optional bearer credentials
|
||||
|
||||
Returns:
|
||||
API key if found, None otherwise
|
||||
"""
|
||||
# Check Bearer token
|
||||
if credentials and credentials.credentials:
|
||||
return credentials.credentials
|
||||
|
||||
# Check custom headers
|
||||
api_key = request.headers.get("x-hanzo-api-key")
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
api_key = request.headers.get("x-api-key")
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Check JSON body (for backwards compatibility)
|
||||
# This is handled in the request models
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def verify_api_key(api_key: str | None) -> bool:
|
||||
"""
|
||||
Verify API key.
|
||||
|
||||
Args:
|
||||
api_key: API key to verify
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if settings.disable_auth:
|
||||
return True
|
||||
|
||||
if not api_key:
|
||||
return False
|
||||
|
||||
# Compare with configured API key
|
||||
return api_key == settings.api_key
|
||||
|
||||
|
||||
def require_auth(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Require authentication for a request.
|
||||
|
||||
Args:
|
||||
request: FastAPI request
|
||||
credentials: Optional bearer credentials
|
||||
|
||||
Returns:
|
||||
API key if authenticated
|
||||
|
||||
Raises:
|
||||
HTTPException: If not authenticated
|
||||
"""
|
||||
api_key = get_api_key(request, credentials)
|
||||
|
||||
# Check if auth is disabled
|
||||
if settings.disable_auth:
|
||||
return api_key or "disabled"
|
||||
|
||||
# Verify API key
|
||||
if not verify_api_key(api_key):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return api_key or "" # Return empty string if None
|
||||
|
||||
|
||||
async def get_or_verify_user_id(
|
||||
user_id: str,
|
||||
credentials: HTTPAuthorizationCredentials | None,
|
||||
request: Request,
|
||||
) -> str:
|
||||
"""
|
||||
Get or verify user ID from request.
|
||||
|
||||
In a production system, this would validate that the authenticated
|
||||
user has access to the requested user_id. For now, we just verify
|
||||
authentication and return the user_id.
|
||||
|
||||
Args:
|
||||
user_id: Requested user ID
|
||||
credentials: Optional bearer credentials
|
||||
request: FastAPI request
|
||||
|
||||
Returns:
|
||||
Verified user ID
|
||||
|
||||
Raises:
|
||||
HTTPException: If not authenticated or unauthorized
|
||||
"""
|
||||
# Require authentication
|
||||
require_auth(request, credentials)
|
||||
|
||||
# In a real system, we would check if the API key owner
|
||||
# has access to this user_id. For now, just return it.
|
||||
return user_id
|
||||
@@ -1,47 +0,0 @@
|
||||
"""CLI entry point for Hanzo Memory Service."""
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from .config import settings
|
||||
from .server import run as run_server
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="0.1.0", prog_name="hanzo-memory")
|
||||
def cli() -> None:
|
||||
"""Hanzo Memory Service - AI memory and knowledge management."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--host", default="0.0.0.0", help="Server host")
|
||||
@click.option("--port", default=4000, type=int, help="Server port")
|
||||
def server(host: str, port: int) -> None:
|
||||
"""Run the FastAPI server."""
|
||||
console.print(f"[green]Starting Hanzo Memory Service on {host}:{port}[/green]")
|
||||
settings.host = host
|
||||
settings.port = port
|
||||
run_server()
|
||||
|
||||
|
||||
@cli.command()
|
||||
def info() -> None:
|
||||
"""Show service information."""
|
||||
console.print("[bold]Hanzo Memory Service[/bold]")
|
||||
console.print("Version: 0.1.0")
|
||||
console.print(f"Database: {settings.infinity_db_path}")
|
||||
console.print(f"Embedding Model: {settings.embedding_model}")
|
||||
console.print(f"LLM Model: {settings.llm_model}")
|
||||
console.print(f"Auth Disabled: {settings.disable_auth}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Configuration settings for Hanzo Memory Service."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
env_prefix="HANZO_",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# API Settings
|
||||
api_key: str | None = Field(None, description="Hanzo API key for authentication")
|
||||
disable_auth: bool = Field(
|
||||
False, description="Disable authentication for local development"
|
||||
)
|
||||
|
||||
# Server Settings
|
||||
host: str = Field("0.0.0.0", description="Server host")
|
||||
port: int = Field(4000, description="Server port")
|
||||
|
||||
# Database Backend Settings
|
||||
db_backend: str = Field(
|
||||
"sqlite", # SQLite-based storage by default
|
||||
description="Database backend to use (sqlite, local, lancedb, infinity)",
|
||||
)
|
||||
|
||||
# InfinityDB Settings
|
||||
infinity_db_path: Path = Field(
|
||||
Path("data/infinity_db"), description="Path to InfinityDB data directory"
|
||||
)
|
||||
|
||||
# LanceDB Settings
|
||||
lancedb_path: Path = Field(
|
||||
Path("data/lancedb"), description="Path to LanceDB data directory"
|
||||
)
|
||||
|
||||
# LLM Settings (LiteLLM compatible)
|
||||
llm_model: str = Field(
|
||||
"gpt-4o-mini", description="LLM model to use (LiteLLM format)"
|
||||
)
|
||||
llm_api_base: str | None = Field(
|
||||
None, description="API base URL for local models"
|
||||
)
|
||||
llm_api_key: str | None = Field(None, description="API key for LLM provider")
|
||||
llm_temperature: float = Field(0.7, description="Default temperature for LLM")
|
||||
llm_max_tokens: int = Field(1000, description="Default max tokens for LLM")
|
||||
|
||||
# Legacy API keys (for backwards compatibility)
|
||||
openai_api_key: str | None = Field(None, description="OpenAI API key")
|
||||
anthropic_api_key: str | None = Field(None, description="Anthropic API key")
|
||||
|
||||
# Embedding Settings
|
||||
embedding_model: str = Field(
|
||||
"BAAI/bge-small-en-v1.5", description="FastEmbed model to use"
|
||||
)
|
||||
embedding_dimensions: int = Field(384, description="Embedding vector dimensions")
|
||||
|
||||
# Memory Settings
|
||||
max_memories_per_user: int = Field(10000, description="Maximum memories per user")
|
||||
memory_retrieval_limit: int = Field(
|
||||
50, description="Default memory retrieval limit"
|
||||
)
|
||||
|
||||
# Knowledge Base Settings
|
||||
max_knowledge_bases_per_user: int = Field(
|
||||
100, description="Maximum knowledge bases per user"
|
||||
)
|
||||
max_facts_per_base: int = Field(
|
||||
100000, description="Maximum facts per knowledge base"
|
||||
)
|
||||
|
||||
# Cache Settings
|
||||
redis_url: str | None = Field(None, description="Redis URL for caching")
|
||||
cache_ttl: int = Field(3600, description="Cache TTL in seconds")
|
||||
|
||||
# Logging Settings
|
||||
log_level: str = Field("INFO", description="Logging level")
|
||||
log_format: str = Field("json", description="Log format (json or text)")
|
||||
|
||||
# MCP Settings
|
||||
mcp_server_name: str = Field("hanzo-memory", description="MCP server name")
|
||||
mcp_server_version: str = Field("1.0.0", description="MCP server version")
|
||||
|
||||
@property
|
||||
def infinity_db_str(self) -> str:
|
||||
"""Get InfinityDB path as string."""
|
||||
return str(self.infinity_db_path.absolute())
|
||||
|
||||
def ensure_paths(self) -> None:
|
||||
"""Ensure required paths exist."""
|
||||
self.infinity_db_path.mkdir(parents=True, exist_ok=True)
|
||||
self.lancedb_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings(
|
||||
api_key=None,
|
||||
disable_auth=False,
|
||||
host="0.0.0.0",
|
||||
port=4000,
|
||||
db_backend="sqlite",
|
||||
infinity_db_path=Path("data/infinity_db"),
|
||||
lancedb_path=Path("data/lancedb"),
|
||||
llm_model="gpt-4o-mini",
|
||||
llm_api_base=None,
|
||||
llm_api_key=None,
|
||||
llm_temperature=0.7,
|
||||
llm_max_tokens=1000,
|
||||
openai_api_key=None,
|
||||
anthropic_api_key=None,
|
||||
embedding_model="BAAI/bge-small-en-v1.5",
|
||||
embedding_dimensions=384,
|
||||
max_memories_per_user=10000,
|
||||
memory_retrieval_limit=50,
|
||||
max_knowledge_bases_per_user=100,
|
||||
max_facts_per_base=100000,
|
||||
redis_url=None,
|
||||
cache_ttl=3600,
|
||||
log_level="INFO",
|
||||
log_format="json",
|
||||
mcp_server_name="hanzo-memory",
|
||||
mcp_server_version="1.0.0",
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Database package."""
|
||||
|
||||
from .base import BaseVectorDB
|
||||
from .client import InfinityClient, get_client
|
||||
from .factory import get_db_client, reset_db_client
|
||||
from .lancedb_client import LanceDBClient
|
||||
|
||||
__all__ = [
|
||||
"BaseVectorDB",
|
||||
"InfinityClient",
|
||||
"LanceDBClient",
|
||||
"get_db_client",
|
||||
"reset_db_client",
|
||||
"get_client",
|
||||
]
|
||||
@@ -1,353 +0,0 @@
|
||||
"""Database backend interfaces and implementations."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Protocol
|
||||
|
||||
|
||||
class DatabaseType(Enum):
|
||||
"""Types of database backends."""
|
||||
|
||||
VECTOR = "vector" # For embeddings and similarity search (LanceDB, Pinecone, Weaviate)
|
||||
RELATIONAL = "relational" # For structured data (PostgreSQL, MySQL, SQLite)
|
||||
DOCUMENT = "document" # For JSON documents (MongoDB, CouchDB)
|
||||
GRAPH = "graph" # For relationships (Neo4j, ArangoDB)
|
||||
KEY_VALUE = "key_value" # For simple storage (Redis, RocksDB)
|
||||
TIME_SERIES = "time_series" # For temporal data (InfluxDB, TimescaleDB)
|
||||
SEARCH = "search" # For full-text search (Elasticsearch, MeiliSearch)
|
||||
FILE = "file" # For file-based storage (JSON, CSV, Parquet)
|
||||
|
||||
|
||||
class VectorDatabase(Protocol):
|
||||
"""Interface for vector databases."""
|
||||
|
||||
async def upsert_vectors(
|
||||
self,
|
||||
vectors: List[List[float]],
|
||||
ids: List[str],
|
||||
metadata: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""Insert or update vectors with metadata."""
|
||||
...
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query_vector: List[float],
|
||||
limit: int = 10,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for similar vectors."""
|
||||
...
|
||||
|
||||
async def delete_vectors(self, ids: List[str]) -> None:
|
||||
"""Delete vectors by ID."""
|
||||
...
|
||||
|
||||
async def get_vectors(self, ids: List[str]) -> List[Dict[str, Any]]:
|
||||
"""Retrieve vectors by ID."""
|
||||
...
|
||||
|
||||
|
||||
class RelationalDatabase(Protocol):
|
||||
"""Interface for relational databases."""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
query: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Execute a SQL query."""
|
||||
...
|
||||
|
||||
async def insert(
|
||||
self,
|
||||
table: str,
|
||||
data: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Insert a record and return ID."""
|
||||
...
|
||||
|
||||
async def update(
|
||||
self,
|
||||
table: str,
|
||||
id: str,
|
||||
data: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Update a record."""
|
||||
...
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
table: str,
|
||||
id: str,
|
||||
) -> bool:
|
||||
"""Delete a record."""
|
||||
...
|
||||
|
||||
async def select(
|
||||
self,
|
||||
table: str,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Select records from table."""
|
||||
...
|
||||
|
||||
async def create_table(
|
||||
self,
|
||||
table: str,
|
||||
schema: Dict[str, str],
|
||||
) -> None:
|
||||
"""Create a table with schema."""
|
||||
...
|
||||
|
||||
|
||||
class DocumentDatabase(Protocol):
|
||||
"""Interface for document databases."""
|
||||
|
||||
async def insert_document(
|
||||
self,
|
||||
collection: str,
|
||||
document: Dict[str, Any],
|
||||
) -> str:
|
||||
"""Insert a document and return ID."""
|
||||
...
|
||||
|
||||
async def find_documents(
|
||||
self,
|
||||
collection: str,
|
||||
filter: Dict[str, Any],
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find documents matching filter."""
|
||||
...
|
||||
|
||||
async def update_document(
|
||||
self,
|
||||
collection: str,
|
||||
id: str,
|
||||
updates: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Update a document."""
|
||||
...
|
||||
|
||||
async def delete_document(
|
||||
self,
|
||||
collection: str,
|
||||
id: str,
|
||||
) -> bool:
|
||||
"""Delete a document."""
|
||||
...
|
||||
|
||||
async def create_index(
|
||||
self,
|
||||
collection: str,
|
||||
fields: List[str],
|
||||
) -> None:
|
||||
"""Create an index on fields."""
|
||||
...
|
||||
|
||||
|
||||
class GraphDatabase(Protocol):
|
||||
"""Interface for graph databases."""
|
||||
|
||||
async def add_node(
|
||||
self,
|
||||
id: str,
|
||||
labels: List[str],
|
||||
properties: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Add a node to the graph."""
|
||||
...
|
||||
|
||||
async def add_edge(
|
||||
self,
|
||||
from_id: str,
|
||||
to_id: str,
|
||||
relationship: str,
|
||||
properties: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Add an edge between nodes."""
|
||||
...
|
||||
|
||||
async def find_neighbors(
|
||||
self,
|
||||
node_id: str,
|
||||
relationship: Optional[str] = None,
|
||||
depth: int = 1,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find neighboring nodes."""
|
||||
...
|
||||
|
||||
async def shortest_path(
|
||||
self,
|
||||
from_id: str,
|
||||
to_id: str,
|
||||
) -> Optional[List[str]]:
|
||||
"""Find shortest path between nodes."""
|
||||
...
|
||||
|
||||
|
||||
class KeyValueDatabase(Protocol):
|
||||
"""Interface for key-value databases."""
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""Get value by key."""
|
||||
...
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Set key-value with optional TTL."""
|
||||
...
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
...
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""Check if key exists."""
|
||||
...
|
||||
|
||||
async def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
...
|
||||
|
||||
|
||||
class SearchDatabase(Protocol):
|
||||
"""Interface for search databases."""
|
||||
|
||||
async def index_document(
|
||||
self,
|
||||
index: str,
|
||||
id: str,
|
||||
document: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Index a document for search."""
|
||||
...
|
||||
|
||||
async def search(
|
||||
self,
|
||||
index: str,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Full-text search."""
|
||||
...
|
||||
|
||||
async def delete_document(
|
||||
self,
|
||||
index: str,
|
||||
id: str,
|
||||
) -> bool:
|
||||
"""Delete a document from index."""
|
||||
...
|
||||
|
||||
async def create_index(
|
||||
self,
|
||||
index: str,
|
||||
mappings: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Create a search index."""
|
||||
...
|
||||
|
||||
|
||||
class TimeSeriesDatabase(Protocol):
|
||||
"""Interface for time-series databases."""
|
||||
|
||||
async def write_point(
|
||||
self,
|
||||
measurement: str,
|
||||
tags: Dict[str, str],
|
||||
fields: Dict[str, Any],
|
||||
timestamp: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Write a time-series data point."""
|
||||
...
|
||||
|
||||
async def query(
|
||||
self,
|
||||
measurement: str,
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
aggregation: Optional[str] = None,
|
||||
group_by: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query time-series data."""
|
||||
...
|
||||
|
||||
async def delete_series(
|
||||
self,
|
||||
measurement: str,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
) -> bool:
|
||||
"""Delete a time series."""
|
||||
...
|
||||
|
||||
|
||||
class FileDatabase(Protocol):
|
||||
"""Interface for file-based storage."""
|
||||
|
||||
async def read_file(self, path: str) -> Dict[str, Any]:
|
||||
"""Read data from file."""
|
||||
...
|
||||
|
||||
async def write_file(
|
||||
self,
|
||||
path: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Write data to file."""
|
||||
...
|
||||
|
||||
async def append_file(
|
||||
self,
|
||||
path: str,
|
||||
data: Any,
|
||||
) -> None:
|
||||
"""Append data to file."""
|
||||
...
|
||||
|
||||
async def delete_file(self, path: str) -> bool:
|
||||
"""Delete a file."""
|
||||
...
|
||||
|
||||
async def list_files(
|
||||
self,
|
||||
pattern: str = "*",
|
||||
) -> List[str]:
|
||||
"""List files matching pattern."""
|
||||
...
|
||||
|
||||
|
||||
class DatabaseBackend(ABC):
|
||||
"""Base class for all database backends."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize with configuration."""
|
||||
self.config = config
|
||||
self.db_type: DatabaseType = DatabaseType.FILE
|
||||
self.capabilities: List[str] = []
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the database."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the database."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if database is healthy."""
|
||||
pass
|
||||
|
||||
def supports(self, capability: str) -> bool:
|
||||
"""Check if backend supports a capability."""
|
||||
return capability in self.capabilities
|
||||
@@ -1,288 +0,0 @@
|
||||
"""Backend registry for memory storage.
|
||||
|
||||
This provides a simple way to register and use different backends.
|
||||
Supports multiple database types:
|
||||
- LanceDB: Vector database with SQL-like queries
|
||||
- KuzuDB: Graph database for relationship-based queries
|
||||
- InfinityDB: High-performance vector database
|
||||
- Local files: Simple JSON storage for development
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Type
|
||||
from structlog import get_logger
|
||||
|
||||
from ..base import BaseVectorDB
|
||||
from ..local_client import LocalMemoryClient
|
||||
from ..lancedb_client import LanceDBClient
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Try to import optional backends
|
||||
try:
|
||||
from ..kuzudb_client import KuzuDBClient
|
||||
KUZU_AVAILABLE = True
|
||||
except ImportError:
|
||||
KUZU_AVAILABLE = False
|
||||
logger.debug("KuzuDB client not available")
|
||||
|
||||
try:
|
||||
from ..client import InfinityClient
|
||||
INFINITY_AVAILABLE = True
|
||||
except ImportError:
|
||||
INFINITY_AVAILABLE = False
|
||||
logger.debug("InfinityDB client not available")
|
||||
|
||||
try:
|
||||
from ..sqlite_client import SQLiteMemoryClient
|
||||
SQLITE_AVAILABLE = True
|
||||
except ImportError:
|
||||
SQLITE_AVAILABLE = False
|
||||
logger.debug("SQLite client not available")
|
||||
|
||||
|
||||
class BackendCapability:
|
||||
"""Capabilities that backends can support."""
|
||||
|
||||
VECTOR_SEARCH = "vector_search"
|
||||
FULL_TEXT_SEARCH = "full_text_search"
|
||||
STRUCTURED_QUERY = "structured_query"
|
||||
MARKDOWN_IMPORT = "markdown_import"
|
||||
PERSISTENCE = "persistence"
|
||||
EMBEDDINGS = "embeddings"
|
||||
GRAPH_QUERIES = "graph_queries"
|
||||
TIME_SERIES = "time_series"
|
||||
|
||||
|
||||
class BackendRegistry:
|
||||
"""Registry for memory backends."""
|
||||
|
||||
# Registered backends and their capabilities
|
||||
BACKENDS: Dict[str, Dict] = {
|
||||
"lancedb": {
|
||||
"class": LanceDBClient,
|
||||
"description": "LanceDB - Embedded vector database with SQL-like queries",
|
||||
"capabilities": [
|
||||
BackendCapability.VECTOR_SEARCH,
|
||||
BackendCapability.STRUCTURED_QUERY,
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.EMBEDDINGS,
|
||||
BackendCapability.MARKDOWN_IMPORT,
|
||||
],
|
||||
"config": {
|
||||
"enable_markdown": True,
|
||||
},
|
||||
},
|
||||
"local": {
|
||||
"class": LocalMemoryClient,
|
||||
"description": "Local file storage using JSON files",
|
||||
"capabilities": [
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.MARKDOWN_IMPORT,
|
||||
BackendCapability.FULL_TEXT_SEARCH, # Simple text search
|
||||
],
|
||||
"config": {
|
||||
"enable_markdown": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Add optional backends if available
|
||||
@classmethod
|
||||
def _init_optional_backends(cls):
|
||||
"""Initialize optional backends if they're available."""
|
||||
if KUZU_AVAILABLE and "kuzudb" not in cls.BACKENDS:
|
||||
cls.BACKENDS["kuzudb"] = {
|
||||
"class": KuzuDBClient,
|
||||
"description": "KuzuDB - Graph database for relationship-based memory storage",
|
||||
"capabilities": [
|
||||
BackendCapability.GRAPH_QUERIES,
|
||||
BackendCapability.STRUCTURED_QUERY,
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.EMBEDDINGS,
|
||||
BackendCapability.MARKDOWN_IMPORT,
|
||||
],
|
||||
"config": {
|
||||
"enable_markdown": True,
|
||||
},
|
||||
}
|
||||
|
||||
if INFINITY_AVAILABLE and "infinity" not in cls.BACKENDS:
|
||||
cls.BACKENDS["infinity"] = {
|
||||
"class": InfinityClient,
|
||||
"description": "InfinityDB - High-performance vector database",
|
||||
"capabilities": [
|
||||
BackendCapability.VECTOR_SEARCH,
|
||||
BackendCapability.STRUCTURED_QUERY,
|
||||
BackendCapability.FULL_TEXT_SEARCH,
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.EMBEDDINGS,
|
||||
BackendCapability.TIME_SERIES,
|
||||
],
|
||||
"config": {},
|
||||
}
|
||||
|
||||
if SQLITE_AVAILABLE and "sqlite" not in cls.BACKENDS:
|
||||
cls.BACKENDS["sqlite"] = {
|
||||
"class": SQLiteMemoryClient,
|
||||
"description": "SQLite - Lightweight embedded database with vector search via sqlite-vec",
|
||||
"capabilities": [
|
||||
BackendCapability.VECTOR_SEARCH,
|
||||
BackendCapability.STRUCTURED_QUERY,
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.EMBEDDINGS,
|
||||
BackendCapability.MARKDOWN_IMPORT,
|
||||
],
|
||||
"config": {},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_backend(
|
||||
cls,
|
||||
name: str,
|
||||
config: Optional[Dict] = None,
|
||||
) -> BaseVectorDB:
|
||||
"""Get a backend instance by name.
|
||||
|
||||
Args:
|
||||
name: Backend name (e.g., "lancedb", "local")
|
||||
config: Optional configuration overrides
|
||||
|
||||
Returns:
|
||||
Backend instance
|
||||
|
||||
Raises:
|
||||
ValueError: If backend not found
|
||||
"""
|
||||
# Initialize optional backends
|
||||
cls._init_optional_backends()
|
||||
|
||||
if name not in cls.BACKENDS:
|
||||
available = ", ".join(cls.BACKENDS.keys())
|
||||
raise ValueError(f"Unknown backend: {name}. Available: {available}")
|
||||
|
||||
backend_info = cls.BACKENDS[name]
|
||||
backend_class = backend_info["class"]
|
||||
|
||||
# Merge default config with user config
|
||||
backend_config = backend_info.get("config", {}).copy()
|
||||
if config:
|
||||
backend_config.update(config)
|
||||
|
||||
logger.info(
|
||||
f"Creating {name} backend",
|
||||
description=backend_info["description"],
|
||||
capabilities=backend_info["capabilities"],
|
||||
)
|
||||
|
||||
# Create instance with config
|
||||
if backend_config:
|
||||
return backend_class(**backend_config)
|
||||
return backend_class()
|
||||
|
||||
@classmethod
|
||||
def list_backends(cls) -> Dict[str, Dict]:
|
||||
"""List all available backends and their capabilities."""
|
||||
# Initialize optional backends
|
||||
cls._init_optional_backends()
|
||||
|
||||
return {
|
||||
name: {
|
||||
"description": info["description"],
|
||||
"capabilities": info["capabilities"],
|
||||
}
|
||||
for name, info in cls.BACKENDS.items()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def find_backend_for_capability(
|
||||
cls,
|
||||
capability: str,
|
||||
) -> Optional[str]:
|
||||
"""Find the best backend for a specific capability.
|
||||
|
||||
Args:
|
||||
capability: Required capability
|
||||
|
||||
Returns:
|
||||
Backend name or None if no backend supports it
|
||||
"""
|
||||
for name, info in cls.BACKENDS.items():
|
||||
if capability in info["capabilities"]:
|
||||
return name
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_backend_for_task(
|
||||
cls,
|
||||
task: str,
|
||||
) -> str:
|
||||
"""Get the recommended backend for a specific task.
|
||||
|
||||
Args:
|
||||
task: Task type (e.g., "similarity_search", "document_storage")
|
||||
|
||||
Returns:
|
||||
Recommended backend name
|
||||
"""
|
||||
task_mapping = {
|
||||
"similarity_search": "lancedb",
|
||||
"vector_search": "lancedb",
|
||||
"embeddings": "lancedb",
|
||||
"document_storage": "local",
|
||||
"simple_storage": "local",
|
||||
"markdown_import": "local", # Both support it, but local is simpler
|
||||
"development": "local", # Simple for dev
|
||||
"production": "lancedb", # Better for production
|
||||
}
|
||||
|
||||
return task_mapping.get(task, "local") # Default to local
|
||||
|
||||
@classmethod
|
||||
def register_backend(
|
||||
cls,
|
||||
name: str,
|
||||
backend_class: Type[BaseVectorDB],
|
||||
description: str,
|
||||
capabilities: list[str],
|
||||
config: Optional[Dict] = None,
|
||||
) -> None:
|
||||
"""Register a new backend.
|
||||
|
||||
Args:
|
||||
name: Backend name
|
||||
backend_class: Backend class
|
||||
description: Human-readable description
|
||||
capabilities: List of capabilities
|
||||
config: Default configuration
|
||||
"""
|
||||
cls.BACKENDS[name] = {
|
||||
"class": backend_class,
|
||||
"description": description,
|
||||
"capabilities": capabilities,
|
||||
"config": config or {},
|
||||
}
|
||||
logger.info(f"Registered backend: {name}")
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def get_backend(name: str = "local", **config) -> BaseVectorDB:
|
||||
"""Get a backend instance.
|
||||
|
||||
Args:
|
||||
name: Backend name (default: "local")
|
||||
**config: Configuration options
|
||||
|
||||
Returns:
|
||||
Backend instance
|
||||
"""
|
||||
return BackendRegistry.get_backend(name, config)
|
||||
|
||||
|
||||
def list_backends() -> Dict[str, Dict]:
|
||||
"""List all available backends."""
|
||||
return BackendRegistry.list_backends()
|
||||
|
||||
|
||||
def get_best_backend_for(capability: str) -> Optional[str]:
|
||||
"""Get the best backend for a capability."""
|
||||
return BackendRegistry.find_backend_for_capability(capability)
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Base database interface for vector storage backends."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class BaseVectorDB(ABC):
|
||||
"""Abstract base class for vector database backends."""
|
||||
|
||||
@abstractmethod
|
||||
def create_project(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new project."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_user_projects(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all projects for a user."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_memories_table(self, user_id: str) -> None:
|
||||
"""Create a memories table for a user."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
importance: float = 0.5,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a memory to the database."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_memories(
|
||||
self,
|
||||
user_id: str,
|
||||
query_embedding: list[float],
|
||||
project_id: str | None = None,
|
||||
limit: int = 10,
|
||||
min_similarity: float = 0.0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search memories by similarity."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_knowledge_base(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
project_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new knowledge base."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_knowledge_bases(self, project_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all knowledge bases for a project."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_fact(
|
||||
self,
|
||||
fact_id: str,
|
||||
knowledge_base_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
confidence: float = 1.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a fact to a knowledge base."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_facts(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
query_embedding: list[float] | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search facts in a knowledge base."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_fact(self, fact_id: str, knowledge_base_id: str) -> bool:
|
||||
"""Delete a fact from a knowledge base."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
importance: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a memory in the database."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_chat_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new chat session."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_chat_message(
|
||||
self,
|
||||
message_id: str,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a message to a chat session."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_chat_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get messages from a chat session."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_chat_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
query_embedding: list[float],
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search messages in a chat session by similarity."""
|
||||
pass
|
||||
|
||||
# Optional methods for implementations - not abstract
|
||||
# Implementations can override these if needed
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection (optional for implementations)."""
|
||||
# Default implementation does nothing
|
||||
return None
|
||||
|
||||
def create_projects_table(self) -> None:
|
||||
"""Create projects table if not exists (optional for implementations)."""
|
||||
# Default implementation does nothing
|
||||
return None
|
||||
|
||||
def create_knowledge_bases_table(self) -> None:
|
||||
"""Create knowledge bases table if not exists (optional for implementations)."""
|
||||
# Default implementation does nothing
|
||||
return None
|
||||
@@ -1,456 +0,0 @@
|
||||
"""InfinityDB client for vector storage and search."""
|
||||
|
||||
import json
|
||||
import platform
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
from .base import BaseVectorDB
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Try to import InfinityDB, fall back to mock for unsupported platforms
|
||||
try:
|
||||
import infinity_embedded
|
||||
|
||||
logger.info("Using InfinityDB")
|
||||
except ImportError:
|
||||
logger.debug(
|
||||
f"InfinityDB not available on {platform.system()} {platform.machine()}, using mock implementation"
|
||||
)
|
||||
from . import mock_infinity as infinity_embedded
|
||||
|
||||
|
||||
class InfinityClient(BaseVectorDB):
|
||||
"""Client for InfinityDB operations."""
|
||||
|
||||
def __init__(self, db_path: str | None = None):
|
||||
"""Initialize InfinityDB client."""
|
||||
self.db_path = db_path or settings.infinity_db_str
|
||||
Path(self.db_path).mkdir(parents=True, exist_ok=True)
|
||||
self.infinity = infinity_embedded.connect(self.db_path)
|
||||
self._ensure_databases()
|
||||
|
||||
def _ensure_databases(self) -> None:
|
||||
"""Ensure required databases exist."""
|
||||
# Create main databases
|
||||
for db_name in ["projects", "memories", "knowledge", "chats"]:
|
||||
try:
|
||||
self.infinity.create_database(db_name)
|
||||
logger.info(f"Created database: {db_name}")
|
||||
except Exception:
|
||||
# Database already exists
|
||||
pass
|
||||
|
||||
def _get_db(self, db_name: str) -> Any:
|
||||
"""Get a database object."""
|
||||
return self.infinity.get_database(db_name)
|
||||
|
||||
# Project Management
|
||||
def create_projects_table(self) -> None:
|
||||
"""Create projects table if not exists."""
|
||||
db = self._get_db("projects")
|
||||
try:
|
||||
db.create_table(
|
||||
"projects",
|
||||
{
|
||||
"project_id": {"type": "varchar"},
|
||||
"user_id": {"type": "varchar"},
|
||||
"name": {"type": "varchar"},
|
||||
"description": {"type": "varchar"},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
"created_at": {"type": "varchar"},
|
||||
"updated_at": {"type": "varchar"},
|
||||
},
|
||||
)
|
||||
logger.info("Created projects table")
|
||||
except Exception as e:
|
||||
logger.debug(f"Projects table may already exist: {e}")
|
||||
|
||||
def create_project(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new project."""
|
||||
db = self._get_db("projects")
|
||||
table = db.get_table("projects")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
project_data = {
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
table.insert([project_data])
|
||||
return project_data
|
||||
|
||||
# Memory Management
|
||||
def create_memories_table(self, user_id: str) -> None:
|
||||
"""Create memories table for a user if not exists."""
|
||||
db = self._get_db("memories")
|
||||
table_name = f"memories_{user_id}"
|
||||
|
||||
try:
|
||||
db.create_table(
|
||||
table_name,
|
||||
{
|
||||
"memory_id": {"type": "varchar"},
|
||||
"user_id": {"type": "varchar"},
|
||||
"project_id": {"type": "varchar"},
|
||||
"content": {"type": "varchar"},
|
||||
"embedding": {
|
||||
"type": f"vector,{settings.embedding_dimensions},float"
|
||||
},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
"importance": {"type": "float"},
|
||||
"created_at": {"type": "varchar"},
|
||||
"updated_at": {"type": "varchar"},
|
||||
},
|
||||
)
|
||||
logger.info(f"Created memories table for user: {user_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
importance: float = 1.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a memory to the database."""
|
||||
db = self._get_db("memories")
|
||||
table_name = f"memories_{user_id}"
|
||||
table = db.get_table(table_name)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
memory_data = {
|
||||
"memory_id": memory_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"content": content,
|
||||
"embedding": embedding,
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
"importance": importance,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
table.insert([memory_data])
|
||||
return memory_data
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
user_id: str,
|
||||
query_embedding: list[float],
|
||||
project_id: str | None = None,
|
||||
limit: int = 10,
|
||||
threshold: float = 0.0,
|
||||
) -> pl.DataFrame:
|
||||
"""Search memories using vector similarity."""
|
||||
db = self._get_db("memories")
|
||||
table_name = f"memories_{user_id}"
|
||||
|
||||
try:
|
||||
table = db.get_table(table_name)
|
||||
|
||||
# Build query
|
||||
query = table.output(["*"]).match_dense(
|
||||
"embedding",
|
||||
query_embedding,
|
||||
"float",
|
||||
"cosine", # Using cosine similarity
|
||||
limit,
|
||||
)
|
||||
|
||||
# Apply project filter if specified
|
||||
if project_id:
|
||||
query = query.filter(f"project_id = '{project_id}'")
|
||||
|
||||
# Execute query and return as polars DataFrame
|
||||
return query.to_pl()
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching memories: {e}")
|
||||
return pl.DataFrame()
|
||||
|
||||
# Knowledge Base Management
|
||||
def create_knowledge_bases_table(self) -> None:
|
||||
"""Create knowledge bases table if not exists."""
|
||||
db = self._get_db("knowledge")
|
||||
try:
|
||||
db.create_table(
|
||||
"knowledge_bases",
|
||||
{
|
||||
"kb_id": {"type": "varchar"},
|
||||
"user_id": {"type": "varchar"},
|
||||
"project_id": {"type": "varchar"},
|
||||
"name": {"type": "varchar"},
|
||||
"description": {"type": "varchar"},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
"created_at": {"type": "varchar"},
|
||||
"updated_at": {"type": "varchar"},
|
||||
},
|
||||
)
|
||||
logger.info("Created knowledge_bases table")
|
||||
except Exception as e:
|
||||
logger.debug(f"Knowledge bases table may already exist: {e}")
|
||||
|
||||
def create_knowledge_base(
|
||||
self,
|
||||
kb_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new knowledge base."""
|
||||
db = self._get_db("knowledge")
|
||||
table = db.get_table("knowledge_bases")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
kb_data = {
|
||||
"kb_id": kb_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
table.insert([kb_data])
|
||||
|
||||
# Create facts table for this knowledge base
|
||||
self._create_facts_table(kb_id)
|
||||
|
||||
return kb_data
|
||||
|
||||
def _create_facts_table(self, kb_id: str) -> None:
|
||||
"""Create facts table for a knowledge base."""
|
||||
db = self._get_db("knowledge")
|
||||
table_name = f"facts_{kb_id}"
|
||||
|
||||
try:
|
||||
db.create_table(
|
||||
table_name,
|
||||
{
|
||||
"fact_id": {"type": "varchar"},
|
||||
"kb_id": {"type": "varchar"},
|
||||
"content": {"type": "varchar"},
|
||||
"embedding": {
|
||||
"type": f"vector,{settings.embedding_dimensions},float"
|
||||
},
|
||||
"parent_id": {"type": "varchar"},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
"created_at": {"type": "varchar"},
|
||||
"updated_at": {"type": "varchar"},
|
||||
},
|
||||
)
|
||||
logger.info(f"Created facts table for kb: {kb_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_fact(
|
||||
self,
|
||||
fact_id: str,
|
||||
kb_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
parent_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a fact to a knowledge base."""
|
||||
db = self._get_db("knowledge")
|
||||
table_name = f"facts_{kb_id}"
|
||||
table = db.get_table(table_name)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
fact_data = {
|
||||
"fact_id": fact_id,
|
||||
"kb_id": kb_id,
|
||||
"content": content,
|
||||
"embedding": embedding,
|
||||
"parent_id": parent_id or "",
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
table.insert([fact_data])
|
||||
return fact_data
|
||||
|
||||
def search_facts(
|
||||
self,
|
||||
kb_id: str,
|
||||
query_embedding: list[float],
|
||||
limit: int = 10,
|
||||
parent_id: str | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""Search facts in a knowledge base."""
|
||||
db = self._get_db("knowledge")
|
||||
table_name = f"facts_{kb_id}"
|
||||
|
||||
try:
|
||||
table = db.get_table(table_name)
|
||||
|
||||
# Build query
|
||||
query = table.output(["*"]).match_dense(
|
||||
"embedding", query_embedding, "float", "cosine", limit
|
||||
)
|
||||
|
||||
# Apply parent filter if specified
|
||||
if parent_id:
|
||||
query = query.filter(f"parent_id = '{parent_id}'")
|
||||
|
||||
return query.to_pl()
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching facts: {e}")
|
||||
return pl.DataFrame()
|
||||
|
||||
# Chat Management
|
||||
def create_chats_table(self, user_id: str) -> None:
|
||||
"""Create chats table for a user."""
|
||||
db = self._get_db("chats")
|
||||
table_name = f"chats_{user_id}"
|
||||
|
||||
try:
|
||||
db.create_table(
|
||||
table_name,
|
||||
{
|
||||
"chat_id": {"type": "varchar"},
|
||||
"user_id": {"type": "varchar"},
|
||||
"project_id": {"type": "varchar"},
|
||||
"session_id": {"type": "varchar"},
|
||||
"role": {"type": "varchar"}, # user, assistant, system
|
||||
"content": {"type": "varchar"},
|
||||
"embedding": {
|
||||
"type": f"vector,{settings.embedding_dimensions},float"
|
||||
},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
"created_at": {"type": "varchar"},
|
||||
},
|
||||
)
|
||||
logger.info(f"Created chats table for user: {user_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def add_chat_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a chat message."""
|
||||
db = self._get_db("chats")
|
||||
table_name = f"chats_{user_id}"
|
||||
table = db.get_table(table_name)
|
||||
|
||||
chat_data = {
|
||||
"chat_id": chat_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"session_id": session_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"embedding": embedding,
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
table.insert([chat_data])
|
||||
return chat_data
|
||||
|
||||
def get_chat_history(
|
||||
self,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
limit: int = 100,
|
||||
) -> pl.DataFrame:
|
||||
"""Get chat history for a session."""
|
||||
db = self._get_db("chats")
|
||||
table_name = f"chats_{user_id}"
|
||||
|
||||
try:
|
||||
table = db.get_table(table_name)
|
||||
|
||||
# Get messages for session ordered by created_at
|
||||
query = table.output(["*"]).filter(f"session_id = '{session_id}'")
|
||||
|
||||
# TODO: Add proper ordering once InfinityDB supports it
|
||||
return query.to_pl()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting chat history: {e}")
|
||||
return pl.DataFrame()
|
||||
|
||||
def search_chats(
|
||||
self,
|
||||
user_id: str,
|
||||
query_embedding: list[float],
|
||||
project_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> pl.DataFrame:
|
||||
"""Search chat messages."""
|
||||
db = self._get_db("chats")
|
||||
table_name = f"chats_{user_id}"
|
||||
|
||||
try:
|
||||
table = db.get_table(table_name)
|
||||
|
||||
# Build query
|
||||
query = table.output(["*"]).match_dense(
|
||||
"embedding", query_embedding, "float", "cosine", limit
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if project_id:
|
||||
query = query.filter(f"project_id = '{project_id}'")
|
||||
if session_id:
|
||||
query = query.filter(f"session_id = '{session_id}'")
|
||||
|
||||
return query.to_pl()
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching chats: {e}")
|
||||
return pl.DataFrame()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the InfinityDB connection."""
|
||||
if hasattr(self, "infinity"):
|
||||
# InfinityDB embedded doesn't have explicit close method
|
||||
pass
|
||||
|
||||
|
||||
# Global client instance
|
||||
_client: InfinityClient | None = None
|
||||
|
||||
|
||||
def get_client() -> InfinityClient:
|
||||
"""Get or create the global InfinityDB client."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = InfinityClient()
|
||||
return _client
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Database factory for selecting the appropriate backend."""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
from .base import BaseVectorDB
|
||||
from .backends.backend_registry import BackendRegistry
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Global database client instance
|
||||
_db_client: BaseVectorDB | None = None
|
||||
|
||||
|
||||
def get_db_client(
|
||||
backend: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> BaseVectorDB:
|
||||
"""Get or create the global database client.
|
||||
|
||||
Args:
|
||||
backend: Override backend type (default: from settings)
|
||||
config: Optional configuration for the backend
|
||||
|
||||
Returns:
|
||||
Database client instance
|
||||
"""
|
||||
global _db_client
|
||||
|
||||
# Determine backend
|
||||
backend_name = backend or settings.db_backend.lower()
|
||||
|
||||
# Reset client if backend changed
|
||||
if _db_client and hasattr(_db_client, "__backend_name__"):
|
||||
if _db_client.__backend_name__ != backend_name: # type: ignore
|
||||
logger.info(f"Backend changed from {_db_client.__backend_name__} to {backend_name}, resetting client") # type: ignore
|
||||
_db_client = None
|
||||
|
||||
if _db_client is None:
|
||||
# Special handling for legacy "infinity" backend name
|
||||
if backend_name == "infinity":
|
||||
logger.warning("InfinityDB backend is optional, falling back to local")
|
||||
backend_name = "local"
|
||||
|
||||
try:
|
||||
_db_client = BackendRegistry.get_backend(backend_name, config)
|
||||
_db_client.__backend_name__ = backend_name # type: ignore # Store for comparison
|
||||
except ValueError as e:
|
||||
logger.error(f"Failed to create backend {backend_name}: {e}")
|
||||
logger.info("Falling back to local backend")
|
||||
_db_client = BackendRegistry.get_backend("local", config)
|
||||
_db_client.__backend_name__ = "local" # type: ignore
|
||||
|
||||
return _db_client
|
||||
|
||||
|
||||
def reset_db_client() -> None:
|
||||
"""Reset the global database client (useful for testing)."""
|
||||
global _db_client
|
||||
_db_client = None
|
||||
|
||||
|
||||
def list_available_backends() -> Dict[str, Dict]:
|
||||
"""List all available backends and their capabilities."""
|
||||
return BackendRegistry.list_backends()
|
||||
|
||||
|
||||
def get_backend_for_task(task: str) -> str:
|
||||
"""Get the recommended backend for a specific task.
|
||||
|
||||
Args:
|
||||
task: Task description (e.g., "vector_search", "document_storage")
|
||||
|
||||
Returns:
|
||||
Recommended backend name
|
||||
"""
|
||||
return BackendRegistry.get_backend_for_task(task)
|
||||
@@ -1,635 +0,0 @@
|
||||
"""KuzuDB client for graph-based memory storage."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
from ..models.memory import Memory, MemoryCreate, MemoryResponse
|
||||
from ..models.project import Project, ProjectCreate
|
||||
from ..models.fact import Fact, FactCreate
|
||||
from .base import BaseVectorDB
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
try:
|
||||
import kuzu
|
||||
KUZU_AVAILABLE = True
|
||||
except ImportError:
|
||||
KUZU_AVAILABLE = False
|
||||
logger.warning("KuzuDB not available. Install with: pip install kuzu")
|
||||
|
||||
|
||||
class KuzuDBClient(BaseVectorDB):
|
||||
"""KuzuDB implementation for graph-based memory storage."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None, enable_markdown: bool = True):
|
||||
"""Initialize KuzuDB client.
|
||||
|
||||
Args:
|
||||
db_path: Path to KuzuDB database directory
|
||||
enable_markdown: Whether to import markdown files
|
||||
"""
|
||||
if not KUZU_AVAILABLE:
|
||||
raise ImportError("KuzuDB not installed. Install with: pip install kuzu")
|
||||
|
||||
self.db_path = Path(db_path or settings.db_path) / "kuzudb"
|
||||
self.db_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize KuzuDB database
|
||||
self.db = kuzu.Database(str(self.db_path))
|
||||
self.conn = kuzu.Connection(self.db)
|
||||
|
||||
# Initialize schema
|
||||
self._init_schema()
|
||||
|
||||
# Import markdown if enabled
|
||||
self.enable_markdown = enable_markdown
|
||||
if enable_markdown:
|
||||
from ..markdown_memory import MarkdownMemoryReader
|
||||
self.markdown_reader = MarkdownMemoryReader()
|
||||
self._import_markdown_memories()
|
||||
|
||||
logger.info(f"Initialized KuzuDB storage at {self.db_path}")
|
||||
|
||||
def _init_schema(self):
|
||||
"""Initialize KuzuDB schema with nodes and relationships."""
|
||||
# Create node tables
|
||||
queries = [
|
||||
# User node
|
||||
"""CREATE NODE TABLE IF NOT EXISTS User(
|
||||
user_id STRING PRIMARY KEY,
|
||||
name STRING,
|
||||
created_at TIMESTAMP
|
||||
)""",
|
||||
|
||||
# Project node
|
||||
"""CREATE NODE TABLE IF NOT EXISTS Project(
|
||||
project_id STRING PRIMARY KEY,
|
||||
name STRING,
|
||||
description STRING,
|
||||
metadata STRING,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)""",
|
||||
|
||||
# Memory node
|
||||
"""CREATE NODE TABLE IF NOT EXISTS Memory(
|
||||
memory_id STRING PRIMARY KEY,
|
||||
content STRING,
|
||||
memory_type STRING,
|
||||
importance DOUBLE,
|
||||
context STRING,
|
||||
metadata STRING,
|
||||
source STRING,
|
||||
embedding DOUBLE[],
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)""",
|
||||
|
||||
# Fact node
|
||||
"""CREATE NODE TABLE IF NOT EXISTS Fact(
|
||||
fact_id STRING PRIMARY KEY,
|
||||
statement STRING,
|
||||
confidence DOUBLE,
|
||||
source STRING,
|
||||
metadata STRING,
|
||||
embedding DOUBLE[],
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)""",
|
||||
|
||||
# KnowledgeBase node
|
||||
"""CREATE NODE TABLE IF NOT EXISTS KnowledgeBase(
|
||||
kb_id STRING PRIMARY KEY,
|
||||
name STRING,
|
||||
description STRING,
|
||||
metadata STRING,
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
)""",
|
||||
|
||||
# Create relationship tables
|
||||
"""CREATE REL TABLE IF NOT EXISTS OWNS(
|
||||
FROM User TO Project
|
||||
)""",
|
||||
|
||||
"""CREATE REL TABLE IF NOT EXISTS HAS_MEMORY(
|
||||
FROM Project TO Memory,
|
||||
user_id STRING
|
||||
)""",
|
||||
|
||||
"""CREATE REL TABLE IF NOT EXISTS HAS_FACT(
|
||||
FROM Project TO Fact,
|
||||
user_id STRING
|
||||
)""",
|
||||
|
||||
"""CREATE REL TABLE IF NOT EXISTS IN_KB(
|
||||
FROM Memory TO KnowledgeBase
|
||||
)""",
|
||||
|
||||
"""CREATE REL TABLE IF NOT EXISTS RELATES_TO(
|
||||
FROM Memory TO Memory,
|
||||
relationship_type STRING,
|
||||
strength DOUBLE
|
||||
)""",
|
||||
|
||||
"""CREATE REL TABLE IF NOT EXISTS DERIVED_FROM(
|
||||
FROM Fact TO Memory
|
||||
)""",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
try:
|
||||
self.conn.execute(query)
|
||||
except Exception as e:
|
||||
# Table might already exist
|
||||
logger.debug(f"Schema creation note: {e}")
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the database."""
|
||||
logger.info("KuzuDB initialized")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close database connection."""
|
||||
if hasattr(self, 'conn'):
|
||||
# KuzuDB doesn't have explicit close, but we can clean up
|
||||
self.conn = None
|
||||
logger.info("Closing KuzuDB connection")
|
||||
|
||||
def create_project_sync(self, project: ProjectCreate, user_id: str) -> dict:
|
||||
"""Create a project synchronously."""
|
||||
project_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Create project node
|
||||
self.conn.execute(
|
||||
"""MERGE (p:Project {project_id: $pid})
|
||||
SET p.name = $name,
|
||||
p.description = $desc,
|
||||
p.metadata = $metadata,
|
||||
p.created_at = $created,
|
||||
p.updated_at = $updated
|
||||
""",
|
||||
{
|
||||
"pid": project_id,
|
||||
"name": project.name,
|
||||
"desc": project.description,
|
||||
"metadata": json.dumps(project.metadata or {}),
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}
|
||||
)
|
||||
|
||||
# Create or connect user
|
||||
self.conn.execute(
|
||||
"""MERGE (u:User {user_id: $uid})
|
||||
ON CREATE SET u.created_at = $created
|
||||
""",
|
||||
{"uid": user_id, "created": now}
|
||||
)
|
||||
|
||||
# Create ownership relationship
|
||||
self.conn.execute(
|
||||
"""MATCH (u:User {user_id: $uid}), (p:Project {project_id: $pid})
|
||||
MERGE (u)-[:OWNS]->(p)
|
||||
""",
|
||||
{"uid": user_id, "pid": project_id}
|
||||
)
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"user_id": user_id,
|
||||
"metadata": project.metadata or {},
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
async def create_project(self, project: ProjectCreate, user_id: str) -> Project:
|
||||
"""Create a project."""
|
||||
project_data = self.create_project_sync(project, user_id)
|
||||
return Project(**project_data)
|
||||
|
||||
def add_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
importance: float = 0.5,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a memory to the graph."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Create memory node
|
||||
self.conn.execute(
|
||||
"""MERGE (m:Memory {memory_id: $mid})
|
||||
SET m.content = $content,
|
||||
m.memory_type = $mtype,
|
||||
m.importance = $importance,
|
||||
m.context = $context,
|
||||
m.metadata = $metadata,
|
||||
m.source = $source,
|
||||
m.embedding = $embedding,
|
||||
m.created_at = $created,
|
||||
m.updated_at = $updated
|
||||
""",
|
||||
{
|
||||
"mid": memory_id,
|
||||
"content": content,
|
||||
"mtype": metadata.get("memory_type", "general") if metadata else "general",
|
||||
"importance": importance,
|
||||
"context": json.dumps(metadata.get("context", {}) if metadata else {}),
|
||||
"metadata": json.dumps(metadata or {}),
|
||||
"source": metadata.get("source", "") if metadata else "",
|
||||
"embedding": embedding,
|
||||
"created": now,
|
||||
"updated": now,
|
||||
}
|
||||
)
|
||||
|
||||
# Connect to project
|
||||
self.conn.execute(
|
||||
"""MATCH (p:Project {project_id: $pid}), (m:Memory {memory_id: $mid})
|
||||
MERGE (p)-[:HAS_MEMORY {user_id: $uid}]->(m)
|
||||
""",
|
||||
{"pid": project_id, "mid": memory_id, "uid": user_id}
|
||||
)
|
||||
|
||||
# Find and create relationships to related memories
|
||||
if embedding:
|
||||
self._create_memory_relationships(memory_id, embedding, project_id)
|
||||
|
||||
return {
|
||||
"memory_id": memory_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
"content": content,
|
||||
"metadata": metadata or {},
|
||||
"importance": importance,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
def _create_memory_relationships(self, memory_id: str, embedding: list[float], project_id: str):
|
||||
"""Create relationships between related memories based on similarity."""
|
||||
# Find similar memories in the same project
|
||||
results = self.conn.execute(
|
||||
"""MATCH (p:Project {project_id: $pid})-[:HAS_MEMORY]->(m:Memory)
|
||||
WHERE m.memory_id <> $mid AND m.embedding IS NOT NULL
|
||||
RETURN m.memory_id, m.embedding
|
||||
LIMIT 10
|
||||
""",
|
||||
{"pid": project_id, "mid": memory_id}
|
||||
)
|
||||
|
||||
query_vec = np.array(embedding)
|
||||
for row in results:
|
||||
other_id = row[0]
|
||||
other_embedding = row[1]
|
||||
if other_embedding:
|
||||
similarity = self._compute_similarity(query_vec, np.array(other_embedding))
|
||||
if similarity > 0.7: # Only create strong relationships
|
||||
self.conn.execute(
|
||||
"""MATCH (m1:Memory {memory_id: $mid1}), (m2:Memory {memory_id: $mid2})
|
||||
MERGE (m1)-[:RELATES_TO {relationship_type: 'similar', strength: $strength}]->(m2)
|
||||
""",
|
||||
{"mid1": memory_id, "mid2": other_id, "strength": similarity}
|
||||
)
|
||||
|
||||
def _compute_similarity(self, vec1: np.ndarray, vec2: np.ndarray) -> float:
|
||||
"""Compute cosine similarity between two vectors."""
|
||||
dot_product = np.dot(vec1, vec2)
|
||||
norm1 = np.linalg.norm(vec1)
|
||||
norm2 = np.linalg.norm(vec2)
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
return float(dot_product / (norm1 * norm2))
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
query_embedding: List[float],
|
||||
user_id: str,
|
||||
project_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
memory_type: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Search memories using graph traversal and vector similarity."""
|
||||
import pandas as pd
|
||||
|
||||
# Build the query
|
||||
where_clause = []
|
||||
params = {"uid": user_id, "limit": limit}
|
||||
|
||||
if project_id:
|
||||
where_clause.append("p.project_id = $pid")
|
||||
params["pid"] = project_id
|
||||
|
||||
if memory_type:
|
||||
where_clause.append("m.memory_type = $mtype")
|
||||
params["mtype"] = memory_type
|
||||
|
||||
where = " AND ".join(where_clause) if where_clause else "1=1"
|
||||
|
||||
# Query memories
|
||||
query = f"""
|
||||
MATCH (u:User {{user_id: $uid}})-[:OWNS]->(p:Project)-[:HAS_MEMORY]->(m:Memory)
|
||||
WHERE {where}
|
||||
RETURN m.memory_id, m.content, m.memory_type, m.importance,
|
||||
m.context, m.metadata, m.source, m.embedding,
|
||||
m.created_at, m.updated_at, p.project_id, $uid as user_id
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
results = self.conn.execute(query, params)
|
||||
|
||||
# Calculate similarities and build dataframe
|
||||
data = []
|
||||
query_vec = np.array(query_embedding) if query_embedding else None
|
||||
|
||||
for row in results:
|
||||
similarity = 0.0
|
||||
if query_vec is not None and row[7]: # embedding
|
||||
similarity = self._compute_similarity(query_vec, np.array(row[7]))
|
||||
|
||||
data.append({
|
||||
"memory_id": row[0],
|
||||
"content": row[1],
|
||||
"memory_type": row[2],
|
||||
"importance": row[3],
|
||||
"context": row[4],
|
||||
"metadata": row[5],
|
||||
"source": row[6],
|
||||
"created_at": row[8],
|
||||
"updated_at": row[9],
|
||||
"project_id": row[10],
|
||||
"user_id": row[11],
|
||||
"similarity_score": similarity,
|
||||
})
|
||||
|
||||
# Sort by similarity if we have embeddings
|
||||
if query_embedding:
|
||||
data.sort(key=lambda x: x["similarity_score"], reverse=True)
|
||||
|
||||
return pd.DataFrame(data[:limit])
|
||||
|
||||
async def search_memories_async(
|
||||
self,
|
||||
query_embedding: List[float],
|
||||
project_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
memory_type: Optional[str] = None,
|
||||
) -> List[Memory]:
|
||||
"""Async wrapper for search_memories."""
|
||||
df = self.search_memories(query_embedding, user_id or "default", project_id, limit, memory_type)
|
||||
|
||||
memories = []
|
||||
for _, row in df.iterrows():
|
||||
memories.append(Memory(
|
||||
memory_id=row.get("memory_id"),
|
||||
project_id=row.get("project_id"),
|
||||
user_id=row.get("user_id"),
|
||||
content=row.get("content"),
|
||||
memory_type=row.get("memory_type"),
|
||||
importance=row.get("importance", 0.5),
|
||||
context=json.loads(row.get("context", "{}")) if isinstance(row.get("context"), str) else row.get("context", {}),
|
||||
metadata=json.loads(row.get("metadata", "{}")) if isinstance(row.get("metadata"), str) else row.get("metadata", {}),
|
||||
source=row.get("source"),
|
||||
created_at=row.get("created_at"),
|
||||
updated_at=row.get("updated_at"),
|
||||
))
|
||||
return memories
|
||||
|
||||
async def create_memory(
|
||||
self, memory: MemoryCreate, project_id: str, user_id: Optional[str] = None
|
||||
) -> Memory:
|
||||
"""Create a memory."""
|
||||
memory_id = str(uuid.uuid4())
|
||||
user_id = user_id or "default"
|
||||
|
||||
memory_data = self.add_memory(
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
content=memory.content,
|
||||
embedding=memory.embedding or [0.0] * settings.embedding_dimensions,
|
||||
metadata={
|
||||
"memory_type": memory.memory_type,
|
||||
"context": memory.context or {},
|
||||
"source": memory.source,
|
||||
**(memory.metadata or {})
|
||||
},
|
||||
importance=memory.importance,
|
||||
)
|
||||
|
||||
return Memory(**memory_data)
|
||||
|
||||
async def get_recent_memories(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
memory_type: Optional[str] = None,
|
||||
) -> List[Memory]:
|
||||
"""Get recent memories from the graph."""
|
||||
user_id = user_id or "default"
|
||||
|
||||
where_clause = ["p.project_id = $pid"]
|
||||
params = {"pid": project_id, "uid": user_id, "limit": limit}
|
||||
|
||||
if memory_type:
|
||||
where_clause.append("m.memory_type = $mtype")
|
||||
params["mtype"] = memory_type
|
||||
|
||||
where = " AND ".join(where_clause)
|
||||
|
||||
query = f"""
|
||||
MATCH (u:User {{user_id: $uid}})-[:OWNS]->(p:Project)-[:HAS_MEMORY]->(m:Memory)
|
||||
WHERE {where}
|
||||
RETURN m.memory_id, m.content, m.memory_type, m.importance,
|
||||
m.context, m.metadata, m.source,
|
||||
m.created_at, m.updated_at
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $limit
|
||||
"""
|
||||
|
||||
results = self.conn.execute(query, params)
|
||||
|
||||
memories = []
|
||||
for row in results:
|
||||
memories.append(Memory(
|
||||
memory_id=row[0],
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
content=row[1],
|
||||
memory_type=row[2],
|
||||
importance=row[3],
|
||||
context=json.loads(row[4]) if isinstance(row[4], str) else row[4],
|
||||
metadata=json.loads(row[5]) if isinstance(row[5], str) else row[5],
|
||||
source=row[6],
|
||||
created_at=row[7],
|
||||
updated_at=row[8],
|
||||
))
|
||||
|
||||
return memories
|
||||
|
||||
async def list_projects(self, user_id: Optional[str] = None) -> List[Project]:
|
||||
"""List all projects for a user."""
|
||||
user_id = user_id or "default"
|
||||
|
||||
results = self.conn.execute(
|
||||
"""MATCH (u:User {user_id: $uid})-[:OWNS]->(p:Project)
|
||||
RETURN p.project_id, p.name, p.description, p.metadata,
|
||||
p.created_at, p.updated_at
|
||||
""",
|
||||
{"uid": user_id}
|
||||
)
|
||||
|
||||
projects = []
|
||||
for row in results:
|
||||
projects.append(Project(
|
||||
project_id=row[0],
|
||||
name=row[1],
|
||||
description=row[2],
|
||||
user_id=user_id,
|
||||
metadata=json.loads(row[3]) if isinstance(row[3], str) else row[3],
|
||||
created_at=row[4],
|
||||
updated_at=row[5],
|
||||
))
|
||||
|
||||
return projects
|
||||
|
||||
def create_memories_table(self, user_id: str) -> None:
|
||||
"""Create memories table (no-op for KuzuDB as schema is predefined)."""
|
||||
pass
|
||||
|
||||
def _import_markdown_memories(self):
|
||||
"""Import memories from markdown files."""
|
||||
if not self.markdown_reader:
|
||||
return
|
||||
|
||||
memories = self.markdown_reader.read_all_memories()
|
||||
imported_count = 0
|
||||
|
||||
# Create a special project for markdown imports
|
||||
project_id = "markdown_import"
|
||||
self.conn.execute(
|
||||
"""MERGE (p:Project {project_id: $pid})
|
||||
SET p.name = $name,
|
||||
p.description = $desc,
|
||||
p.created_at = $created,
|
||||
p.updated_at = $updated
|
||||
""",
|
||||
{
|
||||
"pid": project_id,
|
||||
"name": "Markdown Import",
|
||||
"desc": "Memories imported from markdown files",
|
||||
"created": datetime.now(timezone.utc).isoformat(),
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
for memory_create in memories:
|
||||
memory_id = str(uuid.uuid4())
|
||||
self.add_memory(
|
||||
memory_id=memory_id,
|
||||
user_id="system",
|
||||
project_id=project_id,
|
||||
content=memory_create.content,
|
||||
embedding=[0.0] * settings.embedding_dimensions,
|
||||
metadata={
|
||||
"memory_type": memory_create.memory_type or "knowledge",
|
||||
"context": memory_create.context or {},
|
||||
"source": memory_create.source,
|
||||
**(memory_create.metadata or {})
|
||||
},
|
||||
importance=memory_create.importance,
|
||||
)
|
||||
imported_count += 1
|
||||
|
||||
if imported_count > 0:
|
||||
logger.info(f"Imported {imported_count} memories from markdown files to KuzuDB")
|
||||
|
||||
# Additional methods for graph-specific operations
|
||||
def get_related_memories(self, memory_id: str, relationship_type: Optional[str] = None) -> List[Dict]:
|
||||
"""Get memories related to a specific memory through graph relationships."""
|
||||
where = ""
|
||||
params = {"mid": memory_id}
|
||||
|
||||
if relationship_type:
|
||||
where = "{relationship_type: $rtype}"
|
||||
params["rtype"] = relationship_type
|
||||
|
||||
query = f"""
|
||||
MATCH (m1:Memory {{memory_id: $mid}})-[r:RELATES_TO {where}]->(m2:Memory)
|
||||
RETURN m2.memory_id, m2.content, r.relationship_type, r.strength
|
||||
ORDER BY r.strength DESC
|
||||
"""
|
||||
|
||||
results = self.conn.execute(query, params)
|
||||
|
||||
related = []
|
||||
for row in results:
|
||||
related.append({
|
||||
"memory_id": row[0],
|
||||
"content": row[1],
|
||||
"relationship_type": row[2],
|
||||
"strength": row[3],
|
||||
})
|
||||
|
||||
return related
|
||||
|
||||
def get_memory_graph(self, project_id: str, depth: int = 2) -> Dict:
|
||||
"""Get a subgraph of memories and their relationships."""
|
||||
# Get nodes
|
||||
nodes_query = """
|
||||
MATCH (p:Project {project_id: $pid})-[:HAS_MEMORY]->(m:Memory)
|
||||
RETURN m.memory_id, m.content, m.memory_type, m.importance
|
||||
LIMIT 100
|
||||
"""
|
||||
|
||||
nodes_results = self.conn.execute(nodes_query, {"pid": project_id})
|
||||
|
||||
nodes = []
|
||||
node_ids = set()
|
||||
for row in nodes_results:
|
||||
nodes.append({
|
||||
"id": row[0],
|
||||
"content": row[1],
|
||||
"type": row[2],
|
||||
"importance": row[3],
|
||||
})
|
||||
node_ids.add(row[0])
|
||||
|
||||
# Get edges
|
||||
edges_query = """
|
||||
MATCH (m1:Memory)-[r:RELATES_TO]->(m2:Memory)
|
||||
WHERE m1.memory_id IN $ids AND m2.memory_id IN $ids
|
||||
RETURN m1.memory_id, m2.memory_id, r.relationship_type, r.strength
|
||||
"""
|
||||
|
||||
edges_results = self.conn.execute(edges_query, {"ids": list(node_ids)})
|
||||
|
||||
edges = []
|
||||
for row in edges_results:
|
||||
edges.append({
|
||||
"source": row[0],
|
||||
"target": row[1],
|
||||
"type": row[2],
|
||||
"strength": row[3],
|
||||
})
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,716 +0,0 @@
|
||||
"""Local file-based memory storage implementation."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from structlog import get_logger
|
||||
|
||||
from ..models.knowledge import Fact, FactCreate, KnowledgeBase
|
||||
from ..models.memory import Memory, MemoryCreate, MemoryResponse
|
||||
from ..models.project import Project, ProjectCreate
|
||||
from .base import BaseVectorDB
|
||||
from .markdown_reader import MarkdownMemoryReader
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class LocalMemoryClient(BaseVectorDB):
|
||||
"""Local file-based implementation of the vector database."""
|
||||
|
||||
def __init__(self, storage_dir: Optional[Path] = None, enable_markdown: bool = True):
|
||||
"""Initialize local memory storage.
|
||||
|
||||
Args:
|
||||
storage_dir: Directory to store memory files
|
||||
enable_markdown: Whether to enable markdown file integration
|
||||
"""
|
||||
self.storage_dir = storage_dir or Path.home() / ".hanzo" / "memory"
|
||||
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# File paths for different collections
|
||||
self.memories_file = self.storage_dir / "memories.json"
|
||||
self.facts_file = self.storage_dir / "facts.json"
|
||||
self.projects_file = self.storage_dir / "projects.json"
|
||||
self.embeddings_file = self.storage_dir / "embeddings.npz"
|
||||
self.markdown_index_file = self.storage_dir / "markdown_index.json"
|
||||
|
||||
# Load existing data
|
||||
self.memories = self._load_json(self.memories_file)
|
||||
self.facts = self._load_json(self.facts_file)
|
||||
self.projects = self._load_json(self.projects_file)
|
||||
self.embeddings = self._load_embeddings()
|
||||
self.markdown_index = self._load_json(self.markdown_index_file)
|
||||
|
||||
# Initialize markdown reader if enabled
|
||||
self.enable_markdown = enable_markdown
|
||||
self.markdown_reader = MarkdownMemoryReader() if enable_markdown else None
|
||||
|
||||
# Import markdown memories on initialization
|
||||
if self.enable_markdown:
|
||||
self._import_markdown_memories()
|
||||
|
||||
logger.info(f"Initialized local memory storage at {self.storage_dir}")
|
||||
|
||||
def _load_json(self, file_path: Path) -> Dict[str, Any]:
|
||||
"""Load JSON data from file."""
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
def _save_json(self, data: Dict[str, Any], file_path: Path):
|
||||
"""Save JSON data to file."""
|
||||
try:
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving {file_path}: {e}")
|
||||
|
||||
def _load_embeddings(self) -> Dict[str, np.ndarray]:
|
||||
"""Load embeddings from file."""
|
||||
if self.embeddings_file.exists():
|
||||
try:
|
||||
data = np.load(self.embeddings_file, allow_pickle=True)
|
||||
return {k: v for k, v in data.items()}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading embeddings: {e}")
|
||||
return {}
|
||||
|
||||
def _save_embeddings(self):
|
||||
"""Save embeddings to file."""
|
||||
try:
|
||||
np.savez_compressed(self.embeddings_file, **self.embeddings)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving embeddings: {e}")
|
||||
|
||||
def _compute_similarity(self, embedding1: np.ndarray, embedding2: np.ndarray) -> float:
|
||||
"""Compute cosine similarity between two embeddings."""
|
||||
dot_product = np.dot(embedding1, embedding2)
|
||||
norm1 = np.linalg.norm(embedding1)
|
||||
norm2 = np.linalg.norm(embedding2)
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
return float(dot_product / (norm1 * norm2))
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the database (no-op for local storage)."""
|
||||
logger.info("Local memory storage initialized")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the database connection (save all data)."""
|
||||
self._save_json(self.memories, self.memories_file)
|
||||
self._save_json(self.facts, self.facts_file)
|
||||
self._save_json(self.projects, self.projects_file)
|
||||
self._save_embeddings()
|
||||
logger.info("Local memory storage closed")
|
||||
|
||||
# Memory operations
|
||||
async def create_memory(
|
||||
self, memory: MemoryCreate, project_id: str, user_id: Optional[str] = None
|
||||
) -> Memory:
|
||||
"""Create a new memory."""
|
||||
memory_id = str(uuid.uuid4())
|
||||
|
||||
# Create memory object
|
||||
memory_data = {
|
||||
"id": memory_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id or "default",
|
||||
"content": memory.content,
|
||||
"memory_type": memory.memory_type,
|
||||
"importance": memory.importance,
|
||||
"context": memory.context,
|
||||
"metadata": memory.metadata or {},
|
||||
"source": memory.source,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Store memory
|
||||
self.memories[memory_id] = memory_data
|
||||
|
||||
# Store embedding if provided
|
||||
if memory.embedding:
|
||||
self.embeddings[f"memory_{memory_id}"] = np.array(memory.embedding)
|
||||
|
||||
# Save to disk
|
||||
self._save_json(self.memories, self.memories_file)
|
||||
if memory.embedding:
|
||||
self._save_embeddings()
|
||||
|
||||
# Return with proper memory_id field
|
||||
memory_data["memory_id"] = memory_id # Ensure memory_id is set
|
||||
return Memory(**memory_data)
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
query_embedding: List[float],
|
||||
user_id: str,
|
||||
project_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
memory_type: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Search for similar memories synchronously."""
|
||||
query_vec = np.array(query_embedding)
|
||||
results = []
|
||||
|
||||
for mem_id, memory in self.memories.items():
|
||||
# Filter by project and user
|
||||
if project_id and memory.get("project_id") != project_id:
|
||||
continue
|
||||
if user_id and memory.get("user_id") != user_id:
|
||||
continue
|
||||
if memory_type and memory.get("memory_type") != memory_type:
|
||||
continue
|
||||
|
||||
# Calculate similarity if embedding exists
|
||||
embedding_key = f"memory_{mem_id}"
|
||||
if embedding_key in self.embeddings:
|
||||
similarity = self._compute_similarity(query_vec, self.embeddings[embedding_key])
|
||||
results.append({
|
||||
"memory": memory,
|
||||
"similarity": similarity
|
||||
})
|
||||
|
||||
# Sort by similarity and limit
|
||||
results.sort(key=lambda x: x["similarity"], reverse=True)
|
||||
results = results[:limit]
|
||||
|
||||
# Convert to dataframe-like structure for compatibility
|
||||
import pandas as pd
|
||||
if not results:
|
||||
return pd.DataFrame()
|
||||
|
||||
# Extract memory data for dataframe
|
||||
data = []
|
||||
for r in results:
|
||||
mem = r["memory"]
|
||||
data.append({
|
||||
"memory_id": mem.get("id", mem.get("memory_id")),
|
||||
"project_id": mem.get("project_id"),
|
||||
"user_id": mem.get("user_id"),
|
||||
"content": mem.get("content"),
|
||||
"memory_type": mem.get("memory_type"),
|
||||
"importance": mem.get("importance"),
|
||||
"context": mem.get("context"),
|
||||
"metadata": mem.get("metadata"),
|
||||
"source": mem.get("source"),
|
||||
"created_at": mem.get("created_at"),
|
||||
"updated_at": mem.get("updated_at"),
|
||||
"similarity_score": r["similarity"]
|
||||
})
|
||||
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
async def search_memories_async(
|
||||
self,
|
||||
query_embedding: List[float],
|
||||
project_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
memory_type: Optional[str] = None,
|
||||
min_importance: float = 0.0,
|
||||
) -> List[MemoryResponse]:
|
||||
"""Search for similar memories."""
|
||||
query_vec = np.array(query_embedding)
|
||||
results = []
|
||||
|
||||
for mem_id, memory in self.memories.items():
|
||||
# Filter by project and user
|
||||
if memory["project_id"] != project_id:
|
||||
continue
|
||||
if user_id and memory.get("user_id") != user_id:
|
||||
continue
|
||||
if memory_type and memory.get("memory_type") != memory_type:
|
||||
continue
|
||||
if memory.get("importance", 0) < min_importance:
|
||||
continue
|
||||
|
||||
# Calculate similarity if embedding exists
|
||||
embedding_key = f"memory_{mem_id}"
|
||||
if embedding_key in self.embeddings:
|
||||
similarity = self._compute_similarity(query_vec, self.embeddings[embedding_key])
|
||||
results.append({
|
||||
"memory": Memory(**memory),
|
||||
"similarity": similarity
|
||||
})
|
||||
|
||||
# Sort by similarity and limit
|
||||
results.sort(key=lambda x: x["similarity"], reverse=True)
|
||||
results = results[:limit]
|
||||
|
||||
return [
|
||||
MemoryResponse(
|
||||
memory=r["memory"],
|
||||
similarity=r["similarity"],
|
||||
relevance_score=r["similarity"]
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
|
||||
async def get_recent_memories(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
memory_type: Optional[str] = None,
|
||||
) -> List[Memory]:
|
||||
"""Get recent memories."""
|
||||
results = []
|
||||
|
||||
for memory in self.memories.values():
|
||||
# Filter
|
||||
if memory["project_id"] != project_id:
|
||||
continue
|
||||
if user_id and memory.get("user_id") != user_id:
|
||||
continue
|
||||
if memory_type and memory.get("memory_type") != memory_type:
|
||||
continue
|
||||
|
||||
results.append(memory)
|
||||
|
||||
# Sort by timestamp
|
||||
results.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||
results = results[:limit]
|
||||
|
||||
return [Memory(**r) for r in results]
|
||||
|
||||
async def delete_memory(self, memory_id: str, project_id: str) -> bool:
|
||||
"""Delete a memory."""
|
||||
if memory_id in self.memories:
|
||||
if self.memories[memory_id]["project_id"] == project_id:
|
||||
del self.memories[memory_id]
|
||||
|
||||
# Remove embedding
|
||||
embedding_key = f"memory_{memory_id}"
|
||||
if embedding_key in self.embeddings:
|
||||
del self.embeddings[embedding_key]
|
||||
|
||||
# Save changes
|
||||
self._save_json(self.memories, self.memories_file)
|
||||
self._save_embeddings()
|
||||
return True
|
||||
return False
|
||||
|
||||
# Knowledge operations
|
||||
async def create_fact(
|
||||
self, fact: FactCreate, project_id: str, user_id: Optional[str] = None
|
||||
) -> Fact:
|
||||
"""Create a new fact."""
|
||||
fact_id = str(uuid.uuid4())
|
||||
|
||||
fact_data = {
|
||||
"id": fact_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id or "default",
|
||||
"subject": fact.subject,
|
||||
"predicate": fact.predicate,
|
||||
"object": fact.object,
|
||||
"confidence": fact.confidence,
|
||||
"source": fact.source,
|
||||
"metadata": fact.metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
self.facts[fact_id] = fact_data
|
||||
|
||||
# Store embedding if provided
|
||||
if fact.embedding:
|
||||
self.embeddings[f"fact_{fact_id}"] = np.array(fact.embedding)
|
||||
|
||||
# Save to disk
|
||||
self._save_json(self.facts, self.facts_file)
|
||||
if fact.embedding:
|
||||
self._save_embeddings()
|
||||
|
||||
return Fact(**fact_data)
|
||||
|
||||
async def search_facts(
|
||||
self,
|
||||
query_embedding: List[float],
|
||||
project_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
min_confidence: float = 0.0,
|
||||
) -> List[Fact]:
|
||||
"""Search for similar facts."""
|
||||
query_vec = np.array(query_embedding)
|
||||
results = []
|
||||
|
||||
for fact_id, fact in self.facts.items():
|
||||
# Filter
|
||||
if fact["project_id"] != project_id:
|
||||
continue
|
||||
if user_id and fact.get("user_id") != user_id:
|
||||
continue
|
||||
if fact.get("confidence", 0) < min_confidence:
|
||||
continue
|
||||
|
||||
# Calculate similarity
|
||||
embedding_key = f"fact_{fact_id}"
|
||||
if embedding_key in self.embeddings:
|
||||
similarity = self._compute_similarity(query_vec, self.embeddings[embedding_key])
|
||||
results.append({
|
||||
"fact": fact,
|
||||
"similarity": similarity
|
||||
})
|
||||
|
||||
# Sort and limit
|
||||
results.sort(key=lambda x: x["similarity"], reverse=True)
|
||||
results = results[:limit]
|
||||
|
||||
return [Fact(**r["fact"]) for r in results]
|
||||
|
||||
async def get_knowledge_graph(
|
||||
self, project_id: str, user_id: Optional[str] = None
|
||||
) -> KnowledgeBase:
|
||||
"""Get the knowledge graph."""
|
||||
project_facts = []
|
||||
|
||||
for fact in self.facts.values():
|
||||
if fact["project_id"] != project_id:
|
||||
continue
|
||||
if user_id and fact.get("user_id") != user_id:
|
||||
continue
|
||||
project_facts.append(Fact(**fact))
|
||||
|
||||
# Build entity and relation lists
|
||||
entities = set()
|
||||
relations = set()
|
||||
|
||||
for fact in project_facts:
|
||||
entities.add(fact.subject)
|
||||
entities.add(fact.object)
|
||||
relations.add(fact.predicate)
|
||||
|
||||
return KnowledgeBase(
|
||||
facts=project_facts,
|
||||
entities=list(entities),
|
||||
relations=list(relations),
|
||||
metadata={
|
||||
"fact_count": len(project_facts),
|
||||
"entity_count": len(entities),
|
||||
"relation_count": len(relations)
|
||||
}
|
||||
)
|
||||
|
||||
# Project operations
|
||||
async def create_project(
|
||||
self, project: ProjectCreate, user_id: Optional[str] = None
|
||||
) -> Project:
|
||||
"""Create a new project."""
|
||||
project_id = str(uuid.uuid4())
|
||||
|
||||
project_data = {
|
||||
"id": project_id,
|
||||
"project_id": project_id, # Add project_id field
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"user_id": user_id or "default",
|
||||
"metadata": project.metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
self.projects[project_id] = project_data
|
||||
self._save_json(self.projects, self.projects_file)
|
||||
|
||||
return Project(**project_data)
|
||||
|
||||
async def get_project(
|
||||
self, project_id: str, user_id: Optional[str] = None
|
||||
) -> Optional[Project]:
|
||||
"""Get a project by ID."""
|
||||
if project_id in self.projects:
|
||||
project = self.projects[project_id]
|
||||
if not user_id or project.get("user_id") == user_id:
|
||||
return Project(**project)
|
||||
return None
|
||||
|
||||
async def list_projects(self, user_id: Optional[str] = None) -> List[Project]:
|
||||
"""List all projects."""
|
||||
results = []
|
||||
|
||||
for project in self.projects.values():
|
||||
if not user_id or project.get("user_id") == user_id:
|
||||
# Ensure project_id exists for backward compatibility
|
||||
if "project_id" not in project:
|
||||
project["project_id"] = project.get("id", str(uuid.uuid4()))
|
||||
results.append(Project(**project))
|
||||
|
||||
return results
|
||||
|
||||
async def delete_project(
|
||||
self, project_id: str, user_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a project and all associated data."""
|
||||
if project_id in self.projects:
|
||||
project = self.projects[project_id]
|
||||
if not user_id or project.get("user_id") == user_id:
|
||||
# Delete project
|
||||
del self.projects[project_id]
|
||||
|
||||
# Delete associated memories
|
||||
memory_ids_to_delete = [
|
||||
mid for mid, m in self.memories.items()
|
||||
if m["project_id"] == project_id
|
||||
]
|
||||
for mid in memory_ids_to_delete:
|
||||
del self.memories[mid]
|
||||
embedding_key = f"memory_{mid}"
|
||||
if embedding_key in self.embeddings:
|
||||
del self.embeddings[embedding_key]
|
||||
|
||||
# Delete associated facts
|
||||
fact_ids_to_delete = [
|
||||
fid for fid, f in self.facts.items()
|
||||
if f["project_id"] == project_id
|
||||
]
|
||||
for fid in fact_ids_to_delete:
|
||||
del self.facts[fid]
|
||||
embedding_key = f"fact_{fid}"
|
||||
if embedding_key in self.embeddings:
|
||||
del self.embeddings[embedding_key]
|
||||
|
||||
# Save all changes
|
||||
self._save_json(self.projects, self.projects_file)
|
||||
self._save_json(self.memories, self.memories_file)
|
||||
self._save_json(self.facts, self.facts_file)
|
||||
self._save_embeddings()
|
||||
|
||||
return True
|
||||
return False
|
||||
# Additional abstract methods implementation
|
||||
async def create_memories_table(self, user_id: str = None) -> None:
|
||||
"""Create memories table (no-op for local storage)."""
|
||||
pass
|
||||
|
||||
def create_memories_table(self, user_id: str) -> None:
|
||||
"""Create memories table for a user (no-op for local storage)."""
|
||||
pass
|
||||
|
||||
def add_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
importance: float = 0.5,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a memory synchronously."""
|
||||
memory_data = {
|
||||
"id": memory_id,
|
||||
"memory_id": memory_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
"content": content,
|
||||
"memory_type": metadata.get("memory_type", "general") if metadata else "general",
|
||||
"importance": importance,
|
||||
"context": metadata.get("context", {}) if metadata else {},
|
||||
"metadata": metadata or {},
|
||||
"source": metadata.get("source", "") if metadata else "",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Store memory
|
||||
self.memories[memory_id] = memory_data
|
||||
|
||||
# Store embedding
|
||||
if embedding:
|
||||
self.embeddings[f"memory_{memory_id}"] = np.array(embedding)
|
||||
|
||||
# Save to disk
|
||||
self._save_json(self.memories, self.memories_file)
|
||||
if embedding:
|
||||
self._save_embeddings()
|
||||
|
||||
return memory_data
|
||||
|
||||
async def add_memory_async(self, memory: MemoryCreate, project_id: str) -> Memory:
|
||||
"""Add a memory (alias for create_memory)."""
|
||||
return await self.create_memory(memory, project_id)
|
||||
|
||||
async def add_fact(self, fact: FactCreate, project_id: str) -> Fact:
|
||||
"""Add a fact (alias for create_fact)."""
|
||||
return await self.create_fact(fact, project_id)
|
||||
|
||||
async def delete_fact(self, fact_id: str) -> bool:
|
||||
"""Delete a fact by ID."""
|
||||
if fact_id in self.facts:
|
||||
del self.facts[fact_id]
|
||||
embedding_key = f"fact_{fact_id}"
|
||||
if embedding_key in self.embeddings:
|
||||
del self.embeddings[embedding_key]
|
||||
self._save_json(self.facts, self.facts_file)
|
||||
self._save_embeddings()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def create_knowledge_base(self, name: str, description: str, project_id: str) -> KnowledgeBase:
|
||||
"""Create a knowledge base."""
|
||||
return await self.get_knowledge_graph(project_id)
|
||||
|
||||
async def get_knowledge_bases(self, project_id: str) -> List[KnowledgeBase]:
|
||||
"""Get all knowledge bases for a project."""
|
||||
return [await self.get_knowledge_graph(project_id)]
|
||||
|
||||
async def create_chat_session(self, project_id: str, user_id: Optional[str] = None) -> str:
|
||||
"""Create a new chat session."""
|
||||
session_id = str(uuid.uuid4())
|
||||
if not hasattr(self, "chat_sessions"):
|
||||
self.chat_sessions = {}
|
||||
self.chat_sessions[session_id] = {
|
||||
"id": session_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id or "default",
|
||||
"messages": [],
|
||||
"created_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
return session_id
|
||||
|
||||
async def add_chat_message(self, session_id: str, role: str, content: str) -> None:
|
||||
"""Add a message to a chat session."""
|
||||
if not hasattr(self, "chat_sessions"):
|
||||
self.chat_sessions = {}
|
||||
if session_id in self.chat_sessions:
|
||||
self.chat_sessions[session_id]["messages"].append({
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
async def get_chat_messages(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get messages from a chat session."""
|
||||
if not hasattr(self, "chat_sessions"):
|
||||
self.chat_sessions = {}
|
||||
if session_id in self.chat_sessions:
|
||||
return self.chat_sessions[session_id]["messages"]
|
||||
return []
|
||||
|
||||
async def search_chat_messages(self, query: str, session_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Search chat messages."""
|
||||
if not hasattr(self, "chat_sessions"):
|
||||
return []
|
||||
results = []
|
||||
sessions_to_search = [session_id] if session_id else self.chat_sessions.keys()
|
||||
for sid in sessions_to_search:
|
||||
if sid in self.chat_sessions:
|
||||
for msg in self.chat_sessions[sid]["messages"]:
|
||||
if query.lower() in msg["content"].lower():
|
||||
results.append(msg)
|
||||
return results
|
||||
|
||||
async def get_user_projects(self, user_id: str) -> List[Project]:
|
||||
"""Get all projects for a user."""
|
||||
return await self.list_projects(user_id)
|
||||
|
||||
def update_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
importance: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a memory in the database."""
|
||||
if memory_id not in self.memories:
|
||||
return None
|
||||
|
||||
memory = self.memories[memory_id]
|
||||
|
||||
# Verify user and project match
|
||||
if memory.get("user_id") != user_id or memory.get("project_id") != project_id:
|
||||
return None
|
||||
|
||||
# Update fields if provided
|
||||
if content is not None:
|
||||
memory["content"] = content
|
||||
if metadata is not None:
|
||||
memory["metadata"] = metadata
|
||||
if importance is not None:
|
||||
memory["importance"] = importance
|
||||
|
||||
# Update timestamp
|
||||
memory["updated_at"] = datetime.utcnow().isoformat()
|
||||
|
||||
# Save changes
|
||||
self._save_json(self.memories, self.memories_file)
|
||||
|
||||
return memory
|
||||
|
||||
def _import_markdown_memories(self):
|
||||
"""Import memories from markdown files."""
|
||||
if not self.markdown_reader:
|
||||
return
|
||||
|
||||
try:
|
||||
# Read markdown memories
|
||||
markdown_memories = self.markdown_reader.read_markdown_memories()
|
||||
|
||||
# Create a default project for markdown memories
|
||||
project_id = "markdown_import"
|
||||
if project_id not in self.projects:
|
||||
project_data = {
|
||||
"id": project_id,
|
||||
"project_id": project_id,
|
||||
"name": "Markdown Import",
|
||||
"description": "Automatically imported memories from markdown files",
|
||||
"user_id": "system",
|
||||
"metadata": {"auto_created": True},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
self.projects[project_id] = project_data
|
||||
self._save_json(self.projects, self.projects_file)
|
||||
|
||||
# Import each memory
|
||||
imported_count = 0
|
||||
for memory_create in markdown_memories:
|
||||
# Check if this memory already exists (by source)
|
||||
source = memory_create.source
|
||||
existing = any(
|
||||
m.get("source") == source
|
||||
for m in self.memories.values()
|
||||
)
|
||||
|
||||
if not existing:
|
||||
memory_id = str(uuid.uuid4())
|
||||
memory_data = {
|
||||
"id": memory_id,
|
||||
"project_id": project_id,
|
||||
"user_id": "system",
|
||||
"content": memory_create.content,
|
||||
"memory_type": memory_create.memory_type,
|
||||
"importance": memory_create.importance,
|
||||
"context": memory_create.context,
|
||||
"metadata": memory_create.metadata or {},
|
||||
"source": source,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
self.memories[memory_id] = memory_data
|
||||
imported_count += 1
|
||||
|
||||
if imported_count > 0:
|
||||
self._save_json(self.memories, self.memories_file)
|
||||
logger.info(f"Imported {imported_count} new memories from markdown files")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing markdown memories: {e}")
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
"""Markdown file reader for integrating LLM.md and similar files into memory."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from structlog import get_logger
|
||||
|
||||
from ..models.memory import MemoryCreate
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Supported markdown files for memory integration
|
||||
MEMORY_MD_FILES = [
|
||||
"LLM.md",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"GEMINI.md",
|
||||
"QWEN.md",
|
||||
"AI.md",
|
||||
"README.md", # Often contains important project context
|
||||
"CONTEXT.md",
|
||||
"MEMORY.md",
|
||||
"INSTRUCTIONS.md",
|
||||
"PROMPT.md",
|
||||
]
|
||||
|
||||
# Additional patterns to look for
|
||||
MEMORY_MD_PATTERNS = [
|
||||
"*_LLM.md",
|
||||
"*_AGENT.md",
|
||||
"*_AI.md",
|
||||
"*.claude.md",
|
||||
"*.gemini.md",
|
||||
"*.qwen.md",
|
||||
]
|
||||
|
||||
|
||||
class MarkdownMemoryReader:
|
||||
"""Reads and processes markdown files for memory integration."""
|
||||
|
||||
def __init__(self, watch_dirs: Optional[List[Path]] = None):
|
||||
"""Initialize the markdown reader.
|
||||
|
||||
Args:
|
||||
watch_dirs: List of directories to watch for markdown files.
|
||||
Defaults to current directory and parent directories.
|
||||
"""
|
||||
self.watch_dirs = watch_dirs or self._get_default_watch_dirs()
|
||||
self.processed_files: Set[str] = set()
|
||||
self.file_hashes: Dict[str, str] = {}
|
||||
|
||||
def _get_default_watch_dirs(self) -> List[Path]:
|
||||
"""Get default directories to watch."""
|
||||
dirs = []
|
||||
current = Path.cwd()
|
||||
|
||||
# Add current directory
|
||||
dirs.append(current)
|
||||
|
||||
# Add parent directories up to home or 3 levels
|
||||
for _ in range(3):
|
||||
if current.parent == current or current == Path.home():
|
||||
break
|
||||
current = current.parent
|
||||
dirs.append(current)
|
||||
|
||||
# Add home .config directories
|
||||
home = Path.home()
|
||||
for config_dir in [".claude", ".gemini", ".qwen", ".ai", ".llm"]:
|
||||
config_path = home / config_dir
|
||||
if config_path.exists():
|
||||
dirs.append(config_path)
|
||||
|
||||
return dirs
|
||||
|
||||
def _compute_file_hash(self, filepath: Path) -> str:
|
||||
"""Compute hash of file contents."""
|
||||
try:
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
return hashlib.sha256(content.encode()).hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"Error hashing file {filepath}: {e}")
|
||||
return ""
|
||||
|
||||
def _parse_markdown_sections(self, content: str, filepath: Path) -> List[Dict]:
|
||||
"""Parse markdown content into sections."""
|
||||
sections = []
|
||||
current_section = {
|
||||
"title": f"Content from {filepath.name}",
|
||||
"content": "",
|
||||
"level": 0,
|
||||
"line_start": 0,
|
||||
}
|
||||
|
||||
lines = content.split('\n')
|
||||
current_line = 0
|
||||
|
||||
for line in lines:
|
||||
current_line += 1
|
||||
|
||||
# Check for headers
|
||||
if line.startswith('#'):
|
||||
# Save previous section if it has content
|
||||
if current_section["content"].strip():
|
||||
sections.append(current_section)
|
||||
|
||||
# Start new section
|
||||
header_level = len(line) - len(line.lstrip('#'))
|
||||
header_text = line.lstrip('#').strip()
|
||||
|
||||
current_section = {
|
||||
"title": header_text or f"Section from {filepath.name}",
|
||||
"content": "",
|
||||
"level": header_level,
|
||||
"line_start": current_line,
|
||||
}
|
||||
else:
|
||||
# Add line to current section
|
||||
current_section["content"] += line + "\n"
|
||||
|
||||
# Don't forget the last section
|
||||
if current_section["content"].strip():
|
||||
sections.append(current_section)
|
||||
|
||||
# If no sections were found, treat entire content as one section
|
||||
if not sections and content.strip():
|
||||
sections.append({
|
||||
"title": f"Content from {filepath.name}",
|
||||
"content": content,
|
||||
"level": 0,
|
||||
"line_start": 0,
|
||||
})
|
||||
|
||||
return sections
|
||||
|
||||
def find_markdown_files(self) -> List[Path]:
|
||||
"""Find all relevant markdown files in watched directories."""
|
||||
md_files = []
|
||||
seen_files = set()
|
||||
|
||||
for watch_dir in self.watch_dirs:
|
||||
if not watch_dir.exists():
|
||||
continue
|
||||
|
||||
# Look for specific named files
|
||||
for md_file in MEMORY_MD_FILES:
|
||||
filepath = watch_dir / md_file
|
||||
if filepath.exists() and filepath not in seen_files:
|
||||
md_files.append(filepath)
|
||||
seen_files.add(filepath)
|
||||
|
||||
# Look for pattern-matched files
|
||||
for pattern in MEMORY_MD_PATTERNS:
|
||||
for filepath in watch_dir.glob(pattern):
|
||||
if filepath not in seen_files:
|
||||
md_files.append(filepath)
|
||||
seen_files.add(filepath)
|
||||
|
||||
# Sort by priority (LLM.md first, then others)
|
||||
def priority(f: Path) -> int:
|
||||
name = f.name.upper()
|
||||
if name == "LLM.MD":
|
||||
return 0
|
||||
elif name == "CLAUDE.MD":
|
||||
return 1
|
||||
elif name == "GEMINI.MD":
|
||||
return 2
|
||||
elif name == "QWEN.MD":
|
||||
return 3
|
||||
elif name == "AGENTS.MD":
|
||||
return 4
|
||||
else:
|
||||
return 5
|
||||
|
||||
md_files.sort(key=priority)
|
||||
return md_files
|
||||
|
||||
def read_markdown_memories(self) -> List[MemoryCreate]:
|
||||
"""Read all markdown files and convert to memories."""
|
||||
memories = []
|
||||
md_files = self.find_markdown_files()
|
||||
|
||||
for filepath in md_files:
|
||||
try:
|
||||
# Check if file has been processed and unchanged
|
||||
file_id = str(filepath.absolute())
|
||||
current_hash = self._compute_file_hash(filepath)
|
||||
|
||||
if file_id in self.file_hashes and self.file_hashes[file_id] == current_hash:
|
||||
logger.debug(f"Skipping unchanged file: {filepath}")
|
||||
continue
|
||||
|
||||
# Read and parse the file
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
if not content.strip():
|
||||
continue
|
||||
|
||||
# Store hash for future comparison
|
||||
self.file_hashes[file_id] = current_hash
|
||||
|
||||
# Parse into sections
|
||||
sections = self._parse_markdown_sections(content, filepath)
|
||||
|
||||
# Create memories from sections
|
||||
for section in sections:
|
||||
# Determine importance based on file and section
|
||||
importance = self._calculate_importance(filepath, section)
|
||||
|
||||
# Determine memory type
|
||||
memory_type = self._determine_memory_type(filepath, section)
|
||||
|
||||
# Create memory
|
||||
memory = MemoryCreate(
|
||||
content=section["content"],
|
||||
memory_type=memory_type,
|
||||
importance=importance,
|
||||
context={
|
||||
"source_file": str(filepath.absolute()),
|
||||
"file_name": filepath.name,
|
||||
"section_title": section["title"],
|
||||
"section_level": section["level"],
|
||||
"line_start": section["line_start"],
|
||||
"directory": str(filepath.parent.absolute()),
|
||||
"file_modified": datetime.fromtimestamp(filepath.stat().st_mtime).isoformat(),
|
||||
},
|
||||
metadata={
|
||||
"auto_imported": True,
|
||||
"markdown_file": True,
|
||||
},
|
||||
source=f"markdown://{filepath.absolute()}#{section['line_start']}",
|
||||
)
|
||||
memories.append(memory)
|
||||
|
||||
logger.info(f"Read {len(sections)} sections from {filepath.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading markdown file {filepath}: {e}")
|
||||
continue
|
||||
|
||||
return memories
|
||||
|
||||
def _calculate_importance(self, filepath: Path, section: Dict) -> float:
|
||||
"""Calculate importance score for a memory section."""
|
||||
importance = 0.5 # Base importance
|
||||
|
||||
# File-based importance
|
||||
filename = filepath.name.upper()
|
||||
if filename == "LLM.MD":
|
||||
importance += 0.3
|
||||
elif filename in ["CLAUDE.MD", "GEMINI.MD", "QWEN.MD"]:
|
||||
importance += 0.25
|
||||
elif filename == "AGENTS.MD":
|
||||
importance += 0.2
|
||||
elif filename == "README.MD":
|
||||
importance += 0.1
|
||||
|
||||
# Section-based importance
|
||||
title_lower = section["title"].lower()
|
||||
|
||||
# High importance keywords
|
||||
high_importance_keywords = [
|
||||
"important", "critical", "essential", "must", "always",
|
||||
"never", "warning", "error", "security", "key",
|
||||
"architecture", "design", "api", "interface"
|
||||
]
|
||||
|
||||
for keyword in high_importance_keywords:
|
||||
if keyword in title_lower or keyword in section["content"].lower()[:200]:
|
||||
importance += 0.1
|
||||
break
|
||||
|
||||
# Header level importance (H1 > H2 > H3)
|
||||
if section["level"] == 1:
|
||||
importance += 0.15
|
||||
elif section["level"] == 2:
|
||||
importance += 0.1
|
||||
elif section["level"] == 3:
|
||||
importance += 0.05
|
||||
|
||||
# Cap at 1.0
|
||||
return min(importance, 1.0)
|
||||
|
||||
def _determine_memory_type(self, filepath: Path, section: Dict) -> str:
|
||||
"""Determine the type of memory based on content."""
|
||||
filename = filepath.name.upper()
|
||||
content_lower = section["content"].lower()
|
||||
title_lower = section["title"].lower()
|
||||
|
||||
# Check filename patterns
|
||||
if "AGENT" in filename:
|
||||
return "agent_instruction"
|
||||
elif filename in ["CLAUDE.MD", "GEMINI.MD", "QWEN.MD"]:
|
||||
return "model_instruction"
|
||||
elif filename == "LLM.MD":
|
||||
return "system_context"
|
||||
|
||||
# Check content patterns
|
||||
if any(word in content_lower[:500] for word in ["instruction", "prompt", "you should", "you must"]):
|
||||
return "instruction"
|
||||
elif any(word in content_lower[:500] for word in ["api", "endpoint", "function", "method", "class"]):
|
||||
return "technical"
|
||||
elif any(word in content_lower[:500] for word in ["example", "usage", "how to", "tutorial"]):
|
||||
return "example"
|
||||
elif any(word in title_lower for word in ["config", "setting", "environment", "variable"]):
|
||||
return "configuration"
|
||||
elif any(word in title_lower for word in ["architecture", "design", "structure", "pattern"]):
|
||||
return "architectural"
|
||||
|
||||
return "knowledge"
|
||||
|
||||
def watch_for_changes(self) -> List[MemoryCreate]:
|
||||
"""Check for new or modified markdown files and return new memories."""
|
||||
new_memories = []
|
||||
|
||||
# Get current memories
|
||||
current_memories = self.read_markdown_memories()
|
||||
|
||||
# Track which files were processed
|
||||
for memory in current_memories:
|
||||
source_file = memory.context.get("source_file")
|
||||
if source_file:
|
||||
self.processed_files.add(source_file)
|
||||
|
||||
return current_memories
|
||||
|
||||
def get_project_context(self, project_path: Optional[Path] = None) -> Dict:
|
||||
"""Get comprehensive project context from markdown files."""
|
||||
project_path = project_path or Path.cwd()
|
||||
|
||||
context = {
|
||||
"project_path": str(project_path.absolute()),
|
||||
"project_name": project_path.name,
|
||||
"markdown_files": [],
|
||||
"instructions": [],
|
||||
"configurations": [],
|
||||
"examples": [],
|
||||
"architecture": [],
|
||||
}
|
||||
|
||||
# Find and categorize markdown content
|
||||
md_files = self.find_markdown_files()
|
||||
|
||||
for filepath in md_files:
|
||||
try:
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
sections = self._parse_markdown_sections(content, filepath)
|
||||
|
||||
file_info = {
|
||||
"path": str(filepath.absolute()),
|
||||
"name": filepath.name,
|
||||
"modified": datetime.fromtimestamp(filepath.stat().st_mtime).isoformat(),
|
||||
"sections": len(sections),
|
||||
}
|
||||
context["markdown_files"].append(file_info)
|
||||
|
||||
# Categorize sections
|
||||
for section in sections:
|
||||
memory_type = self._determine_memory_type(filepath, section)
|
||||
|
||||
section_info = {
|
||||
"title": section["title"],
|
||||
"content_preview": section["content"][:200] + "..." if len(section["content"]) > 200 else section["content"],
|
||||
"source": filepath.name,
|
||||
}
|
||||
|
||||
if memory_type in ["instruction", "agent_instruction", "model_instruction"]:
|
||||
context["instructions"].append(section_info)
|
||||
elif memory_type == "configuration":
|
||||
context["configurations"].append(section_info)
|
||||
elif memory_type == "example":
|
||||
context["examples"].append(section_info)
|
||||
elif memory_type == "architectural":
|
||||
context["architecture"].append(section_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {filepath}: {e}")
|
||||
|
||||
return context
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Mock InfinityDB implementation for testing and platforms without support."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
|
||||
class MockDatabase:
|
||||
"""Mock database implementation."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.tables: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def create_table(self, table_name: str, schema: dict[str, Any]) -> None:
|
||||
"""Create a table."""
|
||||
if table_name not in self.tables:
|
||||
self.tables[table_name] = {"schema": schema, "data": []}
|
||||
|
||||
def get_table(self, table_name: str) -> "MockTable":
|
||||
"""Get a table."""
|
||||
if table_name not in self.tables:
|
||||
raise ValueError(f"Table {table_name} not found")
|
||||
return MockTable(self, table_name)
|
||||
|
||||
|
||||
class MockTable:
|
||||
"""Mock table implementation."""
|
||||
|
||||
def __init__(self, db: MockDatabase, name: str):
|
||||
self.db = db
|
||||
self.name = name
|
||||
|
||||
def insert(self, records: list[dict[str, Any]]) -> None:
|
||||
"""Insert records."""
|
||||
self.db.tables[self.name]["data"].extend(records)
|
||||
|
||||
def output(self, columns: list[str]) -> "MockQuery":
|
||||
"""Start a query."""
|
||||
return MockQuery(self, columns)
|
||||
|
||||
|
||||
class MockQuery:
|
||||
"""Mock query implementation."""
|
||||
|
||||
def __init__(self, table: MockTable, columns: list[str]):
|
||||
self.table = table
|
||||
self.columns = columns
|
||||
self.filters: list[str] = []
|
||||
self.vector_search: dict[str, Any] | None = None
|
||||
|
||||
def match_dense(
|
||||
self,
|
||||
column: str,
|
||||
query_vector: list[float],
|
||||
dtype: str,
|
||||
metric: str,
|
||||
limit: int,
|
||||
) -> "MockQuery":
|
||||
"""Add vector search."""
|
||||
self.vector_search = {
|
||||
"column": column,
|
||||
"query_vector": np.array(query_vector),
|
||||
"metric": metric,
|
||||
"limit": limit,
|
||||
}
|
||||
return self
|
||||
|
||||
def filter(self, condition: str) -> "MockQuery":
|
||||
"""Add filter condition."""
|
||||
self.filters.append(condition)
|
||||
return self
|
||||
|
||||
def to_pl(self) -> pl.DataFrame:
|
||||
"""Execute query and return polars DataFrame."""
|
||||
# Get all data
|
||||
data = self.table.db.tables[self.table.name]["data"]
|
||||
|
||||
# Apply filters
|
||||
filtered_data = []
|
||||
for record in data:
|
||||
include = True
|
||||
for filter_cond in self.filters:
|
||||
# Simple filter parsing (e.g., "field = 'value'")
|
||||
if " = " in filter_cond:
|
||||
field, value = filter_cond.split(" = ")
|
||||
value = value.strip("'\"")
|
||||
if record.get(field) != value:
|
||||
include = False
|
||||
break
|
||||
if include:
|
||||
filtered_data.append(record)
|
||||
|
||||
# Apply vector search if specified
|
||||
if self.vector_search and filtered_data:
|
||||
# Calculate similarities
|
||||
similarities = []
|
||||
for record in filtered_data:
|
||||
vec = np.array(record[self.vector_search["column"]])
|
||||
query = self.vector_search["query_vector"]
|
||||
|
||||
if self.vector_search["metric"] == "cosine":
|
||||
# Cosine similarity
|
||||
sim = np.dot(vec, query) / (
|
||||
np.linalg.norm(vec) * np.linalg.norm(query)
|
||||
)
|
||||
elif self.vector_search["metric"] == "ip":
|
||||
# Inner product
|
||||
sim = np.dot(vec, query)
|
||||
else:
|
||||
sim = 0.0
|
||||
|
||||
similarities.append((sim, record))
|
||||
|
||||
# Sort by similarity and limit
|
||||
similarities.sort(key=lambda x: x[0], reverse=True)
|
||||
filtered_data = [r for _, r in similarities[: self.vector_search["limit"]]]
|
||||
|
||||
# Return as polars DataFrame
|
||||
if filtered_data:
|
||||
return pl.DataFrame(filtered_data)
|
||||
else:
|
||||
# Return empty DataFrame with schema
|
||||
return pl.DataFrame()
|
||||
|
||||
|
||||
class MockInfinity:
|
||||
"""Mock Infinity connection."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = Path(path)
|
||||
self.databases: dict[str, MockDatabase] = {}
|
||||
|
||||
def create_database(self, name: str) -> None:
|
||||
"""Create a database."""
|
||||
if name not in self.databases:
|
||||
self.databases[name] = MockDatabase(name)
|
||||
|
||||
def get_database(self, name: str) -> MockDatabase:
|
||||
"""Get a database."""
|
||||
if name not in self.databases:
|
||||
self.create_database(name)
|
||||
return self.databases[name]
|
||||
|
||||
|
||||
def connect(path: str) -> MockInfinity:
|
||||
"""Connect to mock InfinityDB."""
|
||||
return MockInfinity(path)
|
||||
@@ -1,778 +0,0 @@
|
||||
"""SQLite-based memory storage implementation with vector search using sqlite-vec."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from structlog import get_logger
|
||||
|
||||
from .base import BaseVectorDB
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class SQLiteMemoryClient(BaseVectorDB):
|
||||
"""SQLite-based implementation of the vector database with sqlite-vec for vector search."""
|
||||
|
||||
def __init__(self, db_path: Optional[Path] = None):
|
||||
"""Initialize SQLite memory storage.
|
||||
|
||||
Args:
|
||||
db_path: Path to SQLite database file. Defaults to in-memory if None.
|
||||
"""
|
||||
if db_path is None:
|
||||
self.db_path = ":memory:"
|
||||
else:
|
||||
self.db_path = str(db_path)
|
||||
|
||||
# Connect to database
|
||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row # Enable column access by name
|
||||
|
||||
# Enable extension loading for sqlite-vec
|
||||
self.conn.enable_load_extension(True)
|
||||
|
||||
# Initialize tables
|
||||
self._init_tables()
|
||||
|
||||
logger.info(f"Initialized SQLite memory storage at {self.db_path}")
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the database (no-op for local storage)."""
|
||||
logger.info("SQLite memory storage initialized")
|
||||
|
||||
def _init_tables(self):
|
||||
"""Initialize required tables."""
|
||||
# Enable sqlite-vec extension
|
||||
try:
|
||||
self.conn.execute("SELECT load_extension('vec');")
|
||||
except sqlite3.Error as e:
|
||||
logger.warning(f"Could not load sqlite-vec extension: {e}")
|
||||
logger.warning("Vector search functionality will be limited")
|
||||
|
||||
# Create projects table
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
user_id TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
|
||||
# Create memories table
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory_id TEXT UNIQUE NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
memory_type TEXT DEFAULT 'general',
|
||||
importance REAL DEFAULT 0.5,
|
||||
context TEXT,
|
||||
metadata TEXT,
|
||||
source TEXT,
|
||||
embedding BLOB,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id)
|
||||
);
|
||||
""")
|
||||
|
||||
# Create facts table
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS facts (
|
||||
id TEXT PRIMARY KEY,
|
||||
fact_id TEXT UNIQUE NOT NULL,
|
||||
knowledge_base_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
predicate TEXT,
|
||||
object TEXT,
|
||||
confidence REAL DEFAULT 1.0,
|
||||
source TEXT,
|
||||
embedding BLOB,
|
||||
metadata TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
|
||||
# Create knowledge bases table
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||||
id TEXT PRIMARY KEY,
|
||||
kb_id TEXT UNIQUE NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
metadata TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(project_id)
|
||||
);
|
||||
""")
|
||||
|
||||
# Create chat sessions table
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
|
||||
# Create chat messages table
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT UNIQUE NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
metadata TEXT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES chat_sessions(session_id)
|
||||
);
|
||||
""")
|
||||
|
||||
# Create indexes
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_user_project ON memories(user_id, project_id);")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_timestamp ON memories(timestamp);")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_facts_kb ON facts(knowledge_base_id);")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_sessions_user_project ON chat_sessions(user_id, project_id);")
|
||||
|
||||
# Create vector index for embeddings if sqlite-vec is available
|
||||
try:
|
||||
# Create vector index for memories
|
||||
self.conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_embedding
|
||||
ON memories (vec_to_json16(embedding))
|
||||
WHERE embedding IS NOT NULL;
|
||||
""")
|
||||
|
||||
# Create vector index for facts
|
||||
self.conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_embedding
|
||||
ON facts (vec_to_json16(embedding))
|
||||
WHERE embedding IS NOT NULL;
|
||||
""")
|
||||
|
||||
# Create vector index for chat messages
|
||||
self.conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_embedding
|
||||
ON chat_messages (vec_to_json16(embedding))
|
||||
WHERE embedding IS NOT NULL;
|
||||
""")
|
||||
except sqlite3.Error:
|
||||
# If sqlite-vec is not available, skip vector indexes
|
||||
pass
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def create_project(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new project."""
|
||||
project_id = project_id or str(uuid.uuid4())
|
||||
metadata_json = json.dumps(metadata or {})
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO projects (id, project_id, user_id, name, description, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(str(uuid.uuid4()), project_id, user_id, name, description, metadata_json)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
return {
|
||||
"id": project_id,
|
||||
"project_id": project_id,
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
def get_user_projects(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all projects for a user."""
|
||||
cursor = self.conn.execute(
|
||||
"SELECT * FROM projects WHERE user_id = ?", (user_id,)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
projects = []
|
||||
for row in rows:
|
||||
projects.append({
|
||||
"id": row["id"],
|
||||
"project_id": row["project_id"],
|
||||
"user_id": row["user_id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
|
||||
return projects
|
||||
|
||||
def create_memories_table(self, user_id: str) -> None:
|
||||
"""Create a memories table for a user (already handled in initialization)."""
|
||||
# Table is created during initialization
|
||||
pass
|
||||
|
||||
def add_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
importance: float = 0.5,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a memory to the database."""
|
||||
memory_id = memory_id or str(uuid.uuid4())
|
||||
metadata_json = json.dumps(metadata or {})
|
||||
context_json = json.dumps({})
|
||||
embedding_blob = np.array(embedding, dtype=np.float32).tobytes() if embedding else None
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO memories
|
||||
(id, memory_id, user_id, project_id, content, importance, context, metadata, source, embedding)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
memory_id,
|
||||
user_id,
|
||||
project_id,
|
||||
content,
|
||||
importance,
|
||||
context_json,
|
||||
metadata_json,
|
||||
"", # source
|
||||
embedding_blob
|
||||
)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
return {
|
||||
"id": memory_id,
|
||||
"memory_id": memory_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"content": content,
|
||||
"importance": importance,
|
||||
"context": {},
|
||||
"metadata": metadata or {},
|
||||
"source": "",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
user_id: str,
|
||||
query_embedding: list[float],
|
||||
project_id: str | None = None,
|
||||
limit: int = 10,
|
||||
min_similarity: float = 0.0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search memories by similarity."""
|
||||
query_array = np.array(query_embedding, dtype=np.float32)
|
||||
query_blob = query_array.tobytes()
|
||||
|
||||
# Build query
|
||||
where_conditions = ["user_id = ?"]
|
||||
params = [user_id]
|
||||
|
||||
if project_id:
|
||||
where_conditions.append("project_id = ?")
|
||||
params.append(project_id)
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
# If sqlite-vec is available, use vector similarity search
|
||||
try:
|
||||
# Use sqlite-vec for similarity search
|
||||
cursor = self.conn.execute(f"""
|
||||
SELECT *, vec_distance_L2(embedding, ?) as distance
|
||||
FROM memories
|
||||
WHERE {where_clause} AND embedding IS NOT NULL
|
||||
ORDER BY distance ASC
|
||||
LIMIT ?
|
||||
""", [query_blob] + params + [limit])
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
# Calculate similarity from distance (convert L2 distance to similarity)
|
||||
distance = row["distance"]
|
||||
similarity = 1 / (1 + distance) # Convert distance to similarity score
|
||||
|
||||
if similarity >= min_similarity:
|
||||
results.append({
|
||||
"memory_id": row["memory_id"],
|
||||
"user_id": row["user_id"],
|
||||
"project_id": row["project_id"],
|
||||
"content": row["content"],
|
||||
"importance": row["importance"],
|
||||
"context": json.loads(row["context"]) if row["context"] else {},
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"source": row["source"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"similarity_score": similarity
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except sqlite3.Error:
|
||||
# Fallback to basic search without vector similarity
|
||||
cursor = self.conn.execute(f"""
|
||||
SELECT *
|
||||
FROM memories
|
||||
WHERE {where_clause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""", params + [limit])
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append({
|
||||
"memory_id": row["memory_id"],
|
||||
"user_id": row["user_id"],
|
||||
"project_id": row["project_id"],
|
||||
"content": row["content"],
|
||||
"importance": row["importance"],
|
||||
"context": json.loads(row["context"]) if row["context"] else {},
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"source": row["source"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"similarity_score": 0.0 # No similarity calculation without sqlite-vec
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def create_knowledge_base(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
project_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new knowledge base."""
|
||||
knowledge_base_id = knowledge_base_id or str(uuid.uuid4())
|
||||
metadata_json = json.dumps(metadata or {})
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO knowledge_bases (id, kb_id, project_id, name, description, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(str(uuid.uuid4()), knowledge_base_id, project_id, name, description, metadata_json)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
return {
|
||||
"id": knowledge_base_id,
|
||||
"kb_id": knowledge_base_id,
|
||||
"project_id": project_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
def get_knowledge_bases(self, project_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all knowledge bases for a project."""
|
||||
cursor = self.conn.execute(
|
||||
"SELECT * FROM knowledge_bases WHERE project_id = ?", (project_id,)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
kbs = []
|
||||
for row in rows:
|
||||
kbs.append({
|
||||
"id": row["id"],
|
||||
"kb_id": row["kb_id"],
|
||||
"project_id": row["project_id"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
|
||||
return kbs
|
||||
|
||||
def add_fact(
|
||||
self,
|
||||
fact_id: str,
|
||||
knowledge_base_id: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
confidence: float = 1.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a fact to a knowledge base."""
|
||||
fact_id = fact_id or str(uuid.uuid4())
|
||||
metadata_json = json.dumps(metadata or {})
|
||||
embedding_blob = np.array(embedding, dtype=np.float32).tobytes() if embedding else None
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO facts
|
||||
(id, fact_id, knowledge_base_id, content, embedding, metadata, confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(str(uuid.uuid4()), fact_id, knowledge_base_id, content, embedding_blob, metadata_json, confidence)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
return {
|
||||
"id": fact_id,
|
||||
"fact_id": fact_id,
|
||||
"knowledge_base_id": knowledge_base_id,
|
||||
"content": content,
|
||||
"confidence": confidence,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
def search_facts(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
query_embedding: list[float] | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search facts in a knowledge base."""
|
||||
if query_embedding is not None:
|
||||
# If sqlite-vec is available and we have an embedding, use vector search
|
||||
query_array = np.array(query_embedding, dtype=np.float32)
|
||||
query_blob = query_array.tobytes()
|
||||
|
||||
try:
|
||||
cursor = self.conn.execute("""
|
||||
SELECT *, vec_distance_L2(embedding, ?) as distance
|
||||
FROM facts
|
||||
WHERE knowledge_base_id = ? AND embedding IS NOT NULL
|
||||
ORDER BY distance ASC
|
||||
LIMIT ?
|
||||
""", [query_blob, knowledge_base_id, limit])
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
distance = row["distance"]
|
||||
similarity = 1 / (1 + distance) # Convert distance to similarity score
|
||||
|
||||
results.append({
|
||||
"fact_id": row["fact_id"],
|
||||
"knowledge_base_id": row["knowledge_base_id"],
|
||||
"content": row["content"],
|
||||
"confidence": row["confidence"],
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"similarity_score": similarity
|
||||
})
|
||||
|
||||
return results
|
||||
except sqlite3.Error:
|
||||
# Fallback to basic search
|
||||
pass
|
||||
|
||||
# Basic search without vector similarity
|
||||
cursor = self.conn.execute(
|
||||
"SELECT * FROM facts WHERE knowledge_base_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(knowledge_base_id, limit)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append({
|
||||
"fact_id": row["fact_id"],
|
||||
"knowledge_base_id": row["knowledge_base_id"],
|
||||
"content": row["content"],
|
||||
"confidence": row["confidence"],
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"similarity_score": 0.0 # No similarity without embedding
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def delete_fact(self, fact_id: str, knowledge_base_id: str) -> bool:
|
||||
"""Delete a fact from a knowledge base."""
|
||||
cursor = self.conn.execute(
|
||||
"DELETE FROM facts WHERE fact_id = ? AND knowledge_base_id = ?",
|
||||
(fact_id, knowledge_base_id)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
importance: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a memory in the database."""
|
||||
# Check if memory exists and belongs to user/project
|
||||
cursor = self.conn.execute(
|
||||
"SELECT * FROM memories WHERE memory_id = ? AND user_id = ? AND project_id = ?",
|
||||
(memory_id, user_id, project_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
# Build update query
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if content is not None:
|
||||
updates.append("content = ?")
|
||||
params.append(content)
|
||||
|
||||
if metadata is not None:
|
||||
updates.append("metadata = ?")
|
||||
params.append(json.dumps(metadata))
|
||||
|
||||
if importance is not None:
|
||||
updates.append("importance = ?")
|
||||
params.append(importance)
|
||||
|
||||
if updates:
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
query = f"UPDATE memories SET {', '.join(updates)} WHERE memory_id = ?"
|
||||
params.append(memory_id)
|
||||
|
||||
self.conn.execute(query, params)
|
||||
self.conn.commit()
|
||||
|
||||
# Return updated memory
|
||||
cursor = self.conn.execute(
|
||||
"SELECT * FROM memories WHERE memory_id = ?", (memory_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"memory_id": row["memory_id"],
|
||||
"user_id": row["user_id"],
|
||||
"project_id": row["project_id"],
|
||||
"content": row["content"],
|
||||
"importance": row["importance"],
|
||||
"context": json.loads(row["context"]) if row["context"] else {},
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"source": row["source"],
|
||||
"timestamp": row["timestamp"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def create_chat_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new chat session."""
|
||||
session_id = session_id or str(uuid.uuid4())
|
||||
metadata_json = json.dumps(metadata or {})
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_sessions (id, session_id, user_id, project_id, metadata)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(str(uuid.uuid4()), session_id, user_id, project_id, metadata_json)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
return {
|
||||
"id": session_id,
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
def add_chat_message(
|
||||
self,
|
||||
message_id: str,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a message to a chat session."""
|
||||
message_id = message_id or str(uuid.uuid4())
|
||||
metadata_json = json.dumps(metadata or {})
|
||||
embedding_blob = np.array(embedding, dtype=np.float32).tobytes() if embedding else None
|
||||
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_messages
|
||||
(id, message_id, session_id, role, content, embedding, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(str(uuid.uuid4()), message_id, session_id, role, content, embedding_blob, metadata_json)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
return {
|
||||
"id": message_id,
|
||||
"message_id": message_id,
|
||||
"session_id": session_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"metadata": metadata or {},
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
def get_chat_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get messages from a chat session."""
|
||||
query = "SELECT * FROM chat_messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||
params = [session_id]
|
||||
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
|
||||
cursor = self.conn.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
messages = []
|
||||
for row in rows:
|
||||
messages.append({
|
||||
"id": row["id"],
|
||||
"message_id": row["message_id"],
|
||||
"session_id": row["session_id"],
|
||||
"role": row["role"],
|
||||
"content": row["content"],
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"timestamp": row["timestamp"],
|
||||
})
|
||||
|
||||
return messages
|
||||
|
||||
def search_chat_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
query_embedding: list[float],
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search messages in a chat session by similarity."""
|
||||
query_array = np.array(query_embedding, dtype=np.float32)
|
||||
query_blob = query_array.tobytes()
|
||||
|
||||
# If sqlite-vec is available, use vector similarity search
|
||||
try:
|
||||
cursor = self.conn.execute("""
|
||||
SELECT *, vec_distance_L2(embedding, ?) as distance
|
||||
FROM chat_messages
|
||||
WHERE session_id = ? AND embedding IS NOT NULL
|
||||
ORDER BY distance ASC
|
||||
LIMIT ?
|
||||
""", [query_blob, session_id, limit])
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
distance = row["distance"]
|
||||
similarity = 1 / (1 + distance) # Convert distance to similarity score
|
||||
|
||||
results.append({
|
||||
"id": row["id"],
|
||||
"message_id": row["message_id"],
|
||||
"session_id": row["session_id"],
|
||||
"role": row["role"],
|
||||
"content": row["content"],
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"timestamp": row["timestamp"],
|
||||
"similarity_score": similarity
|
||||
})
|
||||
|
||||
return results
|
||||
except sqlite3.Error:
|
||||
# Fallback to basic search without vector similarity
|
||||
cursor = self.conn.execute(
|
||||
"SELECT * FROM chat_messages WHERE session_id = ? ORDER BY timestamp DESC LIMIT ?",
|
||||
(session_id, limit)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append({
|
||||
"id": row["id"],
|
||||
"message_id": row["message_id"],
|
||||
"session_id": row["session_id"],
|
||||
"role": row["role"],
|
||||
"content": row["content"],
|
||||
"metadata": json.loads(row["metadata"]) if row["metadata"] else {},
|
||||
"timestamp": row["timestamp"],
|
||||
"similarity_score": 0.0 # No similarity calculation without sqlite-vec
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
@@ -1,5 +0,0 @@
|
||||
"""MCP server for Hanzo Memory service."""
|
||||
|
||||
from .server import MCPMemoryServer
|
||||
|
||||
__all__ = ["MCPMemoryServer"]
|
||||
@@ -1,6 +0,0 @@
|
||||
"""MCP server entry point."""
|
||||
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,459 +0,0 @@
|
||||
"""MCP server for Hanzo Memory service."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.models import InitializationOptions, ServerCapabilities
|
||||
from mcp.types import (
|
||||
TextContent,
|
||||
Tool,
|
||||
)
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
from ..db import get_db_client
|
||||
from ..services.embeddings import EmbeddingService
|
||||
from ..services.llm import LLMService
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class MCPMemoryServer:
|
||||
"""MCP server for memory operations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the MCP server."""
|
||||
self.server: Server = Server(settings.mcp_server_name)
|
||||
self.db_client = get_db_client()
|
||||
self.embedding_service = EmbeddingService()
|
||||
self.llm_service = LLMService()
|
||||
self._setup_handlers()
|
||||
|
||||
def _setup_handlers(self) -> None:
|
||||
"""Set up server handlers."""
|
||||
|
||||
@self.server.list_tools() # type: ignore[misc]
|
||||
async def handle_list_tools() -> list[Tool]:
|
||||
"""Return available tools."""
|
||||
return [
|
||||
Tool(
|
||||
name="remember",
|
||||
description="Store a memory for a user in a project",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Memory content",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata",
|
||||
},
|
||||
"importance": {
|
||||
"type": "number",
|
||||
"description": "Importance score (0-10)",
|
||||
"default": 1.0,
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "project_id", "content"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="recall",
|
||||
description="Search for relevant memories",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID (optional)",
|
||||
},
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of results",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="create_project",
|
||||
description="Create a new project",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"name": {"type": "string", "description": "Project name"},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Project description",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "name"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="create_knowledge_base",
|
||||
description="Create a knowledge base in a project",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base name",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base description",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "project_id", "name"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="add_fact",
|
||||
description="Add a fact to a knowledge base",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"kb_id": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base ID",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Fact content",
|
||||
},
|
||||
"parent_id": {
|
||||
"type": "string",
|
||||
"description": "Parent fact ID (optional)",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "kb_id", "content"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="search_facts",
|
||||
description="Search facts in a knowledge base",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"kb_id": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base ID",
|
||||
},
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of results",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "kb_id", "query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="summarize_for_knowledge",
|
||||
description="Analyze content and generate knowledge update instructions",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to analyze",
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Additional context",
|
||||
},
|
||||
"skip_summarization": {
|
||||
"type": "boolean",
|
||||
"description": "Skip summarization",
|
||||
"default": False,
|
||||
},
|
||||
"provided_summary": {
|
||||
"type": "string",
|
||||
"description": "Pre-made summary",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@self.server.call_tool() # type: ignore[misc]
|
||||
async def handle_call_tool(
|
||||
name: str, arguments: dict[str, Any] | None = None
|
||||
) -> list[TextContent]:
|
||||
"""Handle tool calls."""
|
||||
try:
|
||||
if name == "remember":
|
||||
result = await self._handle_remember(arguments or {})
|
||||
elif name == "recall":
|
||||
result = await self._handle_recall(arguments or {})
|
||||
elif name == "create_project":
|
||||
result = await self._handle_create_project(arguments or {})
|
||||
elif name == "create_knowledge_base":
|
||||
result = await self._handle_create_knowledge_base(arguments or {})
|
||||
elif name == "add_fact":
|
||||
result = await self._handle_add_fact(arguments or {})
|
||||
elif name == "search_facts":
|
||||
result = await self._handle_search_facts(arguments or {})
|
||||
elif name == "summarize_for_knowledge":
|
||||
result = await self._handle_summarize_for_knowledge(arguments or {})
|
||||
else:
|
||||
result = {"error": f"Unknown tool: {name}"}
|
||||
|
||||
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tool {name}: {e}")
|
||||
return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
|
||||
|
||||
async def _handle_remember(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle remember tool."""
|
||||
user_id = args["user_id"]
|
||||
project_id = args["project_id"]
|
||||
content = args["content"]
|
||||
metadata = args.get("metadata", {})
|
||||
importance = args.get("importance", 1.0)
|
||||
|
||||
# Ensure user's memory table exists
|
||||
self.db_client.create_memories_table(user_id)
|
||||
|
||||
# Generate embedding
|
||||
embedding = self.embedding_service.embed_text(content)[0]
|
||||
|
||||
# Store memory
|
||||
memory_id = str(uuid4())
|
||||
self.db_client.add_memory(
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
content=content,
|
||||
embedding=embedding,
|
||||
metadata=metadata,
|
||||
importance=importance,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"memory_id": memory_id,
|
||||
"message": "Memory stored successfully",
|
||||
}
|
||||
|
||||
async def _handle_recall(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle recall tool."""
|
||||
user_id = args["user_id"]
|
||||
project_id = args.get("project_id")
|
||||
query = args["query"]
|
||||
limit = args.get("limit", 10)
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = self.embedding_service.embed_text(query)[0]
|
||||
|
||||
# Search memories
|
||||
results_df = self.db_client.search_memories(
|
||||
user_id=user_id,
|
||||
query_embedding=query_embedding,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Convert results
|
||||
memories = []
|
||||
if not results_df.is_empty():
|
||||
for row in results_df.to_dicts():
|
||||
memories.append(
|
||||
{
|
||||
"memory_id": row["memory_id"],
|
||||
"content": row["content"],
|
||||
"metadata": json.loads(row.get("metadata", "{}")),
|
||||
"importance": row.get("importance", 1.0),
|
||||
"similarity_score": row.get("_similarity", 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"memories": memories,
|
||||
"count": len(memories),
|
||||
}
|
||||
|
||||
async def _handle_create_project(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle create project tool."""
|
||||
user_id = args["user_id"]
|
||||
name = args["name"]
|
||||
description = args.get("description", "")
|
||||
metadata = args.get("metadata", {})
|
||||
|
||||
# Create project
|
||||
project_id = str(uuid4())
|
||||
self.db_client.create_project(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project_id": project_id,
|
||||
"message": "Project created successfully",
|
||||
}
|
||||
|
||||
async def _handle_create_knowledge_base(
|
||||
self, args: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Handle create knowledge base tool."""
|
||||
user_id = args["user_id"]
|
||||
project_id = args["project_id"]
|
||||
name = args["name"]
|
||||
description = args.get("description", "")
|
||||
|
||||
# Create knowledge base
|
||||
kb_id = str(uuid4())
|
||||
self.db_client.create_knowledge_base(
|
||||
kb_id=kb_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"kb_id": kb_id,
|
||||
"message": "Knowledge base created successfully",
|
||||
}
|
||||
|
||||
async def _handle_add_fact(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle add fact tool."""
|
||||
kb_id = args["kb_id"]
|
||||
content = args["content"]
|
||||
parent_id = args.get("parent_id")
|
||||
metadata = args.get("metadata", {})
|
||||
|
||||
# Generate embedding
|
||||
embedding = self.embedding_service.embed_text(content)[0]
|
||||
|
||||
# Add fact
|
||||
fact_id = str(uuid4())
|
||||
self.db_client.add_fact(
|
||||
fact_id=fact_id,
|
||||
kb_id=kb_id,
|
||||
content=content,
|
||||
embedding=embedding,
|
||||
parent_id=parent_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"fact_id": fact_id,
|
||||
"message": "Fact added successfully",
|
||||
}
|
||||
|
||||
async def _handle_search_facts(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle search facts tool."""
|
||||
kb_id = args["kb_id"]
|
||||
query = args["query"]
|
||||
limit = args.get("limit", 10)
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = self.embedding_service.embed_text(query)[0]
|
||||
|
||||
# Search facts
|
||||
results_df = self.db_client.search_facts(
|
||||
kb_id=kb_id,
|
||||
query_embedding=query_embedding,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Convert results
|
||||
facts = []
|
||||
if not results_df.is_empty():
|
||||
for row in results_df.to_dicts():
|
||||
facts.append(
|
||||
{
|
||||
"fact_id": row["fact_id"],
|
||||
"content": row["content"],
|
||||
"parent_id": row.get("parent_id"),
|
||||
"metadata": json.loads(row.get("metadata", "{}")),
|
||||
"similarity_score": row.get("_similarity", 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"facts": facts,
|
||||
"count": len(facts),
|
||||
}
|
||||
|
||||
async def _handle_summarize_for_knowledge(
|
||||
self, args: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Handle summarize for knowledge tool."""
|
||||
content = args["content"]
|
||||
context = args.get("context")
|
||||
skip_summarization = args.get("skip_summarization", False)
|
||||
provided_summary = args.get("provided_summary")
|
||||
|
||||
# Use LLM service to generate knowledge instructions
|
||||
result = self.llm_service.summarize_for_knowledge(
|
||||
content=content,
|
||||
context=context,
|
||||
skip_summarization=skip_summarization,
|
||||
provided_summary=provided_summary,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the MCP server."""
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await self.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
InitializationOptions(
|
||||
server_name=settings.mcp_server_name,
|
||||
server_version=settings.mcp_server_version,
|
||||
capabilities=ServerCapabilities(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for MCP server."""
|
||||
import asyncio
|
||||
|
||||
server = MCPMemoryServer()
|
||||
asyncio.run(server.run())
|
||||
@@ -1,255 +0,0 @@
|
||||
"""Unified memory interface with backend selection.
|
||||
|
||||
This module provides a clean API for accessing different memory backends
|
||||
using a simple syntax like:
|
||||
memory['lancedb'] # Access LanceDB backend
|
||||
memory['kuzudb'] # Access KuzuDB backend
|
||||
memory['infinity'] # Access InfinityDB backend
|
||||
memory.local # Access local backend (default)
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Any
|
||||
from structlog import get_logger
|
||||
|
||||
from .db.factory import get_db_client, reset_db_client, list_available_backends
|
||||
from .db.base import BaseVectorDB
|
||||
from .services.memory import MemoryService
|
||||
from .services.embeddings import EmbeddingService
|
||||
from .services.llm import LLMService
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class MemoryBackendProxy:
|
||||
"""Proxy for accessing specific memory backends."""
|
||||
|
||||
def __init__(self, backend_name: str, config: Optional[Dict] = None):
|
||||
"""Initialize backend proxy.
|
||||
|
||||
Args:
|
||||
backend_name: Name of the backend to use
|
||||
config: Optional backend configuration
|
||||
"""
|
||||
self.backend_name = backend_name
|
||||
self.config = config or {}
|
||||
self._client = None
|
||||
self._service = None
|
||||
|
||||
@property
|
||||
def client(self) -> BaseVectorDB:
|
||||
"""Get the database client for this backend."""
|
||||
if self._client is None:
|
||||
reset_db_client()
|
||||
self._client = get_db_client(
|
||||
backend=self.backend_name,
|
||||
config=self.config
|
||||
)
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def service(self) -> MemoryService:
|
||||
"""Get the memory service for this backend."""
|
||||
if self._service is None:
|
||||
# Initialize services
|
||||
embeddings = EmbeddingService()
|
||||
llm = LLMService()
|
||||
|
||||
# Create service with this backend
|
||||
self._service = MemoryService()
|
||||
self._service.db = self.client
|
||||
self._service.embeddings = embeddings
|
||||
self._service.llm = llm
|
||||
|
||||
return self._service
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize the backend."""
|
||||
await self.client.initialize()
|
||||
logger.info(f"Initialized {self.backend_name} backend")
|
||||
|
||||
async def close(self):
|
||||
"""Close the backend connection."""
|
||||
if self._client:
|
||||
await self._client.close()
|
||||
logger.info(f"Closed {self.backend_name} backend")
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation."""
|
||||
return f"<MemoryBackend:{self.backend_name}>"
|
||||
|
||||
|
||||
class Memory:
|
||||
"""Main memory interface with backend selection.
|
||||
|
||||
Usage:
|
||||
# Using dictionary syntax
|
||||
memory['lancedb'] # Get LanceDB backend
|
||||
memory['kuzudb'] # Get KuzuDB backend
|
||||
memory['infinity'] # Get InfinityDB backend
|
||||
|
||||
# Using attribute syntax
|
||||
memory.lancedb # Get LanceDB backend
|
||||
memory.kuzudb # Get KuzuDB backend
|
||||
memory.infinity # Get InfinityDB backend
|
||||
memory.local # Get local backend (default)
|
||||
|
||||
# List available backends
|
||||
memory.backends() # Returns list of available backends
|
||||
|
||||
# Use a specific backend
|
||||
lance = memory['lancedb']
|
||||
await lance.initialize()
|
||||
await lance.service.create_memory(...)
|
||||
|
||||
# Or with context manager (auto initialize/close)
|
||||
async with memory.use('lancedb') as backend:
|
||||
await backend.service.create_memory(...)
|
||||
"""
|
||||
|
||||
def __init__(self, default_backend: str = "local"):
|
||||
"""Initialize memory interface.
|
||||
|
||||
Args:
|
||||
default_backend: Default backend to use
|
||||
"""
|
||||
self.default_backend = default_backend
|
||||
self._backends: Dict[str, MemoryBackendProxy] = {}
|
||||
|
||||
def __getitem__(self, backend_name: str) -> MemoryBackendProxy:
|
||||
"""Get a backend using dictionary syntax.
|
||||
|
||||
Args:
|
||||
backend_name: Name of the backend
|
||||
|
||||
Returns:
|
||||
Backend proxy instance
|
||||
"""
|
||||
if backend_name not in self._backends:
|
||||
self._backends[backend_name] = MemoryBackendProxy(backend_name)
|
||||
return self._backends[backend_name]
|
||||
|
||||
def __getattr__(self, backend_name: str) -> MemoryBackendProxy:
|
||||
"""Get a backend using attribute syntax.
|
||||
|
||||
Args:
|
||||
backend_name: Name of the backend
|
||||
|
||||
Returns:
|
||||
Backend proxy instance
|
||||
"""
|
||||
# Handle special attributes
|
||||
if backend_name.startswith('_'):
|
||||
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{backend_name}'")
|
||||
|
||||
# Convert attribute name to backend name (e.g., 'infinity_db' -> 'infinitydb')
|
||||
backend_name = backend_name.replace('_', '')
|
||||
|
||||
return self[backend_name]
|
||||
|
||||
def backends(self) -> Dict[str, Dict]:
|
||||
"""List all available backends.
|
||||
|
||||
Returns:
|
||||
Dictionary of backend names to their info
|
||||
"""
|
||||
return list_available_backends()
|
||||
|
||||
def use(self, backend_name: str, config: Optional[Dict] = None):
|
||||
"""Context manager for using a specific backend.
|
||||
|
||||
Args:
|
||||
backend_name: Name of the backend
|
||||
config: Optional backend configuration
|
||||
|
||||
Returns:
|
||||
Async context manager for the backend
|
||||
"""
|
||||
return MemoryBackendContext(backend_name, config)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation."""
|
||||
backends = list(self.backends().keys())
|
||||
return f"<Memory backends={backends}>"
|
||||
|
||||
|
||||
class MemoryBackendContext:
|
||||
"""Async context manager for using a memory backend."""
|
||||
|
||||
def __init__(self, backend_name: str, config: Optional[Dict] = None):
|
||||
"""Initialize context.
|
||||
|
||||
Args:
|
||||
backend_name: Name of the backend
|
||||
config: Optional backend configuration
|
||||
"""
|
||||
self.backend = MemoryBackendProxy(backend_name, config)
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Enter context - initialize backend."""
|
||||
await self.backend.initialize()
|
||||
return self.backend
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit context - close backend."""
|
||||
await self.backend.close()
|
||||
|
||||
|
||||
# Global memory instance
|
||||
memory = Memory()
|
||||
|
||||
|
||||
# Convenience functions for common operations
|
||||
async def remember(content: str, backend: str = "local", **kwargs):
|
||||
"""Quick function to remember something.
|
||||
|
||||
Args:
|
||||
content: Content to remember
|
||||
backend: Backend to use (default: local)
|
||||
**kwargs: Additional parameters for memory creation
|
||||
|
||||
Returns:
|
||||
Created memory object
|
||||
"""
|
||||
async with memory.use(backend) as mem:
|
||||
from .models.memory import MemoryCreate
|
||||
|
||||
memory_obj = MemoryCreate(
|
||||
content=content,
|
||||
**kwargs
|
||||
)
|
||||
return await mem.service.create_memory(
|
||||
memory=memory_obj,
|
||||
project_id=kwargs.get('project_id', 'default'),
|
||||
user_id=kwargs.get('user_id', 'default')
|
||||
)
|
||||
|
||||
|
||||
async def recall(query: str, backend: str = "local", **kwargs):
|
||||
"""Quick function to recall memories.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
backend: Backend to use (default: local)
|
||||
**kwargs: Additional search parameters
|
||||
|
||||
Returns:
|
||||
List of matching memories
|
||||
"""
|
||||
async with memory.use(backend) as mem:
|
||||
return await mem.service.search_memories(
|
||||
query=query,
|
||||
user_id=kwargs.get('user_id', 'default'),
|
||||
project_id=kwargs.get('project_id', 'default'),
|
||||
limit=kwargs.get('limit', 10)
|
||||
)
|
||||
|
||||
|
||||
# Export main components
|
||||
__all__ = [
|
||||
'Memory',
|
||||
'memory',
|
||||
'remember',
|
||||
'recall',
|
||||
'MemoryBackendProxy',
|
||||
'MemoryBackendContext',
|
||||
]
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Models package."""
|
||||
|
||||
from .base import ProjectScopedModel, TimestampedModel, UserScopedModel
|
||||
from .chat import (
|
||||
ChatHistoryRequest,
|
||||
ChatMessage,
|
||||
ChatMessageBase,
|
||||
ChatMessageCreate,
|
||||
ChatSearchRequest,
|
||||
ChatSession,
|
||||
ChatSessionCreate,
|
||||
ChatSessionList,
|
||||
)
|
||||
from .knowledge import (
|
||||
AddKnowledgeRequest,
|
||||
CreateKnowledgeBaseRequest,
|
||||
DeleteKnowledgeRequest,
|
||||
Fact,
|
||||
FactBase,
|
||||
FactCreate,
|
||||
FactRelation,
|
||||
FactUpdate,
|
||||
FactWithScore,
|
||||
GetKnowledgeRequest,
|
||||
IngestKnowledgeRequest,
|
||||
KnowledgeBase,
|
||||
KnowledgeBaseBase,
|
||||
KnowledgeBaseCreate,
|
||||
KnowledgeBaseUpdate,
|
||||
ListKnowledgeBasesRequest,
|
||||
)
|
||||
from .memory import (
|
||||
AddMemoriesRequest,
|
||||
DeleteMemoryRequest,
|
||||
DeleteUserRequest,
|
||||
GetMemoriesRequest,
|
||||
Memory,
|
||||
MemoryBase,
|
||||
MemoryCreate,
|
||||
MemoryListResponse,
|
||||
MemoryResponse,
|
||||
MemoryUpdate,
|
||||
MemoryWithScore,
|
||||
RememberRequest,
|
||||
UpdateMemoryRequest,
|
||||
)
|
||||
from .project import (
|
||||
Project,
|
||||
ProjectBase,
|
||||
ProjectCreate,
|
||||
ProjectList,
|
||||
ProjectUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"TimestampedModel",
|
||||
"UserScopedModel",
|
||||
"ProjectScopedModel",
|
||||
# Project
|
||||
"Project",
|
||||
"ProjectBase",
|
||||
"ProjectCreate",
|
||||
"ProjectUpdate",
|
||||
"ProjectList",
|
||||
# Memory
|
||||
"Memory",
|
||||
"MemoryBase",
|
||||
"MemoryCreate",
|
||||
"MemoryUpdate",
|
||||
"MemoryWithScore",
|
||||
"MemoryResponse",
|
||||
"MemoryListResponse",
|
||||
"RememberRequest",
|
||||
"AddMemoriesRequest",
|
||||
"GetMemoriesRequest",
|
||||
"DeleteMemoryRequest",
|
||||
"DeleteUserRequest",
|
||||
"UpdateMemoryRequest",
|
||||
# Knowledge
|
||||
"KnowledgeBase",
|
||||
"KnowledgeBaseBase",
|
||||
"KnowledgeBaseCreate",
|
||||
"KnowledgeBaseUpdate",
|
||||
"Fact",
|
||||
"FactBase",
|
||||
"FactCreate",
|
||||
"FactUpdate",
|
||||
"FactWithScore",
|
||||
"FactRelation",
|
||||
"CreateKnowledgeBaseRequest",
|
||||
"ListKnowledgeBasesRequest",
|
||||
"AddKnowledgeRequest",
|
||||
"GetKnowledgeRequest",
|
||||
"DeleteKnowledgeRequest",
|
||||
"IngestKnowledgeRequest",
|
||||
# Chat
|
||||
"ChatMessage",
|
||||
"ChatMessageBase",
|
||||
"ChatMessageCreate",
|
||||
"ChatSession",
|
||||
"ChatSessionCreate",
|
||||
"ChatSessionList",
|
||||
"ChatHistoryRequest",
|
||||
"ChatSearchRequest",
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Base models for Hanzo Memory Service."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TimestampedModel(BaseModel):
|
||||
"""Base model with timestamps."""
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class UserScopedModel(BaseModel):
|
||||
"""Base model for user-scoped resources."""
|
||||
|
||||
user_id: str = Field(..., description="User ID")
|
||||
|
||||
|
||||
class ProjectScopedModel(UserScopedModel):
|
||||
"""Base model for project-scoped resources."""
|
||||
|
||||
project_id: str = Field(..., description="Project ID")
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Chat models."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import ProjectScopedModel
|
||||
|
||||
|
||||
class ChatMessageBase(BaseModel):
|
||||
"""Base chat message model."""
|
||||
|
||||
role: str = Field(..., description="Message role (user, assistant, system)")
|
||||
content: str = Field(..., description="Message content")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Additional metadata"
|
||||
)
|
||||
|
||||
|
||||
class ChatMessageCreate(ChatMessageBase):
|
||||
"""Model for creating a chat message."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
session_id: str = Field(..., description="Chat session ID")
|
||||
project_id: str | None = Field(None, description="Project ID")
|
||||
|
||||
|
||||
class ChatMessage(ChatMessageBase, ProjectScopedModel):
|
||||
"""Complete chat message model."""
|
||||
|
||||
chat_id: str = Field(..., description="Chat message ID")
|
||||
session_id: str = Field(..., description="Chat session ID")
|
||||
embedding: list[float] | None = Field(None, description="Message embedding")
|
||||
created_at: str = Field(..., description="Creation timestamp")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ChatSessionCreate(BaseModel):
|
||||
"""Model for creating a chat session."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
session_id: str | None = Field(None, description="Custom session ID")
|
||||
project_id: str | None = Field(None, description="Project ID")
|
||||
title: str | None = Field(None, description="Session title")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Session metadata"
|
||||
)
|
||||
|
||||
|
||||
class ChatSession(BaseModel):
|
||||
"""Chat session model."""
|
||||
|
||||
session_id: str = Field(..., description="Session ID")
|
||||
user_id: str = Field(..., description="User ID")
|
||||
project_id: str = Field(..., description="Project ID")
|
||||
title: str | None = Field(None, description="Session title")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Session metadata"
|
||||
)
|
||||
created_at: str = Field(..., description="Creation timestamp")
|
||||
updated_at: str = Field(..., description="Last update timestamp")
|
||||
message_count: int = Field(0, description="Number of messages in session")
|
||||
|
||||
|
||||
class ChatHistoryRequest(BaseModel):
|
||||
"""Request model for chat history."""
|
||||
|
||||
user_id: str = Field(..., description="User ID")
|
||||
session_id: str = Field(..., description="Session ID")
|
||||
limit: int = Field(100, ge=1, le=1000, description="Max messages to return")
|
||||
offset: int = Field(0, ge=0, description="Offset for pagination")
|
||||
|
||||
|
||||
class ChatSearchRequest(BaseModel):
|
||||
"""Request model for chat search."""
|
||||
|
||||
user_id: str = Field(..., description="User ID")
|
||||
query: str = Field(..., description="Search query")
|
||||
project_id: str | None = Field(None, description="Filter by project ID")
|
||||
session_id: str | None = Field(None, description="Filter by session ID")
|
||||
limit: int = Field(10, ge=1, le=100, description="Max results to return")
|
||||
|
||||
|
||||
class ChatSessionList(BaseModel):
|
||||
"""Chat session list response."""
|
||||
|
||||
sessions: list[ChatSession] = Field(..., description="List of chat sessions")
|
||||
total: int = Field(..., description="Total number of sessions")
|
||||
page: int = Field(1, description="Current page")
|
||||
per_page: int = Field(50, description="Items per page")
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Knowledge base and fact models."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import ProjectScopedModel, TimestampedModel
|
||||
|
||||
|
||||
class KnowledgeBaseBase(BaseModel):
|
||||
"""Base knowledge base model."""
|
||||
|
||||
name: str = Field(..., description="Knowledge base name")
|
||||
description: str = Field("", description="Knowledge base description")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Additional metadata"
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeBaseCreate(KnowledgeBaseBase):
|
||||
"""Model for creating a knowledge base."""
|
||||
|
||||
kb_id: str | None = Field(None, description="Custom knowledge base ID")
|
||||
|
||||
|
||||
class KnowledgeBaseUpdate(BaseModel):
|
||||
"""Model for updating a knowledge base."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class KnowledgeBase(KnowledgeBaseBase, ProjectScopedModel, TimestampedModel):
|
||||
"""Complete knowledge base model."""
|
||||
|
||||
kb_id: str = Field(..., description="Knowledge base ID")
|
||||
fact_count: int = Field(0, description="Number of facts in knowledge base")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FactBase(BaseModel):
|
||||
"""Base fact model."""
|
||||
|
||||
content: str = Field(..., description="Fact content")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Additional metadata"
|
||||
)
|
||||
parent_id: str | None = Field(None, description="Parent fact ID")
|
||||
|
||||
|
||||
class FactCreate(FactBase):
|
||||
"""Model for creating a fact."""
|
||||
|
||||
fact_id: str | None = Field(None, description="Custom fact ID")
|
||||
|
||||
|
||||
class FactUpdate(BaseModel):
|
||||
"""Model for updating a fact."""
|
||||
|
||||
content: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class Fact(FactBase, TimestampedModel):
|
||||
"""Complete fact model."""
|
||||
|
||||
fact_id: str = Field(..., description="Fact ID")
|
||||
kb_id: str = Field(..., description="Knowledge base ID")
|
||||
embedding: list[float] | None = Field(None, description="Embedding vector")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FactWithScore(Fact):
|
||||
"""Fact with similarity score."""
|
||||
|
||||
similarity_score: float = Field(..., description="Similarity score")
|
||||
|
||||
|
||||
class FactRelation(BaseModel):
|
||||
"""Fact relation model."""
|
||||
|
||||
parent_fact_id: str = Field(..., description="Parent fact ID")
|
||||
child_fact_id: str = Field(..., description="Child fact ID")
|
||||
relation_type: str = Field("child", description="Relation type")
|
||||
|
||||
|
||||
# Knowledge API Request Models
|
||||
|
||||
|
||||
class CreateKnowledgeBaseRequest(BaseModel):
|
||||
"""Request model for creating a knowledge base."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
name: str = Field(..., description="Knowledge base name")
|
||||
kb_id: str | None = Field(None, description="Custom KB ID")
|
||||
|
||||
|
||||
class ListKnowledgeBasesRequest(BaseModel):
|
||||
"""Request model for listing knowledge bases."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
|
||||
|
||||
class AddKnowledgeRequest(BaseModel):
|
||||
"""Request model for adding facts to a knowledge base."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
kb_id: str = Field(..., description="Knowledge base ID")
|
||||
facts: list[dict[str, Any]] = Field(..., description="Facts to add")
|
||||
|
||||
|
||||
class GetKnowledgeRequest(BaseModel):
|
||||
"""Request model for retrieving facts."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
kb_id: str = Field(..., description="Knowledge base ID")
|
||||
fact_id: str | None = Field(None, description="Specific fact ID")
|
||||
subtree: bool = Field(False, description="Include subtree")
|
||||
query: str | None = Field(None, description="Search query")
|
||||
limit: int = Field(50, ge=1, le=1000, description="Max facts to return")
|
||||
|
||||
|
||||
class DeleteKnowledgeRequest(BaseModel):
|
||||
"""Request model for deleting facts."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
kb_id: str = Field(..., description="Knowledge base ID")
|
||||
fact_id: str = Field(..., description="Fact ID to delete")
|
||||
cascade: bool = Field(False, description="Delete descendants")
|
||||
|
||||
|
||||
class IngestKnowledgeRequest(BaseModel):
|
||||
"""Request model for ingesting knowledge from GCS."""
|
||||
|
||||
userid: str = Field(..., description="User ID")
|
||||
kb_id: str = Field(..., description="Knowledge base ID")
|
||||
details: dict[str, str] = Field(..., description="Must include bucketUri")
|
||||
projecttags: list[str] = Field(default_factory=list, description="Tags for facts")
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Memory models."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import ProjectScopedModel, TimestampedModel
|
||||
|
||||
|
||||
class MemoryBase(BaseModel):
|
||||
"""Base memory model."""
|
||||
|
||||
content: str = Field(..., description="Memory content")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Additional metadata"
|
||||
)
|
||||
importance: float = Field(1.0, ge=0.0, le=10.0, description="Importance score")
|
||||
|
||||
|
||||
class MemoryCreate(MemoryBase):
|
||||
"""Model for creating a memory."""
|
||||
|
||||
additional_context: str | None = Field(
|
||||
None, description="Additional context for the memory"
|
||||
)
|
||||
strip_pii: bool = Field(False, description="Strip PII from content")
|
||||
memory_type: str | None = Field(None, description="Type of memory")
|
||||
context: dict[str, Any] | None = Field(None, description="Context information")
|
||||
source: str | None = Field(None, description="Source of the memory")
|
||||
embedding: list[float] | None = Field(None, description="Embedding vector")
|
||||
|
||||
|
||||
class MemoryUpdate(BaseModel):
|
||||
"""Model for updating a memory."""
|
||||
|
||||
content: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
importance: float | None = None
|
||||
|
||||
|
||||
class Memory(MemoryBase, ProjectScopedModel, TimestampedModel):
|
||||
"""Complete memory model."""
|
||||
|
||||
memory_id: str | None = Field(None, description="Memory ID")
|
||||
id: str | None = Field(None, description="Memory ID alias")
|
||||
embedding: list[float] | None = Field(None, description="Embedding vector")
|
||||
memory_type: str | None = Field(None, description="Type of memory")
|
||||
context: dict[str, Any] | None = Field(None, description="Context information")
|
||||
source: str | None = Field(None, description="Source of the memory")
|
||||
timestamp: str | None = Field(None, description="Timestamp")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class MemoryWithScore(Memory):
|
||||
"""Memory with similarity score."""
|
||||
|
||||
similarity_score: float = Field(..., description="Similarity score")
|
||||
|
||||
|
||||
class MemoryResponse(BaseModel):
|
||||
"""Memory response model with search results."""
|
||||
|
||||
memory: Memory = Field(..., description="Memory object")
|
||||
similarity: float | None = Field(None, description="Similarity score")
|
||||
relevance_score: float | None = Field(None, description="Relevance score")
|
||||
|
||||
# Legacy fields for compatibility
|
||||
user_id: str | None = Field(None, description="User ID")
|
||||
relevant_memories: list[str | dict[str, str]] | None = Field(
|
||||
None, description="Relevant memories"
|
||||
)
|
||||
memory_stored: bool | None = Field(None, description="Whether the memory was stored")
|
||||
usage_info: dict[str, int] | None = Field(None, description="Usage information")
|
||||
|
||||
|
||||
class MemoryListResponse(BaseModel):
|
||||
"""Memory list response."""
|
||||
|
||||
user_id: str = Field(..., description="User ID")
|
||||
memories: list[Memory] = Field(..., description="List of memories")
|
||||
pagination: dict[str, Any] = Field(..., description="Pagination info")
|
||||
usage_info: dict[str, int] = Field(..., description="Usage information")
|
||||
|
||||
|
||||
class RememberRequest(BaseModel):
|
||||
"""Request model for /v1/remember endpoint."""
|
||||
|
||||
apikey: str | None = Field(None, description="API key")
|
||||
userid: str = Field(..., description="User ID")
|
||||
messagecontent: str = Field(..., description="Message content")
|
||||
additionalcontext: str | None = Field(None, description="Additional context")
|
||||
strippii: bool = Field(False, description="Strip PII")
|
||||
filterresults: bool = Field(False, description="Filter results with LLM")
|
||||
includememoryid: bool = Field(False, description="Include memory IDs in response")
|
||||
|
||||
|
||||
class AddMemoriesRequest(BaseModel):
|
||||
"""Request model for /v1/memories/add endpoint."""
|
||||
|
||||
apikey: str | None = Field(None, description="API key")
|
||||
userid: str = Field(..., description="User ID")
|
||||
memoriestoadd: str | list[str] = Field(..., description="Memories to add")
|
||||
|
||||
|
||||
class GetMemoriesRequest(BaseModel):
|
||||
"""Request model for /v1/memories/get endpoint."""
|
||||
|
||||
apikey: str | None = Field(None, description="API key")
|
||||
userid: str = Field(..., description="User ID")
|
||||
memoryid: str | None = Field(None, description="Specific memory ID")
|
||||
limit: int = Field(50, ge=1, le=1000, description="Max memories to return")
|
||||
startafter: str | None = Field(None, description="Memory ID to start after")
|
||||
|
||||
|
||||
class DeleteMemoryRequest(BaseModel):
|
||||
"""Request model for /v1/memories/delete endpoint."""
|
||||
|
||||
apikey: str | None = Field(None, description="API key")
|
||||
userid: str = Field(..., description="User ID")
|
||||
memoryid: str = Field(..., description="Memory ID to delete")
|
||||
|
||||
|
||||
class UpdateMemoryRequest(BaseModel):
|
||||
"""Request model for /v1/memories/update endpoint."""
|
||||
|
||||
apikey: str | None = Field(None, description="API key")
|
||||
userid: str = Field(..., description="User ID")
|
||||
memoryid: str = Field(..., description="Memory ID to update")
|
||||
projectid: str = Field(..., description="Project ID")
|
||||
content: str | None = Field(None, description="New content")
|
||||
importance: float | None = Field(None, ge=0.0, le=10.0, description="New importance score")
|
||||
metadata: dict[str, Any] | None = Field(None, description="New metadata")
|
||||
|
||||
|
||||
class DeleteUserRequest(BaseModel):
|
||||
"""Request model for /v1/user/delete endpoint."""
|
||||
|
||||
apikey: str | None = Field(None, description="API key")
|
||||
userid: str = Field(..., description="User ID")
|
||||
confirmdelete: bool = Field(..., description="Confirm deletion")
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Project models."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import TimestampedModel, UserScopedModel
|
||||
|
||||
|
||||
class ProjectBase(BaseModel):
|
||||
"""Base project model."""
|
||||
|
||||
name: str = Field(..., description="Project name")
|
||||
description: str = Field("", description="Project description")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Additional metadata"
|
||||
)
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
"""Model for creating a project."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
"""Model for updating a project."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class Project(ProjectBase, UserScopedModel, TimestampedModel):
|
||||
"""Complete project model."""
|
||||
|
||||
project_id: str = Field(..., description="Project ID")
|
||||
knowledge_base_ids: list[str] = Field(
|
||||
default_factory=list, description="Associated knowledge base IDs"
|
||||
)
|
||||
memory_count: int = Field(0, description="Number of memories in project")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProjectList(BaseModel):
|
||||
"""Project list response."""
|
||||
|
||||
projects: list[Project] = Field(..., description="List of projects")
|
||||
total: int = Field(..., description="Total number of projects")
|
||||
page: int = Field(1, description="Current page")
|
||||
per_page: int = Field(50, description="Items per page")
|
||||
@@ -1,780 +0,0 @@
|
||||
"""FastAPI server for Hanzo Memory Service."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from structlog import get_logger
|
||||
|
||||
from .api.auth import get_or_verify_user_id, require_auth, security
|
||||
from .config import settings
|
||||
from .db import get_db_client
|
||||
from .models import (
|
||||
AddKnowledgeRequest,
|
||||
AddMemoriesRequest,
|
||||
ChatMessageCreate,
|
||||
ChatSessionCreate,
|
||||
CreateKnowledgeBaseRequest,
|
||||
DeleteKnowledgeRequest,
|
||||
DeleteMemoryRequest,
|
||||
DeleteUserRequest,
|
||||
GetKnowledgeRequest,
|
||||
GetMemoriesRequest,
|
||||
MemoryListResponse,
|
||||
MemoryResponse,
|
||||
RememberRequest,
|
||||
UpdateMemoryRequest,
|
||||
)
|
||||
from .services import get_embedding_service, get_memory_service
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Global service instances
|
||||
db_client = None
|
||||
embedding_service = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan manager."""
|
||||
global db_client, embedding_service
|
||||
|
||||
# Startup
|
||||
logger.info("Starting Hanzo Memory Service")
|
||||
settings.ensure_paths()
|
||||
|
||||
# Initialize services
|
||||
db_client = get_db_client()
|
||||
embedding_service = get_embedding_service()
|
||||
if db_client:
|
||||
db_client.create_projects_table()
|
||||
db_client.create_knowledge_bases_table()
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down Hanzo Memory Service")
|
||||
if db_client:
|
||||
db_client.close()
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Hanzo Memory Service",
|
||||
description="AI memory and knowledge management service",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure based on your needs
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "hanzo-memory",
|
||||
"version": "0.1.0",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/remember")
|
||||
async def remember(
|
||||
request: RememberRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> MemoryResponse:
|
||||
"""
|
||||
Retrieve relevant memories and store new memory.
|
||||
|
||||
This endpoint:
|
||||
1. Searches for relevant memories based on the message content
|
||||
2. Optionally filters results using LLM
|
||||
3. Stores the incoming message as a new memory
|
||||
4. Returns relevant memories
|
||||
"""
|
||||
# Check auth
|
||||
request.apikey or require_auth(req, credentials)
|
||||
|
||||
memory_service = get_memory_service()
|
||||
|
||||
# Get or create default project for user
|
||||
project_id = f"project_{request.userid}_default"
|
||||
|
||||
# Search for relevant memories
|
||||
memories = memory_service.search_memories(
|
||||
user_id=request.userid,
|
||||
query=request.messagecontent,
|
||||
project_id=project_id,
|
||||
limit=10,
|
||||
filter_with_llm=request.filterresults,
|
||||
additional_context=request.additionalcontext,
|
||||
)
|
||||
|
||||
# Store the new memory
|
||||
memory_service.create_memory(
|
||||
user_id=request.userid,
|
||||
project_id=project_id,
|
||||
content=request.messagecontent,
|
||||
metadata={
|
||||
"additional_context": request.additionalcontext,
|
||||
},
|
||||
strip_pii=request.strippii,
|
||||
)
|
||||
|
||||
# Format response
|
||||
relevant_memories: list[str | dict[str, str]]
|
||||
if request.includememoryid:
|
||||
relevant_memories = [
|
||||
{"content": m.content, "memoryId": m.memory_id} for m in memories
|
||||
]
|
||||
else:
|
||||
relevant_memories = [m.content for m in memories]
|
||||
|
||||
# Get usage info
|
||||
# TODO: Implement proper usage tracking
|
||||
usage_info = {
|
||||
"current": len(memories),
|
||||
"limit": settings.max_memories_per_user,
|
||||
}
|
||||
|
||||
return MemoryResponse(
|
||||
user_id=request.userid,
|
||||
relevant_memories=relevant_memories,
|
||||
memory_stored=True,
|
||||
usage_info=usage_info,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v1/memories/add")
|
||||
async def add_memories(
|
||||
request: AddMemoriesRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Add explicit memories without importance analysis."""
|
||||
# Check auth
|
||||
request.apikey or require_auth(req, credentials)
|
||||
|
||||
memory_service = get_memory_service()
|
||||
|
||||
# Get or create default project
|
||||
project_id = f"project_{request.userid}_default"
|
||||
|
||||
# Normalize memories to list
|
||||
memories_to_add = (
|
||||
[request.memoriestoadd]
|
||||
if isinstance(request.memoriestoadd, str)
|
||||
else request.memoriestoadd
|
||||
)
|
||||
|
||||
# Add memories
|
||||
memory_ids = []
|
||||
for content in memories_to_add:
|
||||
memory = memory_service.create_memory(
|
||||
user_id=request.userid,
|
||||
project_id=project_id,
|
||||
content=content,
|
||||
importance=5.0, # Default importance for explicit adds
|
||||
)
|
||||
memory_ids.append(memory.memory_id)
|
||||
|
||||
return {
|
||||
"userid": request.userid,
|
||||
"added_count": len(memory_ids),
|
||||
"memory_ids": memory_ids,
|
||||
"usage_info": {
|
||||
"current": len(memory_ids),
|
||||
"limit": settings.max_memories_per_user,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/memories/get")
|
||||
async def get_memories(
|
||||
request: GetMemoriesRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> MemoryListResponse:
|
||||
"""Retrieve stored memories."""
|
||||
# Check auth
|
||||
request.apikey or require_auth(req, credentials)
|
||||
|
||||
memory_service = get_memory_service()
|
||||
|
||||
# If specific memory ID requested
|
||||
if request.memoryid:
|
||||
memory = memory_service.get_memory(request.userid, request.memoryid)
|
||||
if not memory:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memory not found",
|
||||
)
|
||||
memories = [memory]
|
||||
else:
|
||||
# Get paginated list
|
||||
# TODO: Implement proper pagination
|
||||
memories = []
|
||||
|
||||
return MemoryListResponse(
|
||||
user_id=request.userid,
|
||||
memories=memories,
|
||||
pagination={
|
||||
"has_more": False,
|
||||
"last_id": memories[-1].memory_id if memories else None,
|
||||
},
|
||||
usage_info={
|
||||
"current": len(memories),
|
||||
"limit": settings.max_memories_per_user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v1/memories/update")
|
||||
async def update_memory_endpoint(
|
||||
request: UpdateMemoryRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Update a specific memory."""
|
||||
# Check auth
|
||||
request.apikey or require_auth(req, credentials)
|
||||
|
||||
memory_service = get_memory_service()
|
||||
|
||||
# Prepare update parameters
|
||||
update_params = {}
|
||||
if request.content is not None:
|
||||
update_params["content"] = request.content
|
||||
if request.importance is not None:
|
||||
update_params["importance"] = request.importance
|
||||
if request.metadata is not None:
|
||||
update_params["metadata"] = request.metadata
|
||||
|
||||
# Perform the update
|
||||
updated_memory = memory_service.update_memory(
|
||||
user_id=request.userid,
|
||||
memory_id=request.memoryid,
|
||||
project_id=request.projectid,
|
||||
**update_params
|
||||
)
|
||||
|
||||
if not updated_memory:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memory not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Memory updated successfully",
|
||||
"memory_id": request.memoryid,
|
||||
"userid": request.userid,
|
||||
"updated_fields": list(update_params.keys()),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/memories/delete")
|
||||
async def delete_memory(
|
||||
request: DeleteMemoryRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Delete a specific memory."""
|
||||
# Check auth
|
||||
request.apikey or require_auth(req, credentials)
|
||||
|
||||
memory_service = get_memory_service()
|
||||
|
||||
success = memory_service.delete_memory(request.userid, request.memoryid)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Memory not found",
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Memory deleted successfully",
|
||||
"memory_id": request.memoryid,
|
||||
"userid": request.userid,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/user/delete")
|
||||
async def delete_user(
|
||||
request: DeleteUserRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Delete all memories for a user."""
|
||||
# Check auth
|
||||
request.apikey or require_auth(req, credentials)
|
||||
|
||||
if not request.confirmdelete:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="confirmdelete must be true",
|
||||
)
|
||||
|
||||
memory_service = get_memory_service()
|
||||
|
||||
deleted_count = memory_service.delete_user_memories(request.userid)
|
||||
|
||||
return {
|
||||
"message": "All user memories deleted",
|
||||
"userid": request.userid,
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
|
||||
|
||||
# Knowledge Base Management Endpoints
|
||||
|
||||
|
||||
@app.post("/v1/kb/create")
|
||||
async def create_knowledge_base(
|
||||
request: CreateKnowledgeBaseRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Create a new knowledge base."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
user_id = await get_or_verify_user_id(request.userid, credentials, req)
|
||||
|
||||
# Get default project if not specified
|
||||
project_id = getattr(request, "project_id", None)
|
||||
if not project_id:
|
||||
# Create or get default project for user
|
||||
project_id = f"default-{user_id}"
|
||||
try:
|
||||
if db_client:
|
||||
db_client.create_project(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
name="Default Project",
|
||||
description="Automatically created default project",
|
||||
)
|
||||
except Exception:
|
||||
pass # Project may already exist
|
||||
|
||||
# Create knowledge base
|
||||
kb_id = request.kb_id or str(uuid.uuid4())
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
db_client.create_knowledge_base(
|
||||
kb_id=kb_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
name=request.name,
|
||||
description=getattr(request, "description", ""),
|
||||
)
|
||||
|
||||
return {
|
||||
"kb_id": kb_id,
|
||||
"message": f"Knowledge base '{request.name}' created successfully",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating knowledge base: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/v1/kb/list")
|
||||
async def list_knowledge_bases(
|
||||
req: Request,
|
||||
userid: str = Query(...),
|
||||
project_id: str | None = Query(None),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""List knowledge bases for a user."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
user_id = await get_or_verify_user_id(userid, credentials, req)
|
||||
|
||||
# Query knowledge bases from the database
|
||||
# For now, return a simple response
|
||||
# TODO: Implement actual listing from InfinityDB
|
||||
|
||||
return {
|
||||
"userid": user_id,
|
||||
"knowledge_bases": [],
|
||||
"total": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing knowledge bases: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/v1/kb/facts/add")
|
||||
async def add_facts(
|
||||
request: AddKnowledgeRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Add facts to a knowledge base."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
await get_or_verify_user_id(request.userid, credentials, req)
|
||||
|
||||
# Process each fact
|
||||
added_facts = []
|
||||
for fact_data in request.facts:
|
||||
# Generate embedding for fact content
|
||||
content = fact_data.get("content", "")
|
||||
if not embedding_service:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Embedding service not initialized"
|
||||
)
|
||||
embedding = embedding_service.embed_text(content)[0]
|
||||
|
||||
# Add fact to database
|
||||
fact_id = fact_data.get("fact_id") or str(uuid.uuid4())
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
db_client.add_fact(
|
||||
fact_id=fact_id,
|
||||
kb_id=request.kb_id,
|
||||
content=content,
|
||||
embedding=embedding,
|
||||
parent_id=fact_data.get("parent_id"),
|
||||
metadata=fact_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
added_facts.append(
|
||||
{
|
||||
"fact_id": fact_id,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"kb_id": request.kb_id,
|
||||
"facts_added": len(added_facts),
|
||||
"facts": added_facts,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding facts: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/v1/kb/facts/get")
|
||||
async def get_facts(
|
||||
request: GetKnowledgeRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Get facts from a knowledge base."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
await get_or_verify_user_id(request.userid, credentials, req)
|
||||
|
||||
# Search facts if query provided
|
||||
if request.query:
|
||||
# Generate query embedding
|
||||
if not embedding_service:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Embedding service not initialized"
|
||||
)
|
||||
query_embedding = embedding_service.embed_text(request.query)[0]
|
||||
|
||||
# Search facts
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
results_df = db_client.search_facts(
|
||||
kb_id=request.kb_id,
|
||||
query_embedding=query_embedding,
|
||||
limit=request.limit,
|
||||
parent_id=request.fact_id if request.subtree else None,
|
||||
)
|
||||
|
||||
# Convert results
|
||||
facts = []
|
||||
if not results_df.is_empty():
|
||||
for row in results_df.to_dicts():
|
||||
facts.append(
|
||||
{
|
||||
"fact_id": row["fact_id"],
|
||||
"content": row["content"],
|
||||
"parent_id": row.get("parent_id"),
|
||||
"metadata": json.loads(row.get("metadata", "{}")),
|
||||
"similarity_score": row.get("_similarity", 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"kb_id": request.kb_id,
|
||||
"facts": facts,
|
||||
"total": len(facts),
|
||||
}
|
||||
else:
|
||||
# TODO: Implement listing all facts or specific fact retrieval
|
||||
return {
|
||||
"kb_id": request.kb_id,
|
||||
"facts": [],
|
||||
"total": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting facts: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/v1/kb/facts/delete")
|
||||
async def delete_fact(
|
||||
request: DeleteKnowledgeRequest,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Delete a fact from a knowledge base."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
await get_or_verify_user_id(request.userid, credentials, req)
|
||||
|
||||
# TODO: Implement fact deletion in InfinityDB
|
||||
# For now, return success
|
||||
|
||||
return {
|
||||
"kb_id": request.kb_id,
|
||||
"fact_id": request.fact_id,
|
||||
"deleted": True,
|
||||
"cascade": request.cascade,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting fact: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
# Chat Management Endpoints
|
||||
|
||||
|
||||
@app.post("/v1/chat/sessions/create")
|
||||
async def create_chat_session(
|
||||
request: ChatSessionCreate,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Create a new chat session."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
user_id = await get_or_verify_user_id(request.userid, credentials, req)
|
||||
|
||||
# Create session ID
|
||||
session_id = request.session_id or str(uuid.uuid4())
|
||||
|
||||
# Get or create default project
|
||||
project_id = request.project_id or f"default-{user_id}"
|
||||
|
||||
# Ensure user's chat table exists
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
db_client.create_chats_table(user_id)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"userid": user_id,
|
||||
"project_id": project_id,
|
||||
"created": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating chat session: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/v1/chat/messages/add")
|
||||
async def add_chat_message(
|
||||
request: ChatMessageCreate,
|
||||
req: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Add a message to a chat session."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
user_id = await get_or_verify_user_id(request.userid, credentials, req)
|
||||
|
||||
# Generate embedding for message content
|
||||
if not embedding_service:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Embedding service not initialized"
|
||||
)
|
||||
embedding = embedding_service.embed_text(request.content)[0]
|
||||
|
||||
# Check for duplicate messages
|
||||
# Search for similar messages in the same session
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
search_results = db_client.search_chats(
|
||||
user_id=user_id,
|
||||
query_embedding=embedding,
|
||||
session_id=request.session_id,
|
||||
limit=5,
|
||||
)
|
||||
|
||||
# Check if this is a duplicate
|
||||
is_duplicate = False
|
||||
if not search_results.is_empty():
|
||||
for row in search_results.to_dicts():
|
||||
if (
|
||||
row["content"] == request.content
|
||||
and row["role"] == request.role
|
||||
and row.get("_similarity", 0) > 0.99
|
||||
):
|
||||
is_duplicate = True
|
||||
chat_id = row["chat_id"]
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
# Add new message
|
||||
chat_id = str(uuid.uuid4())
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
db_client.add_chat_message(
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
project_id=request.project_id or f"default-{user_id}",
|
||||
session_id=request.session_id,
|
||||
role=request.role,
|
||||
content=request.content,
|
||||
embedding=embedding,
|
||||
metadata=request.metadata or {},
|
||||
)
|
||||
|
||||
return {
|
||||
"chat_id": chat_id,
|
||||
"session_id": request.session_id,
|
||||
"duplicate": is_duplicate,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding chat message: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/v1/chat/sessions/{session_id}/messages")
|
||||
async def get_chat_messages(
|
||||
session_id: str,
|
||||
req: Request,
|
||||
userid: str = Query(...),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Get messages for a chat session."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
user_id = await get_or_verify_user_id(userid, credentials, req)
|
||||
|
||||
# Get chat history
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
history_df = db_client.get_chat_history(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Convert to messages
|
||||
messages = []
|
||||
if not history_df.is_empty():
|
||||
# Sort by created_at timestamp
|
||||
sorted_df = history_df.sort("created_at")
|
||||
|
||||
for row in sorted_df.to_dicts():
|
||||
messages.append(
|
||||
{
|
||||
"chat_id": row["chat_id"],
|
||||
"role": row["role"],
|
||||
"content": row["content"],
|
||||
"metadata": json.loads(row.get("metadata", "{}")),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"messages": messages,
|
||||
"total": len(messages),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting chat messages: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/v1/chat/search")
|
||||
async def search_chat_messages(
|
||||
req: Request,
|
||||
query: str = Query(...),
|
||||
userid: str = Query(...),
|
||||
project_id: str | None = Query(None),
|
||||
session_id: str | None = Query(None),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
) -> dict:
|
||||
"""Search across chat messages."""
|
||||
try:
|
||||
# Get or verify user ID
|
||||
user_id = await get_or_verify_user_id(userid, credentials, req)
|
||||
|
||||
# Generate query embedding
|
||||
if not embedding_service:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Embedding service not initialized"
|
||||
)
|
||||
query_embedding = embedding_service.embed_text(query)[0]
|
||||
|
||||
# Search chats
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=503, detail="Database not initialized")
|
||||
results_df = db_client.search_chats(
|
||||
user_id=user_id,
|
||||
query_embedding=query_embedding,
|
||||
project_id=project_id,
|
||||
session_id=session_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Convert results
|
||||
messages = []
|
||||
if not results_df.is_empty():
|
||||
for row in results_df.to_dicts():
|
||||
messages.append(
|
||||
{
|
||||
"chat_id": row["chat_id"],
|
||||
"session_id": row["session_id"],
|
||||
"role": row["role"],
|
||||
"content": row["content"],
|
||||
"similarity_score": row.get("_similarity", 0.0),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"messages": messages,
|
||||
"total": len(messages),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching chat messages: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Run the server."""
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"hanzo_memory.server:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,14 +0,0 @@
|
||||
"""Services package."""
|
||||
|
||||
from .embeddings import EmbeddingService, get_embedding_service
|
||||
from .llm import LLMService, get_llm_service
|
||||
from .memory import MemoryService, get_memory_service
|
||||
|
||||
__all__ = [
|
||||
"EmbeddingService",
|
||||
"get_embedding_service",
|
||||
"LLMService",
|
||||
"get_llm_service",
|
||||
"MemoryService",
|
||||
"get_memory_service",
|
||||
]
|
||||
@@ -1,241 +0,0 @@
|
||||
"""Embedding service using FastEmbed or LanceDB."""
|
||||
|
||||
|
||||
import numpy as np
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Import backend-specific implementations
|
||||
try:
|
||||
from fastembed import TextEmbedding
|
||||
|
||||
FASTEMBED_AVAILABLE = True
|
||||
except ImportError:
|
||||
FASTEMBED_AVAILABLE = False
|
||||
logger.warning("FastEmbed not available")
|
||||
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
if importlib.util.find_spec("hanzo_memory.services.embeddings_lancedb") is not None:
|
||||
LANCEDB_AVAILABLE = True
|
||||
else:
|
||||
LANCEDB_AVAILABLE = False
|
||||
logger.warning("LanceDB embeddings not available")
|
||||
except ImportError:
|
||||
LANCEDB_AVAILABLE = False
|
||||
logger.warning("LanceDB embeddings not available")
|
||||
|
||||
|
||||
class EmbeddingService:
|
||||
"""Service for generating embeddings using FastEmbed."""
|
||||
|
||||
def __init__(self, model_name: str | None = None):
|
||||
"""Initialize the embedding service."""
|
||||
self.model_name = model_name or settings.embedding_model
|
||||
self._model = None
|
||||
logger.info(f"Initializing embedding service with model: {self.model_name}")
|
||||
|
||||
@property
|
||||
def model(self):
|
||||
"""Lazy load the embedding model."""
|
||||
if self._model is None:
|
||||
if not FASTEMBED_AVAILABLE:
|
||||
raise RuntimeError("FastEmbed is not available")
|
||||
logger.info(f"Loading embedding model: {self.model_name}")
|
||||
self._model = TextEmbedding(model_name=self.model_name)
|
||||
logger.info(f"Model {self.model_name} loaded successfully")
|
||||
return self._model
|
||||
|
||||
def embed_text(self, text: str | list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for text.
|
||||
|
||||
Args:
|
||||
text: Single text string or list of text strings
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
if isinstance(text, str):
|
||||
text = [text]
|
||||
|
||||
# Generate embeddings
|
||||
embeddings = list(self.model.embed(text))
|
||||
|
||||
# Convert numpy arrays to lists
|
||||
return [emb.tolist() for emb in embeddings]
|
||||
|
||||
def embed_single(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding for a single text.
|
||||
|
||||
Args:
|
||||
text: Text string
|
||||
|
||||
Returns:
|
||||
Embedding vector
|
||||
"""
|
||||
embeddings = self.embed_text(text)
|
||||
return embeddings[0]
|
||||
|
||||
def embed_batch(
|
||||
self,
|
||||
texts: list[str],
|
||||
batch_size: int = 32,
|
||||
show_progress: bool = False,
|
||||
) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for a batch of texts.
|
||||
|
||||
Args:
|
||||
texts: List of text strings
|
||||
batch_size: Batch size for processing
|
||||
show_progress: Whether to show progress bar
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
all_embeddings = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
embeddings = self.embed_text(batch)
|
||||
all_embeddings.extend(embeddings)
|
||||
|
||||
if show_progress and i > 0:
|
||||
logger.info(f"Processed {i + len(batch)}/{len(texts)} texts")
|
||||
|
||||
return all_embeddings
|
||||
|
||||
def compute_similarity(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
embeddings: list[list[float]],
|
||||
metric: str = "cosine",
|
||||
) -> list[float]:
|
||||
"""
|
||||
Compute similarity between query embedding and a list of embeddings.
|
||||
|
||||
Args:
|
||||
query_embedding: Query embedding vector
|
||||
embeddings: List of embedding vectors to compare against
|
||||
metric: Similarity metric ("cosine", "dot", "euclidean")
|
||||
|
||||
Returns:
|
||||
List of similarity scores
|
||||
"""
|
||||
if not embeddings:
|
||||
return []
|
||||
|
||||
query = np.array(query_embedding)
|
||||
vectors = np.array(embeddings)
|
||||
|
||||
if metric == "cosine":
|
||||
# Normalize vectors for cosine similarity
|
||||
query_norm = query / np.linalg.norm(query)
|
||||
vectors_norm = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
|
||||
similarities = np.dot(vectors_norm, query_norm)
|
||||
elif metric == "dot":
|
||||
similarities = np.dot(vectors, query)
|
||||
elif metric == "euclidean":
|
||||
# Return negative distance so higher is better
|
||||
similarities = -np.linalg.norm(vectors - query, axis=1)
|
||||
else:
|
||||
raise ValueError(f"Unknown metric: {metric}")
|
||||
|
||||
return list(similarities.tolist()) # Type hint for mypy
|
||||
|
||||
def get_model_info(self) -> dict:
|
||||
"""Get information about the current embedding model."""
|
||||
return {
|
||||
"model_name": self.model_name,
|
||||
"dimensions": settings.embedding_dimensions,
|
||||
"loaded": self._model is not None,
|
||||
}
|
||||
|
||||
|
||||
# Global embedding service instance
|
||||
_embedding_service: EmbeddingService | None = None
|
||||
|
||||
|
||||
def get_embedding_service() -> EmbeddingService:
|
||||
"""Get or create the global embedding service."""
|
||||
global _embedding_service
|
||||
if _embedding_service is None:
|
||||
# Use LanceDB embeddings if configured
|
||||
if settings.db_backend == "lancedb" and LANCEDB_AVAILABLE:
|
||||
logger.info("Using LanceDB embedding service")
|
||||
from .embeddings_lancedb import get_lancedb_embedding_service
|
||||
|
||||
# Wrap LanceDB service in standard interface
|
||||
lancedb_service = get_lancedb_embedding_service()
|
||||
_embedding_service = EmbeddingService()
|
||||
# Override methods with LanceDB implementation
|
||||
_embedding_service.embed_text = lancedb_service.embed_text
|
||||
_embedding_service.embed_single = lancedb_service.embed_single
|
||||
_embedding_service.embed_batch = lancedb_service.embed_batch
|
||||
_embedding_service.compute_similarity = lancedb_service.compute_similarity
|
||||
_embedding_service.get_model_info = lancedb_service.get_model_info
|
||||
elif FASTEMBED_AVAILABLE:
|
||||
logger.info("Using FastEmbed embedding service")
|
||||
_embedding_service = EmbeddingService()
|
||||
else:
|
||||
# Create a minimal embedding service that generates dummy embeddings
|
||||
# This allows the system to work without heavy embedding dependencies
|
||||
logger.warning("Using minimal embedding service (dummy embeddings)")
|
||||
_embedding_service = MinimalEmbeddingService()
|
||||
return _embedding_service
|
||||
|
||||
|
||||
class MinimalEmbeddingService:
|
||||
"""Minimal embedding service that generates dummy embeddings for basic functionality."""
|
||||
|
||||
def __init__(self):
|
||||
self.model_name = "minimal"
|
||||
|
||||
def embed_text(self, text: str | list[str]) -> list[list[float]]:
|
||||
"""Generate dummy embeddings for text."""
|
||||
if isinstance(text, str):
|
||||
text = [text]
|
||||
|
||||
# Generate simple dummy embeddings based on text length and content
|
||||
embeddings = []
|
||||
for t in text:
|
||||
# Create a simple hash-based embedding
|
||||
hash_val = hash(t) % 1000000
|
||||
embedding = [float((hash_val >> i) & 0xFF) / 255.0 for i in range(0, 384, 8)]
|
||||
# Pad or truncate to ensure consistent size
|
||||
embedding = (embedding * (384 // len(embedding) + 1))[:384]
|
||||
embeddings.append(embedding)
|
||||
|
||||
return embeddings
|
||||
|
||||
def embed_single(self, text: str) -> list[float]:
|
||||
"""Generate embedding for a single text."""
|
||||
embeddings = self.embed_text(text)
|
||||
return embeddings[0]
|
||||
|
||||
def embed_batch(self, texts: list[str], batch_size: int = 32, show_progress: bool = False) -> list[list[float]]:
|
||||
"""Generate embeddings for a batch of texts."""
|
||||
return self.embed_text(texts)
|
||||
|
||||
def compute_similarity(self, query_embedding: list[float], embeddings: list[list[float]], metric: str = "cosine") -> list[float]:
|
||||
"""Compute similarity between query embedding and a list of embeddings."""
|
||||
# For minimal service, return dummy similarity scores
|
||||
return [0.5] * len(embeddings) # Return neutral similarity
|
||||
|
||||
def get_model_info(self) -> dict:
|
||||
"""Get information about the current embedding model."""
|
||||
return {
|
||||
"model_name": "minimal",
|
||||
"dimensions": 384,
|
||||
"loaded": True,
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Embedding service using LanceDB's built-in embedding functions."""
|
||||
|
||||
|
||||
import numpy as np
|
||||
from lancedb.embeddings import get_registry
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class LanceDBEmbeddingService:
|
||||
"""Service for generating embeddings using LanceDB's embedding functions."""
|
||||
|
||||
def __init__(self, model_name: str | None = None):
|
||||
"""Initialize the embedding service."""
|
||||
self.model_name = model_name or settings.embedding_model
|
||||
self._embedding_func = None
|
||||
logger.info(
|
||||
f"Initializing LanceDB embedding service with model: {self.model_name}"
|
||||
)
|
||||
|
||||
@property
|
||||
def embedding_func(self):
|
||||
"""Lazy load the embedding function."""
|
||||
if self._embedding_func is None:
|
||||
logger.info(f"Loading embedding function: {self.model_name}")
|
||||
|
||||
# LanceDB supports multiple embedding providers
|
||||
registry = get_registry()
|
||||
|
||||
# Check available embedding functions
|
||||
available_funcs = list(registry.list_embedding_functions())
|
||||
logger.info(f"Available embedding functions: {available_funcs}")
|
||||
|
||||
# Try to use the appropriate embedding function based on model name
|
||||
if "fastembed" in available_funcs:
|
||||
# Use FastEmbed if available
|
||||
try:
|
||||
self._embedding_func = registry.get("fastembed").create(
|
||||
name=self.model_name
|
||||
)
|
||||
logger.info("Using FastEmbed via LanceDB")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to use FastEmbed: {e}")
|
||||
self._embedding_func = None
|
||||
|
||||
if self._embedding_func is None:
|
||||
# Fall back to other providers
|
||||
if self.model_name.startswith("BAAI/") or "bge" in self.model_name:
|
||||
# Use sentence-transformers for BAAI/BGE models
|
||||
self._embedding_func = registry.get("sentence-transformers").create(
|
||||
name=self.model_name
|
||||
)
|
||||
logger.info("Using sentence-transformers via LanceDB")
|
||||
elif (
|
||||
self.model_name.startswith("text-embedding")
|
||||
and "openai" in available_funcs
|
||||
):
|
||||
# Use OpenAI for text-embedding models
|
||||
self._embedding_func = registry.get("openai").create(
|
||||
name=self.model_name
|
||||
)
|
||||
logger.info("Using OpenAI embeddings via LanceDB")
|
||||
else:
|
||||
# Default to sentence-transformers
|
||||
self._embedding_func = registry.get("sentence-transformers").create(
|
||||
name=self.model_name
|
||||
)
|
||||
logger.info("Using sentence-transformers (default) via LanceDB")
|
||||
|
||||
logger.info(f"Embedding function {self.model_name} loaded successfully")
|
||||
return self._embedding_func
|
||||
|
||||
def embed_text(self, text: str | list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for text.
|
||||
|
||||
Args:
|
||||
text: Single text string or list of text strings
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
if isinstance(text, str):
|
||||
text = [text]
|
||||
|
||||
# Generate embeddings using LanceDB's embedding function
|
||||
embeddings = self.embedding_func.compute_source_embeddings(text)
|
||||
|
||||
# Convert to list format
|
||||
if isinstance(embeddings, np.ndarray):
|
||||
return embeddings.tolist()
|
||||
else:
|
||||
return [
|
||||
emb.tolist() if isinstance(emb, np.ndarray) else emb
|
||||
for emb in embeddings
|
||||
]
|
||||
|
||||
def embed_single(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding for a single text.
|
||||
|
||||
Args:
|
||||
text: Text string
|
||||
|
||||
Returns:
|
||||
Embedding vector
|
||||
"""
|
||||
embeddings = self.embed_text(text)
|
||||
return embeddings[0]
|
||||
|
||||
def embed_batch(
|
||||
self,
|
||||
texts: list[str],
|
||||
batch_size: int = 32,
|
||||
show_progress: bool = False,
|
||||
) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for a batch of texts.
|
||||
|
||||
Args:
|
||||
texts: List of text strings
|
||||
batch_size: Batch size for processing
|
||||
show_progress: Whether to show progress bar
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
all_embeddings = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
embeddings = self.embed_text(batch)
|
||||
all_embeddings.extend(embeddings)
|
||||
|
||||
if show_progress and i > 0:
|
||||
logger.info(f"Processed {i + len(batch)}/{len(texts)} texts")
|
||||
|
||||
return all_embeddings
|
||||
|
||||
def compute_similarity(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
embeddings: list[list[float]],
|
||||
metric: str = "cosine",
|
||||
) -> list[float]:
|
||||
"""
|
||||
Compute similarity between query embedding and a list of embeddings.
|
||||
|
||||
Args:
|
||||
query_embedding: Query embedding vector
|
||||
embeddings: List of embedding vectors to compare against
|
||||
metric: Similarity metric ("cosine", "dot", "euclidean")
|
||||
|
||||
Returns:
|
||||
List of similarity scores
|
||||
"""
|
||||
if not embeddings:
|
||||
return []
|
||||
|
||||
query = np.array(query_embedding)
|
||||
vectors = np.array(embeddings)
|
||||
|
||||
if metric == "cosine":
|
||||
# Normalize vectors for cosine similarity
|
||||
query_norm = query / np.linalg.norm(query)
|
||||
vectors_norm = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
|
||||
similarities = np.dot(vectors_norm, query_norm)
|
||||
elif metric == "dot":
|
||||
similarities = np.dot(vectors, query)
|
||||
elif metric == "euclidean":
|
||||
# Return negative distance so higher is better
|
||||
similarities = -np.linalg.norm(vectors - query, axis=1)
|
||||
else:
|
||||
raise ValueError(f"Unknown metric: {metric}")
|
||||
|
||||
return list(similarities.tolist()) # Type hint for mypy
|
||||
|
||||
def get_model_info(self) -> dict:
|
||||
"""Get information about the current embedding model."""
|
||||
return {
|
||||
"model_name": self.model_name,
|
||||
"dimensions": (
|
||||
self.embedding_func.ndims()
|
||||
if self._embedding_func
|
||||
else settings.embedding_dimensions
|
||||
),
|
||||
"loaded": self._embedding_func is not None,
|
||||
"backend": "lancedb",
|
||||
}
|
||||
|
||||
|
||||
# Global embedding service instance
|
||||
_embedding_service: LanceDBEmbeddingService | None = None
|
||||
|
||||
|
||||
def get_lancedb_embedding_service() -> LanceDBEmbeddingService:
|
||||
"""Get or create the global LanceDB embedding service."""
|
||||
global _embedding_service
|
||||
if _embedding_service is None:
|
||||
_embedding_service = LanceDBEmbeddingService()
|
||||
return _embedding_service
|
||||
@@ -1,242 +0,0 @@
|
||||
"""LLM service for AI operations using LiteLLM."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Try to import LiteLLM, but make it optional
|
||||
try:
|
||||
import litellm
|
||||
LITELLM_AVAILABLE = True
|
||||
# Configure LiteLLM
|
||||
litellm.drop_params = True # Drop unsupported params instead of failing
|
||||
litellm.set_verbose = False # Disable verbose logging
|
||||
except ImportError:
|
||||
LITELLM_AVAILABLE = False
|
||||
litellm = None
|
||||
logger.warning("LiteLLM not available, LLM features will be limited")
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""Service for LLM operations using LiteLLM."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize LLM service."""
|
||||
self.default_model = settings.llm_model
|
||||
self.api_base = settings.llm_api_base
|
||||
self.temperature = settings.llm_temperature
|
||||
self.max_tokens = settings.llm_max_tokens
|
||||
|
||||
# Set up API keys if LiteLLM is available
|
||||
if LITELLM_AVAILABLE:
|
||||
# Set up API keys
|
||||
if settings.llm_api_key:
|
||||
litellm.api_key = settings.llm_api_key
|
||||
if settings.openai_api_key:
|
||||
litellm.openai_key = settings.openai_api_key
|
||||
if settings.anthropic_api_key:
|
||||
litellm.anthropic_key = settings.anthropic_api_key
|
||||
|
||||
def complete(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
response_format: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Complete a prompt using LiteLLM.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to complete
|
||||
model: Model to use (defaults to config)
|
||||
max_tokens: Maximum tokens to generate
|
||||
temperature: Temperature for generation
|
||||
response_format: Optional response format ("json" for JSON mode)
|
||||
|
||||
Returns:
|
||||
Generated text
|
||||
"""
|
||||
if not LITELLM_AVAILABLE:
|
||||
logger.warning("LiteLLM not available, returning empty response")
|
||||
return ""
|
||||
|
||||
model = model or self.default_model
|
||||
max_tokens = max_tokens or self.max_tokens
|
||||
temperature = temperature or self.temperature
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
# Add API base if configured (for local models)
|
||||
if self.api_base:
|
||||
kwargs["api_base"] = self.api_base
|
||||
|
||||
# Add response format if specified
|
||||
if response_format == "json":
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = litellm.completion(**kwargs)
|
||||
return response.choices[0].message.content or ""
|
||||
except Exception as e:
|
||||
logger.error(f"LLM completion error: {e}")
|
||||
return ""
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
model: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
response_format: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Chat with the LLM using LiteLLM.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with "role" and "content"
|
||||
model: Model to use
|
||||
max_tokens: Maximum tokens to generate
|
||||
temperature: Temperature for generation
|
||||
response_format: Optional response format ("json" for JSON mode)
|
||||
|
||||
Returns:
|
||||
Generated response
|
||||
"""
|
||||
if not LITELLM_AVAILABLE:
|
||||
logger.warning("LiteLLM not available, returning empty response")
|
||||
return ""
|
||||
|
||||
model = model or self.default_model
|
||||
max_tokens = max_tokens or self.max_tokens
|
||||
temperature = temperature or self.temperature
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
# Add API base if configured
|
||||
if self.api_base:
|
||||
kwargs["api_base"] = self.api_base
|
||||
|
||||
# Add response format if specified
|
||||
if response_format == "json":
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = litellm.completion(**kwargs)
|
||||
return response.choices[0].message.content or ""
|
||||
except Exception as e:
|
||||
logger.error(f"LLM chat error: {e}")
|
||||
return ""
|
||||
|
||||
def summarize_for_knowledge(
|
||||
self,
|
||||
content: str,
|
||||
context: str | None = None,
|
||||
skip_summarization: bool = False,
|
||||
provided_summary: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Summarize content and generate knowledge update instructions.
|
||||
|
||||
Args:
|
||||
content: Content to summarize
|
||||
context: Additional context
|
||||
skip_summarization: Skip summarization step
|
||||
provided_summary: Pre-provided summary
|
||||
|
||||
Returns:
|
||||
JSON with summary and knowledge instructions
|
||||
"""
|
||||
if skip_summarization and not provided_summary:
|
||||
# Just return the content as-is with basic instructions
|
||||
return {
|
||||
"summary": content,
|
||||
"knowledge_instructions": {
|
||||
"action": "add_fact",
|
||||
"facts": [{"content": content}],
|
||||
"reasoning": "Content added without summarization",
|
||||
},
|
||||
}
|
||||
|
||||
if provided_summary:
|
||||
summary = provided_summary
|
||||
else:
|
||||
# Generate summary
|
||||
summary_prompt = f"""Summarize the following content concisely, preserving key information:
|
||||
|
||||
Content: {content}"""
|
||||
if context:
|
||||
summary_prompt += f"\n\nContext: {context}"
|
||||
|
||||
summary = self.complete(summary_prompt, temperature=0.3)
|
||||
|
||||
# Generate knowledge instructions
|
||||
if LITELLM_AVAILABLE:
|
||||
instruction_prompt = f"""Based on this summary, generate instructions for updating a knowledge base.
|
||||
Return a JSON object with the following structure:
|
||||
{{
|
||||
"action": "add_fact" or "update_fact" or "add_relation",
|
||||
"facts": [
|
||||
{{
|
||||
"content": "fact content",
|
||||
"metadata": {{"tags": [], "source": "..."}},
|
||||
"parent_id": null or "parent_fact_id"
|
||||
}}
|
||||
],
|
||||
"reasoning": "explanation of why these facts should be added"
|
||||
}}
|
||||
|
||||
Summary: {summary}"""
|
||||
|
||||
if context:
|
||||
instruction_prompt += f"\n\nContext: {context}"
|
||||
|
||||
instructions_json = self.complete(
|
||||
instruction_prompt, temperature=0.3, response_format="json"
|
||||
)
|
||||
|
||||
try:
|
||||
instructions = json.loads(instructions_json)
|
||||
except json.JSONDecodeError:
|
||||
instructions = {
|
||||
"action": "add_fact",
|
||||
"facts": [{"content": summary}],
|
||||
"reasoning": "Failed to parse LLM response, using summary as fact",
|
||||
}
|
||||
else:
|
||||
# Without LLM, return basic instructions
|
||||
instructions = {
|
||||
"action": "add_fact",
|
||||
"facts": [{"content": summary}],
|
||||
"reasoning": "Content added without LLM processing",
|
||||
}
|
||||
|
||||
return {"summary": summary, "knowledge_instructions": instructions}
|
||||
|
||||
|
||||
# Global LLM service instance
|
||||
_llm_service: LLMService | None = None
|
||||
|
||||
|
||||
def get_llm_service() -> LLMService:
|
||||
"""Get or create the global LLM service."""
|
||||
global _llm_service
|
||||
if _llm_service is None:
|
||||
_llm_service = LLMService()
|
||||
return _llm_service
|
||||
@@ -1,284 +0,0 @@
|
||||
"""Memory service for managing memories."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from structlog import get_logger
|
||||
|
||||
from ..db import get_db_client
|
||||
from ..models.memory import Memory, MemoryWithScore
|
||||
from .embeddings import get_embedding_service
|
||||
from .llm import get_llm_service
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class MemoryService:
|
||||
"""Service for managing memories."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize memory service."""
|
||||
self.db = get_db_client()
|
||||
self.embeddings = get_embedding_service()
|
||||
self.llm = get_llm_service()
|
||||
|
||||
def create_memory(
|
||||
self,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str,
|
||||
metadata: dict | None = None,
|
||||
importance: float = 1.0,
|
||||
strip_pii: bool = False,
|
||||
) -> Memory:
|
||||
"""
|
||||
Create a new memory.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
project_id: Project ID
|
||||
content: Memory content
|
||||
metadata: Additional metadata
|
||||
importance: Importance score
|
||||
strip_pii: Whether to strip PII from content
|
||||
|
||||
Returns:
|
||||
Created memory
|
||||
"""
|
||||
# Ensure user's memory table exists
|
||||
self.db.create_memories_table(user_id)
|
||||
|
||||
# Strip PII if requested
|
||||
if strip_pii:
|
||||
content = self._strip_pii(content)
|
||||
|
||||
# Generate embedding
|
||||
embedding = self.embeddings.embed_single(content)
|
||||
|
||||
# Create memory
|
||||
memory_id = f"mem_{uuid.uuid4().hex[:12]}"
|
||||
memory_data = self.db.add_memory(
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
content=content,
|
||||
embedding=embedding,
|
||||
metadata=metadata,
|
||||
importance=importance,
|
||||
)
|
||||
|
||||
# Parse JSON fields if needed
|
||||
if isinstance(memory_data.get("metadata"), str):
|
||||
memory_data["metadata"] = json.loads(memory_data["metadata"])
|
||||
|
||||
return Memory(**memory_data)
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
user_id: str,
|
||||
query: str,
|
||||
project_id: str | None = None,
|
||||
limit: int = 10,
|
||||
filter_with_llm: bool = False,
|
||||
additional_context: str | None = None,
|
||||
) -> list[MemoryWithScore]:
|
||||
"""
|
||||
Search memories by semantic similarity.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
query: Search query
|
||||
project_id: Optional project filter
|
||||
limit: Maximum results
|
||||
filter_with_llm: Use LLM to filter results
|
||||
additional_context: Additional context for filtering
|
||||
|
||||
Returns:
|
||||
List of memories with similarity scores
|
||||
"""
|
||||
# Generate query embedding
|
||||
query_embedding = self.embeddings.embed_single(query)
|
||||
|
||||
# Search memories
|
||||
results_df = self.db.search_memories(
|
||||
user_id=user_id,
|
||||
query_embedding=query_embedding,
|
||||
project_id=project_id,
|
||||
limit=limit * 2 if filter_with_llm else limit, # Get more if filtering
|
||||
)
|
||||
|
||||
if results_df.empty:
|
||||
return []
|
||||
|
||||
# Convert to memories with scores
|
||||
memories = []
|
||||
for _, row in results_df.iterrows():
|
||||
# Get similarity score if available, otherwise calculate it
|
||||
if "similarity_score" in row:
|
||||
score = row["similarity_score"]
|
||||
elif "embedding" in row:
|
||||
# Calculate similarity score (cosine similarity)
|
||||
embedding = row["embedding"]
|
||||
score = self.embeddings.compute_similarity(
|
||||
query_embedding, [embedding], metric="cosine"
|
||||
)[0]
|
||||
else:
|
||||
score = 0.0 # Default score if no embedding available
|
||||
|
||||
# Parse metadata if it's a string
|
||||
metadata = row.get("metadata", {})
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
|
||||
memory = MemoryWithScore(
|
||||
memory_id=row.get("memory_id", row.get("id")),
|
||||
user_id=row.get("user_id"),
|
||||
project_id=row.get("project_id"),
|
||||
content=row.get("content"),
|
||||
metadata=metadata,
|
||||
importance=row.get("importance", 0.5),
|
||||
created_at=datetime.fromisoformat(row.get("created_at", datetime.now().isoformat())),
|
||||
updated_at=datetime.fromisoformat(row.get("updated_at", datetime.now().isoformat())),
|
||||
embedding=row.get("embedding"),
|
||||
similarity_score=score,
|
||||
)
|
||||
memories.append(memory)
|
||||
|
||||
# Sort by similarity score
|
||||
memories.sort(key=lambda m: m.similarity_score, reverse=True)
|
||||
|
||||
# Filter with LLM if requested
|
||||
if filter_with_llm and memories:
|
||||
memories = self._filter_with_llm(
|
||||
query=query,
|
||||
memories=memories[: limit * 2],
|
||||
limit=limit,
|
||||
additional_context=additional_context,
|
||||
)
|
||||
|
||||
return memories[:limit]
|
||||
|
||||
def get_memory(self, user_id: str, memory_id: str) -> Memory | None:
|
||||
"""Get a specific memory by ID."""
|
||||
# This would need to be implemented in the DB client
|
||||
# For now, return None
|
||||
logger.warning(f"get_memory not fully implemented for {memory_id}")
|
||||
return None
|
||||
|
||||
def delete_memory(self, user_id: str, memory_id: str) -> bool:
|
||||
"""Delete a memory."""
|
||||
# This would need to be implemented in the DB client
|
||||
logger.warning(f"delete_memory not fully implemented for {memory_id}")
|
||||
return False
|
||||
|
||||
def update_memory(
|
||||
self,
|
||||
user_id: str,
|
||||
memory_id: str,
|
||||
project_id: str,
|
||||
content: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
importance: float | None = None,
|
||||
) -> Memory | None:
|
||||
"""Update a memory."""
|
||||
# Update the memory in the database
|
||||
updated_data = self.db.update_memory(
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
importance=importance,
|
||||
)
|
||||
|
||||
if updated_data:
|
||||
# Parse JSON fields if needed
|
||||
if isinstance(updated_data.get("metadata"), str):
|
||||
updated_data["metadata"] = json.loads(updated_data["metadata"])
|
||||
if isinstance(updated_data.get("context"), str):
|
||||
updated_data["context"] = json.loads(updated_data.get("context", "{}"))
|
||||
|
||||
return Memory(**updated_data)
|
||||
|
||||
return None
|
||||
|
||||
def delete_user_memories(self, user_id: str) -> int:
|
||||
"""Delete all memories for a user."""
|
||||
# This would need to be implemented in the DB client
|
||||
logger.warning(f"delete_user_memories not fully implemented for {user_id}")
|
||||
return 0
|
||||
|
||||
def _strip_pii(self, content: str) -> str:
|
||||
"""Strip PII from content using LLM."""
|
||||
prompt = f"""Remove any personally identifiable information (PII) from the following text.
|
||||
Replace names with [NAME], emails with [EMAIL], phone numbers with [PHONE], addresses with [ADDRESS], etc.
|
||||
|
||||
Text: {content}
|
||||
|
||||
Anonymized text:"""
|
||||
|
||||
try:
|
||||
anonymized = self.llm.complete(prompt)
|
||||
return anonymized.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stripping PII: {e}")
|
||||
return content
|
||||
|
||||
def _filter_with_llm(
|
||||
self,
|
||||
query: str,
|
||||
memories: list[MemoryWithScore],
|
||||
limit: int,
|
||||
additional_context: str | None = None,
|
||||
) -> list[MemoryWithScore]:
|
||||
"""Filter memories using LLM to select most relevant."""
|
||||
# Create memory list for LLM
|
||||
memory_texts = []
|
||||
for i, memory in enumerate(memories):
|
||||
memory_texts.append(f"{i + 1}. {memory.content}")
|
||||
|
||||
context = (
|
||||
f"Additional context: {additional_context}" if additional_context else ""
|
||||
)
|
||||
|
||||
prompt = f"""Given the query: "{query}"
|
||||
{context}
|
||||
|
||||
Select the {limit} most relevant memories from the following list.
|
||||
Return only the numbers of the selected memories, separated by commas.
|
||||
|
||||
Memories:
|
||||
{chr(10).join(memory_texts)}
|
||||
|
||||
Selected memory numbers:"""
|
||||
|
||||
try:
|
||||
response = self.llm.complete(prompt)
|
||||
# Parse selected indices
|
||||
selected_indices = []
|
||||
for part in response.strip().split(","):
|
||||
try:
|
||||
idx = int(part.strip()) - 1
|
||||
if 0 <= idx < len(memories):
|
||||
selected_indices.append(idx)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Return selected memories
|
||||
return [memories[i] for i in selected_indices[:limit]]
|
||||
except Exception as e:
|
||||
logger.error(f"Error filtering with LLM: {e}")
|
||||
return memories[:limit]
|
||||
|
||||
|
||||
# Global memory service instance
|
||||
_memory_service: MemoryService | None = None
|
||||
|
||||
|
||||
def get_memory_service() -> MemoryService:
|
||||
"""Get or create the global memory service."""
|
||||
global _memory_service
|
||||
if _memory_service is None:
|
||||
_memory_service = MemoryService()
|
||||
return _memory_service
|
||||
@@ -7,7 +7,7 @@ name = "hanzo-memory"
|
||||
version = "1.0.1"
|
||||
description = "AI memory service with FastAPI and MCP support"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.12"
|
||||
license = {text = "BSD"}
|
||||
authors = [
|
||||
{name = "Hanzo Industries Inc.", email = "[email protected]"}
|
||||
@@ -18,8 +18,6 @@ classifiers = [
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
|
||||
@@ -57,6 +55,8 @@ test = [
|
||||
"faker>=30.0.0",
|
||||
"factory-boy>=3.3.0",
|
||||
"respx>=0.21.0",
|
||||
"polars>=1.0.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
docs = [
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
from .base import BaseVectorDB
|
||||
from .client import InfinityClient, get_client
|
||||
from .factory import get_db_client, reset_db_client
|
||||
from .lancedb_client import LanceDBClient
|
||||
|
||||
__all__ = [
|
||||
"BaseVectorDB",
|
||||
"InfinityClient",
|
||||
"LanceDBClient",
|
||||
"get_db_client",
|
||||
"reset_db_client",
|
||||
"get_client",
|
||||
|
||||
@@ -13,11 +13,17 @@ from structlog import get_logger
|
||||
|
||||
from ..base import BaseVectorDB
|
||||
from ..local_client import LocalMemoryClient
|
||||
from ..lancedb_client import LanceDBClient
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
# Try to import optional backends
|
||||
try:
|
||||
from ..lancedb_client import LanceDBClient
|
||||
LANCEDB_AVAILABLE = True
|
||||
except ImportError:
|
||||
LANCEDB_AVAILABLE = False
|
||||
logger.debug("LanceDB client not available")
|
||||
|
||||
try:
|
||||
from ..kuzudb_client import KuzuDBClient
|
||||
KUZU_AVAILABLE = True
|
||||
@@ -58,27 +64,13 @@ class BackendRegistry:
|
||||
|
||||
# Registered backends and their capabilities
|
||||
BACKENDS: Dict[str, Dict] = {
|
||||
"lancedb": {
|
||||
"class": LanceDBClient,
|
||||
"description": "LanceDB - Embedded vector database with SQL-like queries",
|
||||
"capabilities": [
|
||||
BackendCapability.VECTOR_SEARCH,
|
||||
BackendCapability.STRUCTURED_QUERY,
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.EMBEDDINGS,
|
||||
BackendCapability.MARKDOWN_IMPORT,
|
||||
],
|
||||
"config": {
|
||||
"enable_markdown": True,
|
||||
},
|
||||
},
|
||||
"local": {
|
||||
"class": LocalMemoryClient,
|
||||
"description": "Local file storage using JSON files",
|
||||
"capabilities": [
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.MARKDOWN_IMPORT,
|
||||
BackendCapability.FULL_TEXT_SEARCH, # Simple text search
|
||||
BackendCapability.FULL_TEXT_SEARCH,
|
||||
],
|
||||
"config": {
|
||||
"enable_markdown": True,
|
||||
@@ -90,6 +82,22 @@ class BackendRegistry:
|
||||
@classmethod
|
||||
def _init_optional_backends(cls):
|
||||
"""Initialize optional backends if they're available."""
|
||||
if LANCEDB_AVAILABLE and "lancedb" not in cls.BACKENDS:
|
||||
cls.BACKENDS["lancedb"] = {
|
||||
"class": LanceDBClient,
|
||||
"description": "LanceDB - Embedded vector database with SQL-like queries",
|
||||
"capabilities": [
|
||||
BackendCapability.VECTOR_SEARCH,
|
||||
BackendCapability.STRUCTURED_QUERY,
|
||||
BackendCapability.PERSISTENCE,
|
||||
BackendCapability.EMBEDDINGS,
|
||||
BackendCapability.MARKDOWN_IMPORT,
|
||||
],
|
||||
"config": {
|
||||
"enable_markdown": True,
|
||||
},
|
||||
}
|
||||
|
||||
if KUZU_AVAILABLE and "kuzudb" not in cls.BACKENDS:
|
||||
cls.BACKENDS["kuzudb"] = {
|
||||
"class": KuzuDBClient,
|
||||
|
||||
@@ -443,6 +443,133 @@ class InfinityClient(BaseVectorDB):
|
||||
# InfinityDB embedded doesn't have explicit close method
|
||||
pass
|
||||
|
||||
# Abstract method implementations required by BaseVectorDB
|
||||
def get_user_projects(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all projects for a user."""
|
||||
db = self._get_db("projects")
|
||||
try:
|
||||
table = db.get_table("projects")
|
||||
result = table.output(["*"]).filter(f"user_id = '{user_id}'").to_pl()
|
||||
return result.to_dicts() if len(result) > 0 else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user projects: {e}")
|
||||
return []
|
||||
|
||||
def get_knowledge_bases(self, project_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all knowledge bases for a project."""
|
||||
db = self._get_db("knowledge")
|
||||
try:
|
||||
table = db.get_table("knowledge_bases")
|
||||
result = table.output(["*"]).filter(f"project_id = '{project_id}'").to_pl()
|
||||
return result.to_dicts() if len(result) > 0 else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting knowledge bases: {e}")
|
||||
return []
|
||||
|
||||
def delete_fact(self, fact_id: str, knowledge_base_id: str) -> bool:
|
||||
"""Delete a fact from a knowledge base."""
|
||||
db = self._get_db("knowledge")
|
||||
table_name = f"facts_{knowledge_base_id}"
|
||||
try:
|
||||
table = db.get_table(table_name)
|
||||
table.delete(f"fact_id = '{fact_id}'")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting fact: {e}")
|
||||
return False
|
||||
|
||||
def update_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
content: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
importance: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a memory in the database."""
|
||||
db = self._get_db("memories")
|
||||
table_name = f"memories_{user_id}"
|
||||
try:
|
||||
table = db.get_table(table_name)
|
||||
updates = {"updated_at": datetime.now(timezone.utc).isoformat()}
|
||||
if content is not None:
|
||||
updates["content"] = content
|
||||
if metadata is not None:
|
||||
updates["metadata"] = json.dumps(metadata)
|
||||
if importance is not None:
|
||||
updates["importance"] = importance
|
||||
table.update(f"memory_id = '{memory_id}'", updates)
|
||||
return updates
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating memory: {e}")
|
||||
return None
|
||||
|
||||
def create_chat_session(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
metadata: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new chat session."""
|
||||
self.create_chats_table(user_id)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def get_chat_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get messages from a chat session."""
|
||||
# Note: This implementation searches all user tables - not ideal
|
||||
db = self._get_db("chats")
|
||||
try:
|
||||
# Get all tables and search for the session
|
||||
for table_name in db.list_tables():
|
||||
if table_name.startswith("chats_"):
|
||||
table = db.get_table(table_name)
|
||||
query = table.output(["*"]).filter(f"session_id = '{session_id}'")
|
||||
result = query.to_pl()
|
||||
if len(result) > 0:
|
||||
messages = result.to_dicts()
|
||||
if limit:
|
||||
return messages[:limit]
|
||||
return messages
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting chat messages: {e}")
|
||||
return []
|
||||
|
||||
def search_chat_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
query_embedding: list[float],
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search messages in a chat session by similarity."""
|
||||
db = self._get_db("chats")
|
||||
try:
|
||||
for table_name in db.list_tables():
|
||||
if table_name.startswith("chats_"):
|
||||
table = db.get_table(table_name)
|
||||
query = table.output(["*"]).match_dense(
|
||||
"embedding", query_embedding, "float", "cosine", limit
|
||||
).filter(f"session_id = '{session_id}'")
|
||||
result = query.to_pl()
|
||||
if len(result) > 0:
|
||||
return result.to_dicts()
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching chat messages: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Global client instance
|
||||
_client: InfinityClient | None = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -213,8 +213,8 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
"name": name,
|
||||
"description": description,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def get_user_projects(self, user_id: str) -> list[dict[str, Any]]:
|
||||
@@ -291,9 +291,9 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
"context": {},
|
||||
"metadata": metadata or {},
|
||||
"source": "",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def search_memories(
|
||||
@@ -412,8 +412,8 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
"name": name,
|
||||
"description": description,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def get_knowledge_bases(self, project_id: str) -> list[dict[str, Any]]:
|
||||
@@ -469,8 +469,8 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
"content": content,
|
||||
"confidence": confidence,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def search_facts(
|
||||
@@ -617,6 +617,20 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
|
||||
return None
|
||||
|
||||
def delete_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
) -> bool:
|
||||
"""Delete a memory from the database."""
|
||||
cursor = self.conn.execute(
|
||||
"DELETE FROM memories WHERE memory_id = ? AND user_id = ? AND project_id = ?",
|
||||
(memory_id, user_id, project_id)
|
||||
)
|
||||
self.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def create_chat_session(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -643,8 +657,8 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"metadata": metadata or {},
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def add_chat_message(
|
||||
@@ -678,7 +692,7 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
"role": role,
|
||||
"content": content,
|
||||
"metadata": metadata or {},
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def get_chat_messages(
|
||||
@@ -775,4 +789,16 @@ class SQLiteMemoryClient(BaseVectorDB):
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
self.conn.close()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_sqlite_client: Optional[SQLiteMemoryClient] = None
|
||||
|
||||
|
||||
def get_sqlite_client() -> SQLiteMemoryClient:
|
||||
"""Get or create the singleton SQLite client."""
|
||||
global _sqlite_client
|
||||
if _sqlite_client is None:
|
||||
_sqlite_client = SQLiteMemoryClient()
|
||||
return _sqlite_client
|
||||
@@ -1,7 +1,8 @@
|
||||
"""MCP server for Hanzo Memory service."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
import asyncio
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from mcp.server import Server
|
||||
@@ -13,13 +14,22 @@ from mcp.types import (
|
||||
from structlog import get_logger
|
||||
|
||||
from ..config import settings
|
||||
from ..db import get_db_client
|
||||
from ..services.embeddings import EmbeddingService
|
||||
from ..services.llm import LLMService
|
||||
from ..db.sqlite_client import SQLiteMemoryClient, get_sqlite_client
|
||||
from ..models.knowledge import Fact, FactCreate, KnowledgeBase
|
||||
from ..models.memory import Memory, MemoryCreate
|
||||
from ..services.embeddings import EmbeddingService, get_embedding_service
|
||||
from ..services.llm import LLMService, get_llm_service
|
||||
# Constants
|
||||
ServiceName = "hanzo-memory"
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def get_db_client() -> SQLiteMemoryClient:
|
||||
"""Get the database client."""
|
||||
return get_sqlite_client()
|
||||
|
||||
|
||||
class MCPMemoryServer:
|
||||
"""MCP server for memory operations."""
|
||||
|
||||
@@ -39,170 +49,51 @@ class MCPMemoryServer:
|
||||
"""Return available tools."""
|
||||
return [
|
||||
Tool(
|
||||
name="remember",
|
||||
description="Store a memory for a user in a project",
|
||||
name="memory",
|
||||
description="""Unified memory tool for storing, retrieving, and managing information.
|
||||
Supports the following actions:
|
||||
- remember: Store a new memory
|
||||
- recall: Search for memories
|
||||
- delete: Delete a memory
|
||||
- create_project: Create a new project container
|
||||
- create_kb: Create a structured knowledge base
|
||||
- add_fact: Add a fact to a knowledge base
|
||||
- search_facts: Search within a knowledge base
|
||||
- delete_fact: Remove a fact from a knowledge base
|
||||
- summarize: Analyze content and generate knowledge entries""",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"project_id": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Project ID",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Memory content",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata",
|
||||
},
|
||||
"importance": {
|
||||
"type": "number",
|
||||
"description": "Importance score (0-10)",
|
||||
"default": 1.0,
|
||||
"enum": [
|
||||
"remember",
|
||||
"recall",
|
||||
"delete",
|
||||
"create_project",
|
||||
"create_kb",
|
||||
"add_fact",
|
||||
"search_facts",
|
||||
"delete_fact",
|
||||
"summarize"
|
||||
],
|
||||
"description": "The action to perform"
|
||||
},
|
||||
"user_id": {"type": "string", "description": "User ID (required for all actions)"},
|
||||
"project_id": {"type": "string", "description": "Project ID (optional for some actions)"},
|
||||
"content": {"type": "string", "description": "Content for storage or analysis"},
|
||||
"query": {"type": "string", "description": "Search query for recall/search purposes"},
|
||||
"id": {"type": "string", "description": "ID of item to delete (memory_id or fact_id)"},
|
||||
"kb_id": {"type": "string", "description": "Knowledge base ID for fact operations"},
|
||||
"name": {"type": "string", "description": "Name for new project or knowledge base"},
|
||||
"description": {"type": "string", "description": "Description for new project or KB"},
|
||||
"metadata": {"type": "object", "description": "Additional metadata"},
|
||||
"limit": {"type": "integer", "description": "Limit results", "default": 10},
|
||||
"importance": {"type": "number", "description": "Importance score (0-10)", "default": 1.0},
|
||||
"parent_id": {"type": "string", "description": "Parent fact ID"},
|
||||
"context": {"type": "string", "description": "Context for summarization"},
|
||||
},
|
||||
"required": ["user_id", "project_id", "content"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="recall",
|
||||
description="Search for relevant memories",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID (optional)",
|
||||
},
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of results",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="create_project",
|
||||
description="Create a new project",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"name": {"type": "string", "description": "Project name"},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Project description",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "name"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="create_knowledge_base",
|
||||
description="Create a knowledge base in a project",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base name",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base description",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "project_id", "name"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="add_fact",
|
||||
description="Add a fact to a knowledge base",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"kb_id": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base ID",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Fact content",
|
||||
},
|
||||
"parent_id": {
|
||||
"type": "string",
|
||||
"description": "Parent fact ID (optional)",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional metadata",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "kb_id", "content"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="search_facts",
|
||||
description="Search facts in a knowledge base",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"kb_id": {
|
||||
"type": "string",
|
||||
"description": "Knowledge base ID",
|
||||
},
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of results",
|
||||
"default": 10,
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "kb_id", "query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="summarize_for_knowledge",
|
||||
description="Analyze content and generate knowledge update instructions",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to analyze",
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Additional context",
|
||||
},
|
||||
"skip_summarization": {
|
||||
"type": "boolean",
|
||||
"description": "Skip summarization",
|
||||
"default": False,
|
||||
},
|
||||
"provided_summary": {
|
||||
"type": "string",
|
||||
"description": "Pre-made summary",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
"required": ["action", "user_id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -213,53 +104,67 @@ class MCPMemoryServer:
|
||||
) -> list[TextContent]:
|
||||
"""Handle tool calls."""
|
||||
try:
|
||||
if name == "remember":
|
||||
result = await self._handle_remember(arguments or {})
|
||||
elif name == "recall":
|
||||
result = await self._handle_recall(arguments or {})
|
||||
elif name == "create_project":
|
||||
result = await self._handle_create_project(arguments or {})
|
||||
elif name == "create_knowledge_base":
|
||||
result = await self._handle_create_knowledge_base(arguments or {})
|
||||
elif name == "add_fact":
|
||||
result = await self._handle_add_fact(arguments or {})
|
||||
elif name == "search_facts":
|
||||
result = await self._handle_search_facts(arguments or {})
|
||||
elif name == "summarize_for_knowledge":
|
||||
result = await self._handle_summarize_for_knowledge(arguments or {})
|
||||
if name != "memory":
|
||||
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: {name}"}))]
|
||||
|
||||
args = arguments or {}
|
||||
action = args.get("action")
|
||||
|
||||
if action == "remember":
|
||||
result = await self._handle_remember(args)
|
||||
elif action == "recall":
|
||||
result = await self._handle_recall(args)
|
||||
elif action == "delete":
|
||||
result = await self._handle_delete_memory(args)
|
||||
elif action == "create_project":
|
||||
result = await self._handle_create_project(args)
|
||||
elif action == "create_kb":
|
||||
result = await self._handle_create_knowledge_base(args)
|
||||
elif action == "add_fact":
|
||||
result = await self._handle_add_fact(args)
|
||||
elif action == "search_facts":
|
||||
result = await self._handle_search_facts(args)
|
||||
elif action == "delete_fact":
|
||||
result = await self._handle_delete_fact(args)
|
||||
elif action == "summarize":
|
||||
result = await self._handle_summarize_for_knowledge(args)
|
||||
else:
|
||||
result = {"error": f"Unknown tool: {name}"}
|
||||
return [TextContent(type="text", text=json.dumps({"error": f"Unknown action: {action}"}))]
|
||||
|
||||
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling tool {name}: {e}")
|
||||
logger.error(f"Error handling memory action {arguments.get('action')}: {e}")
|
||||
return [TextContent(type="text", text=json.dumps({"error": str(e)}))]
|
||||
|
||||
async def _handle_remember(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle remember tool."""
|
||||
"""Handle remember action."""
|
||||
user_id = args["user_id"]
|
||||
project_id = args["project_id"]
|
||||
content = args["content"]
|
||||
project_id = args.get("project_id", "default")
|
||||
content = args.get("content")
|
||||
if not content:
|
||||
raise ValueError("content is required for remember action")
|
||||
|
||||
metadata = args.get("metadata", {})
|
||||
importance = args.get("importance", 1.0)
|
||||
|
||||
# Ensure user's memory table exists
|
||||
self.db_client.create_memories_table(user_id)
|
||||
|
||||
# Generate embedding
|
||||
embedding = self.embedding_service.embed_text(content)[0]
|
||||
|
||||
# Store memory
|
||||
# Run embedding generation in thread pool
|
||||
embedding = (await asyncio.to_thread(self.embedding_service.embed_text, content))[0]
|
||||
memory_id = str(uuid4())
|
||||
self.db_client.add_memory(
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
content=content,
|
||||
embedding=embedding,
|
||||
metadata=metadata,
|
||||
importance=importance,
|
||||
)
|
||||
|
||||
# Run DB operations in thread pool
|
||||
def _store():
|
||||
self.db_client.create_memories_table(user_id)
|
||||
self.db_client.add_memory(
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
content=content,
|
||||
embedding=embedding,
|
||||
metadata=metadata,
|
||||
importance=importance,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(_store)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -268,17 +173,21 @@ class MCPMemoryServer:
|
||||
}
|
||||
|
||||
async def _handle_recall(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle recall tool."""
|
||||
"""Handle recall action."""
|
||||
user_id = args["user_id"]
|
||||
project_id = args.get("project_id")
|
||||
query = args["query"]
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
raise ValueError("query is required for recall action")
|
||||
|
||||
limit = args.get("limit", 10)
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = self.embedding_service.embed_text(query)[0]
|
||||
query_embedding = (await asyncio.to_thread(self.embedding_service.embed_text, query))[0]
|
||||
|
||||
# Search memories
|
||||
results_df = self.db_client.search_memories(
|
||||
results_df = await asyncio.to_thread(
|
||||
self.db_client.search_memories,
|
||||
user_id=user_id,
|
||||
query_embedding=query_embedding,
|
||||
project_id=project_id,
|
||||
@@ -287,15 +196,17 @@ class MCPMemoryServer:
|
||||
|
||||
# Convert results
|
||||
memories = []
|
||||
if not results_df.is_empty():
|
||||
if isinstance(results_df, list):
|
||||
memories = results_df # It's a list of dicts from sqlite client fallback or non-polars return
|
||||
elif not results_df.is_empty():
|
||||
for row in results_df.to_dicts():
|
||||
memories.append(
|
||||
{
|
||||
"memory_id": row["memory_id"],
|
||||
"content": row["content"],
|
||||
"metadata": json.loads(row.get("metadata", "{}")),
|
||||
"memory_id": row.get("memory_id"),
|
||||
"content": row.get("content"),
|
||||
"metadata": json.loads(row.get("metadata", "{}")) if isinstance(row.get("metadata"), str) else row.get("metadata", {}),
|
||||
"importance": row.get("importance", 1.0),
|
||||
"similarity_score": row.get("_similarity", 0.0),
|
||||
"similarity_score": row.get("_similarity", row.get("similarity_score", 0.0)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -305,16 +216,41 @@ class MCPMemoryServer:
|
||||
"count": len(memories),
|
||||
}
|
||||
|
||||
async def _handle_create_project(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle create project tool."""
|
||||
async def _handle_delete_memory(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle delete memory action."""
|
||||
user_id = args["user_id"]
|
||||
name = args["name"]
|
||||
project_id = args.get("project_id", "default")
|
||||
memory_id = args.get("id")
|
||||
|
||||
if not memory_id:
|
||||
raise ValueError("id is required for delete action")
|
||||
|
||||
success = await asyncio.to_thread(
|
||||
self.db_client.delete_memory,
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Memory deleted" if success else "Memory not found"
|
||||
}
|
||||
|
||||
async def _handle_create_project(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle create project action."""
|
||||
user_id = args["user_id"]
|
||||
name = args.get("name")
|
||||
if not name:
|
||||
raise ValueError("name is required for create_project action")
|
||||
|
||||
description = args.get("description", "")
|
||||
metadata = args.get("metadata", {})
|
||||
|
||||
# Create project
|
||||
project_id = str(uuid4())
|
||||
self.db_client.create_project(
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.db_client.create_project,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
@@ -331,15 +267,19 @@ class MCPMemoryServer:
|
||||
async def _handle_create_knowledge_base(
|
||||
self, args: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Handle create knowledge base tool."""
|
||||
"""Handle create knowledge base action."""
|
||||
user_id = args["user_id"]
|
||||
project_id = args["project_id"]
|
||||
name = args["name"]
|
||||
project_id = args.get("project_id", "default")
|
||||
name = args.get("name")
|
||||
if not name:
|
||||
raise ValueError("name is required for create_kb action")
|
||||
|
||||
description = args.get("description", "")
|
||||
|
||||
# Create knowledge base
|
||||
kb_id = str(uuid4())
|
||||
self.db_client.create_knowledge_base(
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.db_client.create_knowledge_base,
|
||||
kb_id=kb_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
@@ -354,18 +294,23 @@ class MCPMemoryServer:
|
||||
}
|
||||
|
||||
async def _handle_add_fact(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle add fact tool."""
|
||||
kb_id = args["kb_id"]
|
||||
content = args["content"]
|
||||
"""Handle add fact action."""
|
||||
kb_id = args.get("kb_id")
|
||||
if not kb_id:
|
||||
raise ValueError("kb_id is required for add_fact action")
|
||||
|
||||
content = args.get("content")
|
||||
if not content:
|
||||
raise ValueError("content is required for add_fact action")
|
||||
|
||||
parent_id = args.get("parent_id")
|
||||
metadata = args.get("metadata", {})
|
||||
|
||||
# Generate embedding
|
||||
embedding = self.embedding_service.embed_text(content)[0]
|
||||
|
||||
# Add fact
|
||||
embedding = (await asyncio.to_thread(self.embedding_service.embed_text, content))[0]
|
||||
fact_id = str(uuid4())
|
||||
self.db_client.add_fact(
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.db_client.add_fact,
|
||||
fact_id=fact_id,
|
||||
kb_id=kb_id,
|
||||
content=content,
|
||||
@@ -381,32 +326,38 @@ class MCPMemoryServer:
|
||||
}
|
||||
|
||||
async def _handle_search_facts(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle search facts tool."""
|
||||
kb_id = args["kb_id"]
|
||||
query = args["query"]
|
||||
"""Handle search facts action."""
|
||||
kb_id = args.get("kb_id")
|
||||
if not kb_id:
|
||||
raise ValueError("kb_id is required for search_facts action")
|
||||
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
raise ValueError("query is required for search_facts action")
|
||||
|
||||
limit = args.get("limit", 10)
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = self.embedding_service.embed_text(query)[0]
|
||||
query_embedding = (await asyncio.to_thread(self.embedding_service.embed_text, query))[0]
|
||||
|
||||
# Search facts
|
||||
results_df = self.db_client.search_facts(
|
||||
results_df = await asyncio.to_thread(
|
||||
self.db_client.search_facts,
|
||||
kb_id=kb_id,
|
||||
query_embedding=query_embedding,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Convert results
|
||||
facts = []
|
||||
if not results_df.is_empty():
|
||||
if isinstance(results_df, list):
|
||||
facts = results_df
|
||||
elif not results_df.is_empty():
|
||||
for row in results_df.to_dicts():
|
||||
facts.append(
|
||||
{
|
||||
"fact_id": row["fact_id"],
|
||||
"content": row["content"],
|
||||
"fact_id": row.get("fact_id"),
|
||||
"content": row.get("content"),
|
||||
"parent_id": row.get("parent_id"),
|
||||
"metadata": json.loads(row.get("metadata", "{}")),
|
||||
"similarity_score": row.get("_similarity", 0.0),
|
||||
"metadata": json.loads(row.get("metadata", "{}")) if isinstance(row.get("metadata"), str) else row.get("metadata", {}),
|
||||
"similarity_score": row.get("_similarity", row.get("similarity_score", 0.0)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -416,17 +367,40 @@ class MCPMemoryServer:
|
||||
"count": len(facts),
|
||||
}
|
||||
|
||||
async def _handle_delete_fact(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle delete fact action."""
|
||||
kb_id = args.get("kb_id")
|
||||
fact_id = args.get("id")
|
||||
if not kb_id or not fact_id:
|
||||
raise ValueError("kb_id and id are required for delete_fact action")
|
||||
|
||||
success = await asyncio.to_thread(
|
||||
self.db_client.delete_fact,
|
||||
fact_id=fact_id,
|
||||
knowledge_base_id=kb_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Fact deleted" if success else "Fact not found"
|
||||
}
|
||||
|
||||
async def _handle_summarize_for_knowledge(
|
||||
self, args: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Handle summarize for knowledge tool."""
|
||||
content = args["content"]
|
||||
"""Handle summarize for knowledge action."""
|
||||
content = args.get("content")
|
||||
if not content:
|
||||
raise ValueError("content is required for summarize action")
|
||||
|
||||
context = args.get("context")
|
||||
skip_summarization = args.get("skip_summarization", False)
|
||||
provided_summary = args.get("provided_summary")
|
||||
|
||||
# Use LLM service to generate knowledge instructions
|
||||
result = self.llm_service.summarize_for_knowledge(
|
||||
# LLM service might be IO bound depending on implementation, assume safe to verify async later or wrap now
|
||||
# Wrapping just in case
|
||||
result = await asyncio.to_thread(
|
||||
self.llm_service.summarize_for_knowledge,
|
||||
content=content,
|
||||
context=context,
|
||||
skip_summarization=skip_summarization,
|
||||
@@ -452,8 +426,10 @@ class MCPMemoryServer:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for MCP server."""
|
||||
import asyncio
|
||||
|
||||
"""Run the MCP memory server."""
|
||||
server = MCPMemoryServer()
|
||||
asyncio.run(server.run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -61,7 +61,7 @@ class MemoryWithScore(Memory):
|
||||
class MemoryResponse(BaseModel):
|
||||
"""Memory response model with search results."""
|
||||
|
||||
memory: Memory = Field(..., description="Memory object")
|
||||
memory: Memory | None = Field(None, description="Memory object")
|
||||
similarity: float | None = Field(None, description="Similarity score")
|
||||
relevance_score: float | None = Field(None, description="Relevance score")
|
||||
|
||||
|
||||
@@ -524,6 +524,8 @@ async def get_facts(
|
||||
status_code=400,
|
||||
detail="Query parameter is required to search facts. Provide a search query.",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting facts: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from .embeddings import EmbeddingService, get_embedding_service
|
||||
from .llm import LLMService, get_llm_service
|
||||
from .memory import MemoryService, get_memory_service
|
||||
from .memory import MemoryService, get_memory_service, reset_memory_service
|
||||
|
||||
__all__ = [
|
||||
"EmbeddingService",
|
||||
@@ -11,4 +11,5 @@ __all__ = [
|
||||
"get_llm_service",
|
||||
"MemoryService",
|
||||
"get_memory_service",
|
||||
"reset_memory_service",
|
||||
]
|
||||
|
||||
@@ -108,12 +108,21 @@ class MemoryService:
|
||||
limit=limit * 2 if filter_with_llm else limit, # Get more if filtering
|
||||
)
|
||||
|
||||
if results_df.empty:
|
||||
# Handle both list and DataFrame results
|
||||
if isinstance(results_df, list):
|
||||
results = results_df
|
||||
else:
|
||||
# DataFrame case
|
||||
if results_df.empty:
|
||||
return []
|
||||
results = results_df.to_dicts() if hasattr(results_df, 'to_dicts') else results_df.to_dict('records')
|
||||
|
||||
if not results:
|
||||
return []
|
||||
|
||||
# Convert to memories with scores
|
||||
memories = []
|
||||
for _, row in results_df.iterrows():
|
||||
for row in results:
|
||||
# Get similarity score if available, otherwise calculate it
|
||||
if "similarity_score" in row:
|
||||
score = row["similarity_score"]
|
||||
@@ -282,3 +291,9 @@ def get_memory_service() -> MemoryService:
|
||||
if _memory_service is None:
|
||||
_memory_service = MemoryService()
|
||||
return _memory_service
|
||||
|
||||
|
||||
def reset_memory_service() -> None:
|
||||
"""Reset the global memory service (useful for testing)."""
|
||||
global _memory_service
|
||||
_memory_service = None
|
||||
|
||||
@@ -11,12 +11,18 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from hanzo_memory.config import settings
|
||||
from hanzo_memory.db.client import InfinityClient
|
||||
from hanzo_memory.db import reset_db_client
|
||||
from hanzo_memory.services import reset_memory_service
|
||||
from hanzo_memory.server import app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def test_settings(monkeypatch):
|
||||
"""Configure test settings."""
|
||||
# Reset any cached clients/services before each test
|
||||
reset_db_client()
|
||||
reset_memory_service()
|
||||
|
||||
# Use temporary directory for testing
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
monkeypatch.setattr(settings, "infinity_db_path", Path(tmpdir) / "test_db")
|
||||
@@ -24,6 +30,10 @@ def test_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "llm_model", "gpt-3.5-turbo")
|
||||
yield
|
||||
|
||||
# Reset again after test
|
||||
reset_db_client()
|
||||
reset_memory_service()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_embedding_model():
|
||||
|
||||
@@ -162,6 +162,7 @@ class TestKnowledgeAPI:
|
||||
|
||||
def test_get_facts_without_query(self):
|
||||
"""Test getting facts without a search query."""
|
||||
# API requires query parameter
|
||||
response = self.client.post(
|
||||
"/v1/kb/facts/get",
|
||||
json={
|
||||
@@ -170,7 +171,18 @@ class TestKnowledgeAPI:
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
# With query, should return empty results
|
||||
response = self.client.post(
|
||||
"/v1/kb/facts/get",
|
||||
json={
|
||||
"userid": "user123",
|
||||
"kb_id": "kb123",
|
||||
"query": "test",
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["kb_id"] == "kb123"
|
||||
|
||||
@@ -11,7 +11,7 @@ from hanzo_memory.mcp.server import MCPMemoryServer
|
||||
def mcp_server():
|
||||
"""Create MCP server instance."""
|
||||
with (
|
||||
patch("hanzo_memory.mcp.server.get_client"),
|
||||
patch("hanzo_memory.mcp.server.get_db_client"),
|
||||
patch("hanzo_memory.mcp.server.EmbeddingService"),
|
||||
patch("hanzo_memory.mcp.server.LLMService"),
|
||||
):
|
||||
|
||||
@@ -97,19 +97,21 @@ class TestMemoryAPI:
|
||||
|
||||
def test_get_memories(self, client, sample_user_id):
|
||||
"""Test /v1/memories/get endpoint."""
|
||||
# Without memoryid, API returns 400
|
||||
request_data = {
|
||||
"userid": sample_user_id,
|
||||
"limit": 10,
|
||||
}
|
||||
|
||||
response = client.post("/v1/memories/get", json=request_data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
data = response.json()
|
||||
assert data["user_id"] == sample_user_id
|
||||
assert "memories" in data
|
||||
assert "pagination" in data
|
||||
assert "usage_info" in data
|
||||
# With memoryid but non-existent, returns 404
|
||||
request_data = {
|
||||
"userid": sample_user_id,
|
||||
"memoryid": "non-existent-memory",
|
||||
}
|
||||
response = client.post("/v1/memories/get", json=request_data)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_delete_user_memories(self, client, sample_user_id):
|
||||
"""Test /v1/user/delete endpoint."""
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
from hanzo_memory.mcp.server import MCPMemoryServer
|
||||
|
||||
@pytest.fixture
|
||||
def memory_server():
|
||||
server = MCPMemoryServer()
|
||||
# Mock services to avoid external dependencies
|
||||
server.db_client = MagicMock()
|
||||
server.embedding_service = MagicMock()
|
||||
server.llm_service = MagicMock()
|
||||
|
||||
# Mock embedding return
|
||||
server.embedding_service.embed_text.return_value = [[0.1, 0.2, 0.3]]
|
||||
|
||||
# Mock DB returns
|
||||
server.db_client.search_memories.return_value = []
|
||||
server.db_client.search_facts.return_value = []
|
||||
server.db_client.delete_memory.return_value = True
|
||||
server.db_client.delete_fact.return_value = True
|
||||
|
||||
return server
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remember_and_recall(memory_server):
|
||||
# Test remember
|
||||
remember_args = {
|
||||
"user_id": "user1",
|
||||
"content": "Test memory",
|
||||
"action": "remember"
|
||||
}
|
||||
result = await memory_server._handle_remember(remember_args)
|
||||
assert result["success"] is True
|
||||
assert "memory_id" in result
|
||||
|
||||
# Verify DB call
|
||||
memory_server.db_client.add_memory.assert_called_once()
|
||||
|
||||
# Test recall
|
||||
recall_args = {
|
||||
"user_id": "user1",
|
||||
"query": "Test",
|
||||
"action": "recall"
|
||||
}
|
||||
result = await memory_server._handle_recall(recall_args)
|
||||
assert result["success"] is True
|
||||
assert "memories" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_memory(memory_server):
|
||||
delete_args = {
|
||||
"user_id": "user1",
|
||||
"id": "mem_123",
|
||||
"action": "delete"
|
||||
}
|
||||
result = await memory_server._handle_delete_memory(delete_args)
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify DB call
|
||||
memory_server.db_client.delete_memory.assert_called_once_with(
|
||||
memory_id="mem_123",
|
||||
user_id="user1",
|
||||
project_id="default"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_base_operations(memory_server):
|
||||
# Test create KB
|
||||
kb_args = {
|
||||
"user_id": "user1",
|
||||
"name": "My KB",
|
||||
"action": "create_kb"
|
||||
}
|
||||
result = await memory_server._handle_create_knowledge_base(kb_args)
|
||||
assert result["success"] is True
|
||||
assert "kb_id" in result
|
||||
kb_id = result["kb_id"]
|
||||
|
||||
# Test add fact
|
||||
fact_args = {
|
||||
"user_id": "user1",
|
||||
"kb_id": kb_id,
|
||||
"content": "A fact",
|
||||
"action": "add_fact"
|
||||
}
|
||||
result = await memory_server._handle_add_fact(fact_args)
|
||||
assert result["success"] is True
|
||||
assert "fact_id" in result
|
||||
fact_id = result["fact_id"]
|
||||
|
||||
# Test delete fact
|
||||
del_fact_args = {
|
||||
"user_id": "user1", # required by schema though not strictly used in delete_fact impl
|
||||
"kb_id": kb_id,
|
||||
"id": fact_id,
|
||||
"action": "delete_fact"
|
||||
}
|
||||
result = await memory_server._handle_delete_fact(del_fact_args)
|
||||
assert result["success"] is True
|
||||
|
||||
memory_server.db_client.delete_fact.assert_called_once_with(
|
||||
fact_id=fact_id,
|
||||
knowledge_base_id=kb_id
|
||||
)
|
||||
+20
-1
@@ -59,13 +59,17 @@ dev-dependencies = [
|
||||
"importlib-metadata>=6.7.0",
|
||||
"rich>=13.7.1",
|
||||
"nest_asyncio==1.6.0",
|
||||
# hanzo-memory test dependencies
|
||||
"polars>=1.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"fastapi>=0.115.0",
|
||||
"fastembed>=0.4.0",
|
||||
]
|
||||
|
||||
[tool.rye.workspace]
|
||||
members = [
|
||||
"pkg/hanzo",
|
||||
"pkg/hanzo-aci",
|
||||
"pkg/hanzo-agent",
|
||||
"pkg/hanzo-mcp",
|
||||
"pkg/hanzo-memory",
|
||||
"pkg/hanzo-network",
|
||||
@@ -109,6 +113,13 @@ build-backend = "hatchling.build"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"build>=1.2.2.post1",
|
||||
"fastapi>=0.128.0",
|
||||
"fastembed>=0.7.4",
|
||||
"hanzo-memory",
|
||||
"httpx>=0.28.1",
|
||||
"polars>=1.36.1",
|
||||
"pytest>=8.4.2",
|
||||
"pytest-asyncio>=1.2.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build]
|
||||
@@ -421,6 +432,14 @@ exclude = [
|
||||
"tests/",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = [
|
||||
"pkg/hanzo-memory",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
hanzo-memory = { workspace = true }
|
||||
|
||||
[tool.ruff.lint.extend-per-file-ignores]
|
||||
# Allow print statements in scripts and examples
|
||||
"bin/**.py" = ["T201", "T203"]
|
||||
|
||||
+273
-217
File diff suppressed because it is too large
Load Diff
+307
-251
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user