feat: add Phase F.1 memory infrastructure
Add multi-tenancy support and memory storage infrastructure:
- Add ContextVar-based request context (src/core/context.py)
- Async-safe user/conversation tracking via contextvars
- RequestContext manager for clean setup/teardown
- get_user(), get_conversation_id() helpers
- Add multi-tenancy utilities (src/core/multi_tenancy.py)
- User ID sanitization for collection/key names
- get_memory_collection_name(), get_session_key() helpers
- Add Ollama embedding client (src/core/embeddings.py)
- nomic-embed-text model (768 dimensions)
- embed(), embed_batch(), health_check() methods
- Add Qdrant client wrapper (src/core/qdrant.py)
- Per-user collection pattern: memories_{user}
- upsert_memory(), search_memories(), delete_memory()
- Type-based filtering support
- Add Redis memory cache (src/core/memory_cache.py)
- Session context with 24h TTL
- Recent entities tracking
- Separate from benchmarks (db=2)
- Update config with memory settings
- QDRANT_HOST, QDRANT_PORT, QDRANT_EMBEDDING_DIM
- OLLAMA_EMBEDDING_MODEL
- REDIS_MEMORY_DB, REDIS_MEMORY_TTL_HOURS
- Add user field to ResponseRequest (OpenAI standard)
- Set context in router, reset in finally block
- Update librarian client to use get_user() (12 methods)
All 333 unit tests pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,12 @@ This document contains instructions and documentation references for AI assistan
|
|||||||
* **Act:** Execute the changes in small, atomic steps.
|
* **Act:** Execute the changes in small, atomic steps.
|
||||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||||
|
|
||||||
|
### 🌐 Internal Service Access
|
||||||
|
* **git.schweitz.net**: Access via `http://localhost:3002` (direct Gitea) to bypass Authentik SSO
|
||||||
|
* Example: `curl http://localhost:3002/jpmschweitzer/library-desk/raw/branch/main/README.md`
|
||||||
|
* Public repos are readable without authentication
|
||||||
|
* Related repos: `library-desk`, `scheduler`
|
||||||
|
|
||||||
### 🛡️ Git Discipline
|
### 🛡️ Git Discipline
|
||||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ starlette>=0.45,<0.46
|
|||||||
# hiredis: C parser for better performance
|
# hiredis: C parser for better performance
|
||||||
redis[hiredis]>=5.2,<6.0
|
redis[hiredis]>=5.2,<6.0
|
||||||
|
|
||||||
|
# Qdrant vector database client for memory storage
|
||||||
|
# Latest: 1.12.1 (Dec 2025) - No known CVEs
|
||||||
|
qdrant-client>=1.12,<2.0
|
||||||
|
|
||||||
# Structured logging for observability
|
# Structured logging for observability
|
||||||
# Latest: 24.4.0 (Aug 22, 2024) - No known CVEs
|
# Latest: 24.4.0 (Aug 22, 2024) - No known CVEs
|
||||||
structlog>=24.1,<25.0
|
structlog>=24.1,<25.0
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import httpx
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from src.core.config import config
|
from src.core.config import config
|
||||||
|
from src.core.context import get_user
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -179,7 +180,7 @@ class LibraryDeskClient:
|
|||||||
async def hybrid_search(
|
async def hybrid_search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
vector_limit: int = 10,
|
vector_limit: int = 10,
|
||||||
graph_limit: int = 10,
|
graph_limit: int = 10,
|
||||||
web_limit: int = 5,
|
web_limit: int = 5,
|
||||||
@@ -191,7 +192,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
user: User identifier for multi-tenancy
|
user: User identifier for multi-tenancy (defaults to request context)
|
||||||
vector_limit: Max results from vector search
|
vector_limit: Max results from vector search
|
||||||
graph_limit: Max results from graph search
|
graph_limit: Max results from graph search
|
||||||
web_limit: Max results from web search
|
web_limit: Max results from web search
|
||||||
@@ -201,6 +202,7 @@ class LibraryDeskClient:
|
|||||||
Returns:
|
Returns:
|
||||||
HybridRAGResponse with ranked results and context
|
HybridRAGResponse with ranked results and context
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -255,7 +257,7 @@ class LibraryDeskClient:
|
|||||||
async def search_wiki(
|
async def search_wiki(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
) -> list[WikiSearchResult]:
|
) -> list[WikiSearchResult]:
|
||||||
"""
|
"""
|
||||||
@@ -263,12 +265,13 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
limit: Maximum results
|
limit: Maximum results
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of matching wiki pages
|
List of matching wiki pages
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
logger.debug("library_desk_wiki_search", query=query, user=user)
|
logger.debug("library_desk_wiki_search", query=query, user=user)
|
||||||
@@ -285,18 +288,19 @@ class LibraryDeskClient:
|
|||||||
async def get_wiki_page(
|
async def get_wiki_page(
|
||||||
self,
|
self,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
) -> WikiPage:
|
) -> WikiPage:
|
||||||
"""
|
"""
|
||||||
Get a wiki page by ID.
|
Get a wiki page by ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
page_id: Page ID
|
page_id: Page ID
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
WikiPage with full content
|
WikiPage with full content
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@@ -309,7 +313,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
async def list_wiki_pages(
|
async def list_wiki_pages(
|
||||||
self,
|
self,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list[WikiPage]:
|
) -> list[WikiPage]:
|
||||||
@@ -317,13 +321,14 @@ class LibraryDeskClient:
|
|||||||
List wiki pages, optionally filtered by tag.
|
List wiki pages, optionally filtered by tag.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
tag: Optional tag (dossier) to filter by
|
tag: Optional tag (dossier) to filter by
|
||||||
limit: Maximum pages to return
|
limit: Maximum pages to return
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of wiki pages
|
List of wiki pages
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
params: dict[str, Any] = {"user": user, "limit": limit}
|
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||||
@@ -341,7 +346,7 @@ class LibraryDeskClient:
|
|||||||
title: str,
|
title: str,
|
||||||
path: str,
|
path: str,
|
||||||
content: str,
|
content: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
) -> WikiPage:
|
) -> WikiPage:
|
||||||
@@ -352,13 +357,14 @@ class LibraryDeskClient:
|
|||||||
title: Page title
|
title: Page title
|
||||||
path: Page path (e.g., "/projects/my-project")
|
path: Page path (e.g., "/projects/my-project")
|
||||||
content: Markdown content
|
content: Markdown content
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
description: Short description
|
description: Short description
|
||||||
tags: List of tags (dossiers)
|
tags: List of tags (dossiers)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created WikiPage
|
Created WikiPage
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -380,7 +386,7 @@ class LibraryDeskClient:
|
|||||||
async def update_wiki_page(
|
async def update_wiki_page(
|
||||||
self,
|
self,
|
||||||
page_id: int,
|
page_id: int,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
content: Optional[str] = None,
|
content: Optional[str] = None,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
@@ -394,7 +400,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
page_id: ID of the page to update
|
page_id: ID of the page to update
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
content: New content (optional)
|
content: New content (optional)
|
||||||
title: New title (optional)
|
title: New title (optional)
|
||||||
tags: New tags list (optional)
|
tags: New tags list (optional)
|
||||||
@@ -403,6 +409,7 @@ class LibraryDeskClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Updated WikiPage
|
Updated WikiPage
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
# Build update payload with only provided fields
|
# Build update payload with only provided fields
|
||||||
@@ -435,7 +442,7 @@ class LibraryDeskClient:
|
|||||||
self,
|
self,
|
||||||
topic: str,
|
topic: str,
|
||||||
tags: list[str],
|
tags: list[str],
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
path: Optional[str] = None,
|
path: Optional[str] = None,
|
||||||
include_web_research: bool = True,
|
include_web_research: bool = True,
|
||||||
include_wiki_search: bool = True,
|
include_wiki_search: bool = True,
|
||||||
@@ -460,6 +467,7 @@ class LibraryDeskClient:
|
|||||||
Returns:
|
Returns:
|
||||||
SmartCreateResponse with page and research metadata
|
SmartCreateResponse with page and research metadata
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -499,17 +507,18 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
async def list_dossiers(
|
async def list_dossiers(
|
||||||
self,
|
self,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
) -> list[Dossier]:
|
) -> list[Dossier]:
|
||||||
"""
|
"""
|
||||||
List all dossiers (tag collections) for a user.
|
List all dossiers (tag collections) for a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dossiers with page counts
|
List of dossiers with page counts
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@@ -528,7 +537,7 @@ class LibraryDeskClient:
|
|||||||
async def semantic_search(
|
async def semantic_search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
score_threshold: float = 0.5,
|
score_threshold: float = 0.5,
|
||||||
) -> list[VectorSearchResult]:
|
) -> list[VectorSearchResult]:
|
||||||
@@ -537,13 +546,14 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Natural language query
|
query: Natural language query
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
limit: Maximum results
|
limit: Maximum results
|
||||||
score_threshold: Minimum similarity score
|
score_threshold: Minimum similarity score
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of matching document chunks with scores
|
List of matching document chunks with scores
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -568,7 +578,7 @@ class LibraryDeskClient:
|
|||||||
async def query_graph(
|
async def query_graph(
|
||||||
self,
|
self,
|
||||||
cypher_query: str,
|
cypher_query: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
parameters: Optional[dict[str, Any]] = None,
|
parameters: Optional[dict[str, Any]] = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
@@ -578,12 +588,13 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
cypher_query: Cypher query string
|
cypher_query: Cypher query string
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
parameters: Query parameters
|
parameters: Query parameters
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of result records
|
List of result records
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -601,7 +612,7 @@ class LibraryDeskClient:
|
|||||||
|
|
||||||
async def list_graph_nodes(
|
async def list_graph_nodes(
|
||||||
self,
|
self,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
node_type: Optional[str] = None,
|
node_type: Optional[str] = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> list[GraphNode]:
|
) -> list[GraphNode]:
|
||||||
@@ -609,13 +620,14 @@ class LibraryDeskClient:
|
|||||||
List nodes in the knowledge graph.
|
List nodes in the knowledge graph.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
node_type: Optional filter by type (Document, Person, Concept, etc.)
|
node_type: Optional filter by type (Document, Person, Concept, etc.)
|
||||||
limit: Maximum nodes
|
limit: Maximum nodes
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of graph nodes
|
List of graph nodes
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
params: dict[str, Any] = {"user": user, "limit": limit}
|
params: dict[str, Any] = {"user": user, "limit": limit}
|
||||||
@@ -631,18 +643,19 @@ class LibraryDeskClient:
|
|||||||
async def get_graph_node(
|
async def get_graph_node(
|
||||||
self,
|
self,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
user: str = "jpmschweitzer",
|
user: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get detailed information about a graph node.
|
Get detailed information about a graph node.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
node_id: Node ID
|
node_id: Node ID
|
||||||
user: User identifier
|
user: User identifier (defaults to request context)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Node with relationships and connected nodes
|
Node with relationships and connected nodes
|
||||||
"""
|
"""
|
||||||
|
user = user or get_user()
|
||||||
client = self._ensure_client()
|
client = self._ensure_client()
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
|
|||||||
+41
-1
@@ -124,6 +124,36 @@ class Config(BaseSettings):
|
|||||||
description="Library-Desk request timeout in seconds"
|
description="Library-Desk request timeout in seconds"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Qdrant Configuration (Memory vector storage)
|
||||||
|
QDRANT_HOST: str = Field(
|
||||||
|
default="localhost",
|
||||||
|
description="Qdrant server host"
|
||||||
|
)
|
||||||
|
QDRANT_PORT: int = Field(
|
||||||
|
default=6333,
|
||||||
|
description="Qdrant server port"
|
||||||
|
)
|
||||||
|
QDRANT_EMBEDDING_DIM: int = Field(
|
||||||
|
default=768,
|
||||||
|
description="Embedding dimension (768 for nomic-embed-text)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ollama Embedding Configuration
|
||||||
|
OLLAMA_EMBEDDING_MODEL: str = Field(
|
||||||
|
default="nomic-embed-text",
|
||||||
|
description="Ollama model for embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redis Memory Database (separate from benchmarks)
|
||||||
|
REDIS_MEMORY_DB: int = Field(
|
||||||
|
default=2,
|
||||||
|
description="Redis database number for memory cache"
|
||||||
|
)
|
||||||
|
REDIS_MEMORY_TTL_HOURS: int = Field(
|
||||||
|
default=24,
|
||||||
|
description="TTL for session context in hours"
|
||||||
|
)
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
|
LOG_LEVEL: str = Field(default="INFO", description="Logging level")
|
||||||
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
|
ENABLE_BENCHMARKS: bool = Field(default=True, description="Enable performance benchmarking")
|
||||||
@@ -139,9 +169,19 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def redis_url(self) -> str:
|
def redis_url(self) -> str:
|
||||||
"""Construct Redis connection URL."""
|
"""Construct Redis connection URL for benchmarks."""
|
||||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis_memory_url(self) -> str:
|
||||||
|
"""Construct Redis connection URL for memory cache."""
|
||||||
|
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_MEMORY_DB}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qdrant_url(self) -> str:
|
||||||
|
"""Construct Qdrant server URL."""
|
||||||
|
return f"http://{self.QDRANT_HOST}:{self.QDRANT_PORT}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def log_format(self) -> str:
|
def log_format(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Request context using ContextVar for async-safe user/conversation tracking.
|
||||||
|
|
||||||
|
ContextVar provides task-local storage that automatically propagates through
|
||||||
|
async calls, eliminating the need to thread user identity through every function.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# At request entry (router):
|
||||||
|
token = current_user.set(request.user or "jpmschweitzer")
|
||||||
|
try:
|
||||||
|
await service.process(request)
|
||||||
|
finally:
|
||||||
|
current_user.reset(token)
|
||||||
|
|
||||||
|
# Anywhere in the codebase:
|
||||||
|
from src.core.context import get_user
|
||||||
|
user = get_user() # Returns current request's user
|
||||||
|
"""
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
# Default user for single-user homelab setup
|
||||||
|
DEFAULT_USER = "jpmschweitzer"
|
||||||
|
|
||||||
|
# Request-scoped context variables (async-safe, isolated per request)
|
||||||
|
current_user: ContextVar[str] = ContextVar("current_user", default=DEFAULT_USER)
|
||||||
|
current_conversation: ContextVar[str | None] = ContextVar(
|
||||||
|
"current_conversation", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user() -> str:
|
||||||
|
"""
|
||||||
|
Get current user from request context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User identifier for the current request.
|
||||||
|
Falls back to DEFAULT_USER if not set.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
user = get_user() # "jpmschweitzer" or whatever was set in router
|
||||||
|
"""
|
||||||
|
return current_user.get()
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_id() -> str | None:
|
||||||
|
"""
|
||||||
|
Get current conversation ID from request context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Conversation ID if set, None otherwise.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
conv_id = get_conversation_id() # "conv_abc123" or None
|
||||||
|
"""
|
||||||
|
return current_conversation.get()
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContext:
|
||||||
|
"""
|
||||||
|
Context manager for setting request-scoped context.
|
||||||
|
|
||||||
|
Provides a cleaner alternative to manual token management.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with RequestContext(user="alice", conversation_id="conv_123"):
|
||||||
|
# All code here sees user="alice"
|
||||||
|
result = await some_service.process()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user: str | None = None,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize request context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier (defaults to DEFAULT_USER if None)
|
||||||
|
conversation_id: Conversation ID (optional)
|
||||||
|
"""
|
||||||
|
self.user = user or DEFAULT_USER
|
||||||
|
self.conversation_id = conversation_id
|
||||||
|
self._user_token = None
|
||||||
|
self._conv_token = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "RequestContext":
|
||||||
|
"""Set context variables on entry."""
|
||||||
|
self._user_token = current_user.set(self.user)
|
||||||
|
self._conv_token = current_conversation.set(self.conversation_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Reset context variables on exit."""
|
||||||
|
if self._user_token is not None:
|
||||||
|
current_user.reset(self._user_token)
|
||||||
|
if self._conv_token is not None:
|
||||||
|
current_conversation.reset(self._conv_token)
|
||||||
|
|
||||||
|
def __enter__(self) -> "RequestContext":
|
||||||
|
"""Sync context manager entry (for non-async code)."""
|
||||||
|
self._user_token = current_user.set(self.user)
|
||||||
|
self._conv_token = current_conversation.set(self.conversation_id)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Sync context manager exit."""
|
||||||
|
if self._user_token is not None:
|
||||||
|
current_user.reset(self._user_token)
|
||||||
|
if self._conv_token is not None:
|
||||||
|
current_conversation.reset(self._conv_token)
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Ollama client for embeddings generation.
|
||||||
|
|
||||||
|
Provides async embedding operations via Ollama API:
|
||||||
|
- Text embedding generation
|
||||||
|
- Batch embedding support
|
||||||
|
- Health checks
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaEmbeddingClient:
|
||||||
|
"""
|
||||||
|
Ollama API client for embeddings.
|
||||||
|
|
||||||
|
Uses the Ollama embeddings endpoint to generate vector representations
|
||||||
|
of text using the nomic-embed-text model (768 dimensions).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = OllamaEmbeddingClient()
|
||||||
|
embedding = await client.embed("Hello world")
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
Or with context manager:
|
||||||
|
async with OllamaEmbeddingClient() as client:
|
||||||
|
embedding = await client.embed("Hello world")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Ollama embedding client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Ollama server URL (defaults to config.OLLAMA_HOST)
|
||||||
|
model: Embedding model name (defaults to config.OLLAMA_EMBEDDING_MODEL)
|
||||||
|
timeout: Request timeout in seconds (embeddings can be slow)
|
||||||
|
"""
|
||||||
|
self.base_url = (base_url or str(config.OLLAMA_HOST)).rstrip("/")
|
||||||
|
self.model = model or config.OLLAMA_EMBEDDING_MODEL
|
||||||
|
self.embeddings_url = f"{self.base_url}/api/embeddings"
|
||||||
|
self.tags_url = f"{self.base_url}/api/tags"
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"ollama_embedding_client_initialized",
|
||||||
|
base_url=self.base_url,
|
||||||
|
model=self.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Get or create HTTP client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = httpx.AsyncClient(timeout=self._timeout)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "OllamaEmbeddingClient":
|
||||||
|
"""Async context manager entry."""
|
||||||
|
await self._get_client()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close HTTP client."""
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def embed(self, text: str) -> list[float] | None:
|
||||||
|
"""
|
||||||
|
Generate embedding for single text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to embed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Embedding vector (768-dimensional for nomic-embed-text) or None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> embedding = await client.embed("Hello world")
|
||||||
|
>>> len(embedding)
|
||||||
|
768
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(self.embeddings_url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
embedding = data.get("embedding")
|
||||||
|
if not embedding:
|
||||||
|
logger.error("ollama_embed_no_embedding", response_data=data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return embedding
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
"ollama_embed_http_error",
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=e.response.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ollama_embed_failed", error=str(e), exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def embed_batch(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
show_progress: bool = False,
|
||||||
|
) -> list[list[float] | None]:
|
||||||
|
"""
|
||||||
|
Generate embeddings for multiple texts.
|
||||||
|
|
||||||
|
Note: Ollama doesn't support native batch embeddings, so this
|
||||||
|
sequentially calls embed() for each text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed
|
||||||
|
show_progress: Log progress for large batches
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of embedding vectors (same order as input)
|
||||||
|
None entries for texts that failed to embed
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> texts = ["Hello", "World", "Test"]
|
||||||
|
>>> embeddings = await client.embed_batch(texts)
|
||||||
|
>>> len(embeddings)
|
||||||
|
3
|
||||||
|
"""
|
||||||
|
embeddings = []
|
||||||
|
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
if show_progress and i % 10 == 0:
|
||||||
|
logger.info(
|
||||||
|
"ollama_embed_batch_progress",
|
||||||
|
current=i,
|
||||||
|
total=len(texts),
|
||||||
|
)
|
||||||
|
|
||||||
|
embedding = await self.embed(text)
|
||||||
|
embeddings.append(embedding)
|
||||||
|
|
||||||
|
if show_progress:
|
||||||
|
logger.info(
|
||||||
|
"ollama_embed_batch_complete",
|
||||||
|
successful=sum(1 for e in embeddings if e is not None),
|
||||||
|
total=len(texts),
|
||||||
|
)
|
||||||
|
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
async def embed_batch_filtered(
|
||||||
|
self,
|
||||||
|
texts: list[str],
|
||||||
|
show_progress: bool = False,
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""
|
||||||
|
Generate embeddings for multiple texts, filtering out failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed
|
||||||
|
show_progress: Log progress for large batches
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of successful embedding vectors (may be shorter than input)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> embeddings = await client.embed_batch_filtered(texts)
|
||||||
|
>>> all(e is not None for e in embeddings)
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
all_embeddings = await self.embed_batch(texts, show_progress)
|
||||||
|
return [e for e in all_embeddings if e is not None]
|
||||||
|
|
||||||
|
async def get_embedding_dimension(self) -> int | None:
|
||||||
|
"""
|
||||||
|
Get embedding dimension for current model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Embedding dimension (e.g., 768 for nomic-embed-text) or None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> dim = await client.get_embedding_dimension()
|
||||||
|
>>> dim
|
||||||
|
768
|
||||||
|
"""
|
||||||
|
test_embedding = await self.embed("test")
|
||||||
|
if test_embedding:
|
||||||
|
return len(test_embedding)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Ollama server is reachable and model is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.get(self.tags_url, timeout=5.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
models = data.get("models", [])
|
||||||
|
|
||||||
|
# Check if our embedding model is available
|
||||||
|
model_found = False
|
||||||
|
for m in models:
|
||||||
|
name = m.get("name", "")
|
||||||
|
if name == self.model or name.startswith(f"{self.model}:"):
|
||||||
|
model_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not model_found:
|
||||||
|
logger.warning(
|
||||||
|
"ollama_embedding_model_not_found",
|
||||||
|
model=self.model,
|
||||||
|
available=[m.get("name") for m in models],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ollama_embedding_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance (lazy initialization)
|
||||||
|
_embedding_client: OllamaEmbeddingClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedding_client() -> OllamaEmbeddingClient:
|
||||||
|
"""
|
||||||
|
Get global embedding client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OllamaEmbeddingClient instance
|
||||||
|
"""
|
||||||
|
global _embedding_client
|
||||||
|
if _embedding_client is None:
|
||||||
|
_embedding_client = OllamaEmbeddingClient()
|
||||||
|
return _embedding_client
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""
|
||||||
|
Redis-backed memory cache for session context.
|
||||||
|
|
||||||
|
Provides short-term memory storage with TTL:
|
||||||
|
- Session context (24h TTL)
|
||||||
|
- Recent entities mentioned in conversation
|
||||||
|
- User-scoped with conversation isolation
|
||||||
|
|
||||||
|
Uses Redis DB 2 (separate from benchmarks in DB 1).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .multi_tenancy import get_session_key, get_entities_key
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCache:
|
||||||
|
"""
|
||||||
|
Redis-backed cache for session memory.
|
||||||
|
|
||||||
|
Stores ephemeral context that doesn't need vector search:
|
||||||
|
- Session context (recent topics, user state)
|
||||||
|
- Recent entities (people, places, things mentioned)
|
||||||
|
- Conversation metadata
|
||||||
|
|
||||||
|
All data expires after REDIS_MEMORY_TTL_HOURS (default 24h).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cache = MemoryCache()
|
||||||
|
await cache.set_session_context(
|
||||||
|
user="jpmschweitzer",
|
||||||
|
conversation_id="conv_123",
|
||||||
|
context={"topic": "docker", "mood": "curious"}
|
||||||
|
)
|
||||||
|
context = await cache.get_session_context("jpmschweitzer", "conv_123")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_url: str | None = None,
|
||||||
|
ttl_hours: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize memory cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis_url: Redis connection URL (defaults to config.redis_memory_url)
|
||||||
|
ttl_hours: TTL for cached data (defaults to config.REDIS_MEMORY_TTL_HOURS)
|
||||||
|
"""
|
||||||
|
self._redis_url = redis_url or config.redis_memory_url
|
||||||
|
self._ttl_seconds = (ttl_hours or config.REDIS_MEMORY_TTL_HOURS) * 3600
|
||||||
|
self._client: redis.Redis | None = None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"memory_cache_initialized",
|
||||||
|
redis_url=self._redis_url,
|
||||||
|
ttl_hours=ttl_hours or config.REDIS_MEMORY_TTL_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_client(self) -> redis.Redis:
|
||||||
|
"""Get or create Redis client."""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = redis.from_url(
|
||||||
|
self._redis_url,
|
||||||
|
encoding="utf-8",
|
||||||
|
decode_responses=True,
|
||||||
|
socket_timeout=config.REDIS_TIMEOUT,
|
||||||
|
socket_connect_timeout=config.REDIS_TIMEOUT,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close Redis connection."""
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session Context
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Session context dict or None if not found
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> context = await cache.get_session_context("jpmschweitzer", "conv_123")
|
||||||
|
>>> context
|
||||||
|
{"topic": "docker", "mood": "curious", "last_tool": "librarian"}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
|
||||||
|
data = await client.get(key)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_get_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
context: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Set session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
context: Context data to store
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await cache.set_session_context(
|
||||||
|
... "jpmschweitzer",
|
||||||
|
... "conv_123",
|
||||||
|
... {"topic": "docker", "mood": "curious"}
|
||||||
|
... )
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
|
||||||
|
await client.setex(
|
||||||
|
key,
|
||||||
|
self._ttl_seconds,
|
||||||
|
json.dumps(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_cache_set_session",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
context_keys=list(context.keys()),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_set_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
updates: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Update session context (merge with existing).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
updates: Fields to update/add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
existing = await self.get_session_context(user, conversation_id) or {}
|
||||||
|
existing.update(updates)
|
||||||
|
return await self.set_session_context(user, conversation_id, existing)
|
||||||
|
|
||||||
|
async def delete_session_context(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Delete session context for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_session_key(user, conversation_id)
|
||||||
|
await client.delete(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_delete_session_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Recent Entities
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get recently mentioned entities in a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of entity names/identifiers
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> entities = await cache.get_recent_entities("jpmschweitzer", "conv_123")
|
||||||
|
>>> entities
|
||||||
|
["Docker", "Kubernetes", "nginx"]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
|
||||||
|
# Get all members of the set
|
||||||
|
entities = await client.smembers(key)
|
||||||
|
return list(entities)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_get_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
entities: list[str],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Add entities to the recent entities set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
entities: Entity names to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await cache.add_recent_entities(
|
||||||
|
... "jpmschweitzer",
|
||||||
|
... "conv_123",
|
||||||
|
... ["Docker", "Kubernetes"]
|
||||||
|
... )
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
if not entities:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
|
||||||
|
# Add to set
|
||||||
|
await client.sadd(key, *entities)
|
||||||
|
|
||||||
|
# Refresh TTL
|
||||||
|
await client.expire(key, self._ttl_seconds)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"memory_cache_add_entities",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
entities=entities,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_add_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def clear_recent_entities(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Clear all recent entities for a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cleared, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
key = get_entities_key(user, conversation_id)
|
||||||
|
await client.delete(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"memory_cache_clear_entities_failed",
|
||||||
|
user=user,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Health Check
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Redis is reachable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = await self._get_client()
|
||||||
|
await client.ping()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("memory_cache_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global cache instance (lazy initialization)
|
||||||
|
_memory_cache: MemoryCache | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_cache() -> MemoryCache:
|
||||||
|
"""
|
||||||
|
Get global memory cache instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MemoryCache instance
|
||||||
|
"""
|
||||||
|
global _memory_cache
|
||||||
|
if _memory_cache is None:
|
||||||
|
_memory_cache = MemoryCache()
|
||||||
|
return _memory_cache
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
Multi-tenancy helpers for Tatlock.
|
||||||
|
|
||||||
|
Provides utilities for user namespace management across:
|
||||||
|
- Qdrant (collection per user for memories)
|
||||||
|
- Redis (user-scoped keys for session context)
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_user_id(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Sanitize user ID for use in collection names, keys, and paths.
|
||||||
|
|
||||||
|
Converts special characters to underscores and ensures alphanumeric safety.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Raw user identifier (email, username, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized user ID safe for use in identifiers
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> sanitize_user_id("john@example.com")
|
||||||
|
'john_at_example_com'
|
||||||
|
>>> sanitize_user_id("user.name")
|
||||||
|
'user_name'
|
||||||
|
>>> sanitize_user_id("User Name")
|
||||||
|
'user_name'
|
||||||
|
"""
|
||||||
|
sanitized = user_id.lower()
|
||||||
|
|
||||||
|
# Convert @ to _at_
|
||||||
|
sanitized = sanitized.replace("@", "_at_")
|
||||||
|
|
||||||
|
# Convert dots to underscores
|
||||||
|
sanitized = sanitized.replace(".", "_")
|
||||||
|
|
||||||
|
# Replace any non-alphanumeric characters with underscores
|
||||||
|
sanitized = re.sub(r'[^a-z0-9_]', '_', sanitized)
|
||||||
|
|
||||||
|
# Remove consecutive underscores
|
||||||
|
sanitized = re.sub(r'_+', '_', sanitized)
|
||||||
|
|
||||||
|
# Remove leading/trailing underscores
|
||||||
|
sanitized = sanitized.strip('_')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_collection_name(user_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Qdrant collection name for user's memories.
|
||||||
|
|
||||||
|
Pattern: memories_{sanitized_user_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Qdrant collection name
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_memory_collection_name("jpmschweitzer")
|
||||||
|
'memories_jpmschweitzer'
|
||||||
|
>>> get_memory_collection_name("john@example.com")
|
||||||
|
'memories_john_at_example_com'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"memories_{sanitized}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_key(user_id: str, conversation_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Redis key for session context.
|
||||||
|
|
||||||
|
Pattern: session:{sanitized_user}:{conversation_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redis key for session context
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_session_key("jpmschweitzer", "conv_abc123")
|
||||||
|
'session:jpmschweitzer:conv_abc123'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"session:{sanitized}:{conversation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_entities_key(user_id: str, conversation_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Get Redis key for recent entities in a conversation.
|
||||||
|
|
||||||
|
Pattern: entities:{sanitized_user}:{conversation_id}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
conversation_id: Conversation identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redis key for recent entities
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_entities_key("jpmschweitzer", "conv_abc123")
|
||||||
|
'entities:jpmschweitzer:conv_abc123'
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_user_id(user_id)
|
||||||
|
return f"entities:{sanitized}:{conversation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_user_id(user_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that a user ID is acceptable.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Not empty
|
||||||
|
- Not too long (max 100 chars)
|
||||||
|
- Contains some alphanumeric characters
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> validate_user_id("jpmschweitzer")
|
||||||
|
True
|
||||||
|
>>> validate_user_id("")
|
||||||
|
False
|
||||||
|
>>> validate_user_id("a" * 101)
|
||||||
|
False
|
||||||
|
"""
|
||||||
|
if not user_id or len(user_id) > 100:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Must contain at least one alphanumeric character
|
||||||
|
if not re.search(r'[a-zA-Z0-9]', user_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
"""
|
||||||
|
Qdrant client wrapper for memory vector storage.
|
||||||
|
|
||||||
|
Provides async operations for storing and retrieving memory embeddings:
|
||||||
|
- Collection management (per-user collections)
|
||||||
|
- Memory upsert/search/delete
|
||||||
|
- Filtering by memory type
|
||||||
|
|
||||||
|
Adapted from library-desk patterns.
|
||||||
|
"""
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qdrant_models
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .logging_config import get_logger
|
||||||
|
from .multi_tenancy import get_memory_collection_name
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryQdrantClient:
|
||||||
|
"""
|
||||||
|
Qdrant client wrapper for memory storage.
|
||||||
|
|
||||||
|
Manages per-user collections with the pattern: memories_{user}
|
||||||
|
Stores memory embeddings with metadata (type, content, timestamps).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = MemoryQdrantClient()
|
||||||
|
await client.ensure_collection("jpmschweitzer")
|
||||||
|
await client.upsert_memory(
|
||||||
|
user="jpmschweitzer",
|
||||||
|
memory_id="mem_123",
|
||||||
|
vector=[0.1, 0.2, ...],
|
||||||
|
payload={"type": "fact", "content": "User prefers dark mode"}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str | None = None,
|
||||||
|
embedding_dim: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Qdrant client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Qdrant server URL (defaults to config.qdrant_url)
|
||||||
|
embedding_dim: Vector dimension (defaults to config.QDRANT_EMBEDDING_DIM)
|
||||||
|
"""
|
||||||
|
self.url = url or config.qdrant_url
|
||||||
|
self.embedding_dim = embedding_dim or config.QDRANT_EMBEDDING_DIM
|
||||||
|
self._client = QdrantClient(url=self.url)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_client_initialized",
|
||||||
|
url=self.url,
|
||||||
|
embedding_dim=self.embedding_dim,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close Qdrant client."""
|
||||||
|
if self._client is not None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
async def ensure_collection(self, user: str) -> bool:
|
||||||
|
"""
|
||||||
|
Ensure collection exists for user, create if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if collection exists or was created successfully
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await client.ensure_collection("jpmschweitzer")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if collection exists
|
||||||
|
collections = self._client.get_collections()
|
||||||
|
existing = [c.name for c in collections.collections]
|
||||||
|
|
||||||
|
if collection_name in existing:
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_collection_exists",
|
||||||
|
collection=collection_name,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create collection with cosine distance
|
||||||
|
self._client.create_collection(
|
||||||
|
collection_name=collection_name,
|
||||||
|
vectors_config=qdrant_models.VectorParams(
|
||||||
|
size=self.embedding_dim,
|
||||||
|
distance=qdrant_models.Distance.COSINE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_collection_created",
|
||||||
|
collection=collection_name,
|
||||||
|
embedding_dim=self.embedding_dim,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_ensure_collection_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def upsert_memory(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
memory_id: str | None,
|
||||||
|
vector: list[float],
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Upsert a memory point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID (generated if None)
|
||||||
|
vector: Embedding vector
|
||||||
|
payload: Memory metadata (should include 'type', 'content', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory ID if successful, None on failure
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> memory_id = await client.upsert_memory(
|
||||||
|
... user="jpmschweitzer",
|
||||||
|
... memory_id=None,
|
||||||
|
... vector=[0.1, 0.2, ...],
|
||||||
|
... payload={
|
||||||
|
... "type": "fact",
|
||||||
|
... "content": "User prefers dark mode",
|
||||||
|
... "created_at": "2024-01-01T00:00:00Z"
|
||||||
|
... }
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
memory_id = memory_id or f"mem_{uuid4().hex[:16]}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure collection exists
|
||||||
|
await self.ensure_collection(user)
|
||||||
|
|
||||||
|
# Create point
|
||||||
|
point = qdrant_models.PointStruct(
|
||||||
|
id=memory_id,
|
||||||
|
vector=vector,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upsert
|
||||||
|
self._client.upsert(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points=[point],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_memory_upserted",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
memory_type=payload.get("type"),
|
||||||
|
)
|
||||||
|
return memory_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_upsert_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_memories(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
query_vector: list[float],
|
||||||
|
limit: int = 10,
|
||||||
|
memory_type: str | None = None,
|
||||||
|
score_threshold: float = 0.5,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Search memories by vector similarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
query_vector: Query embedding vector
|
||||||
|
limit: Maximum results
|
||||||
|
memory_type: Filter by memory type (e.g., "fact", "preference", "profile")
|
||||||
|
score_threshold: Minimum similarity score (0-1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching memories with scores
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> memories = await client.search_memories(
|
||||||
|
... user="jpmschweitzer",
|
||||||
|
... query_vector=[0.1, 0.2, ...],
|
||||||
|
... limit=5,
|
||||||
|
... memory_type="fact"
|
||||||
|
... )
|
||||||
|
>>> memories[0]
|
||||||
|
{"id": "mem_123", "score": 0.89, "type": "fact", "content": "..."}
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build filter if memory_type specified
|
||||||
|
query_filter = None
|
||||||
|
if memory_type:
|
||||||
|
query_filter = qdrant_models.Filter(
|
||||||
|
must=[
|
||||||
|
qdrant_models.FieldCondition(
|
||||||
|
key="type",
|
||||||
|
match=qdrant_models.MatchValue(value=memory_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
results = self._client.search(
|
||||||
|
collection_name=collection_name,
|
||||||
|
query_vector=query_vector,
|
||||||
|
limit=limit,
|
||||||
|
query_filter=query_filter,
|
||||||
|
score_threshold=score_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format results
|
||||||
|
memories = []
|
||||||
|
for hit in results:
|
||||||
|
memory = {
|
||||||
|
"id": hit.id,
|
||||||
|
"score": hit.score,
|
||||||
|
**hit.payload,
|
||||||
|
}
|
||||||
|
memories.append(memory)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_search_memories",
|
||||||
|
collection=collection_name,
|
||||||
|
results_count=len(memories),
|
||||||
|
memory_type=memory_type,
|
||||||
|
)
|
||||||
|
return memories
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_search_memories_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_memory(self, user: str, memory_id: str) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get a specific memory by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Memory data or None if not found
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
points = self._client.retrieve(
|
||||||
|
collection_name=collection_name,
|
||||||
|
ids=[memory_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
point = points[0]
|
||||||
|
return {
|
||||||
|
"id": point.id,
|
||||||
|
**point.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_get_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_memory(self, user: str, memory_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a memory by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_id: Memory ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await client.delete_memory("jpmschweitzer", "mem_123")
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._client.delete(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points_selector=qdrant_models.PointIdsList(
|
||||||
|
points=[memory_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"qdrant_memory_deleted",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_delete_memory_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_id=memory_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_memories_by_type(self, user: str, memory_type: str) -> int:
|
||||||
|
"""
|
||||||
|
Delete all memories of a specific type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
memory_type: Type of memories to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of memories deleted (approximate)
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete by filter
|
||||||
|
self._client.delete(
|
||||||
|
collection_name=collection_name,
|
||||||
|
points_selector=qdrant_models.FilterSelector(
|
||||||
|
filter=qdrant_models.Filter(
|
||||||
|
must=[
|
||||||
|
qdrant_models.FieldCondition(
|
||||||
|
key="type",
|
||||||
|
match=qdrant_models.MatchValue(value=memory_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"qdrant_memories_deleted_by_type",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_type=memory_type,
|
||||||
|
)
|
||||||
|
return -1 # Qdrant doesn't return count for filter deletes
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_delete_memories_by_type_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
memory_type=memory_type,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def count_memories(self, user: str) -> int:
|
||||||
|
"""
|
||||||
|
Count total memories for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of memories in user's collection
|
||||||
|
"""
|
||||||
|
collection_name = get_memory_collection_name(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = self._client.get_collection(collection_name)
|
||||||
|
return info.points_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"qdrant_count_memories_failed",
|
||||||
|
collection=collection_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Qdrant server is reachable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._client.get_collections()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("qdrant_health_check_failed", error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Global client instance (lazy initialization)
|
||||||
|
_qdrant_client: MemoryQdrantClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_qdrant_client() -> MemoryQdrantClient:
|
||||||
|
"""
|
||||||
|
Get global Qdrant client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MemoryQdrantClient instance
|
||||||
|
"""
|
||||||
|
global _qdrant_client
|
||||||
|
if _qdrant_client is None:
|
||||||
|
_qdrant_client = MemoryQdrantClient()
|
||||||
|
return _qdrant_client
|
||||||
@@ -11,6 +11,7 @@ from sse_starlette.sse import EventSourceResponse
|
|||||||
from src.responses import service
|
from src.responses import service
|
||||||
from src.responses.schemas import ResponseRequest, Response
|
from src.responses.schemas import ResponseRequest, Response
|
||||||
from src.core.exceptions import ModelNotFoundError, AppException
|
from src.core.exceptions import ModelNotFoundError, AppException
|
||||||
|
from src.core.context import current_user, current_conversation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -94,6 +95,11 @@ async def create_response(
|
|||||||
"""
|
"""
|
||||||
logger.info(f"Response request for model: {request.model}")
|
logger.info(f"Response request for model: {request.model}")
|
||||||
|
|
||||||
|
# Set request context (propagates through all async calls)
|
||||||
|
user_token = current_user.set(request.user or "jpmschweitzer")
|
||||||
|
conv_id = request.metadata.get("conversation_id") if request.metadata else None
|
||||||
|
conv_token = current_conversation.set(conv_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
|
# Check if this is a Tatlock request - use Steward preprocessing (Phase 2)
|
||||||
model_id = request.model
|
model_id = request.model
|
||||||
@@ -136,3 +142,8 @@ async def create_response(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Reset context (important for connection reuse)
|
||||||
|
current_user.reset(user_token)
|
||||||
|
current_conversation.reset(conv_token)
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ class ResponseRequest(CustomBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Stop sequences"
|
description="Stop sequences"
|
||||||
)
|
)
|
||||||
|
user: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Unique identifier for end-user (OpenAI standard)"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator('reasoning')
|
@field_validator('reasoning')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
Reference in New Issue
Block a user