Files
tatlock/src/core/multi_tenancy.py
T
jpmschweitzerandClaude Opus 4.5 4c6ac89808 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>
2025-12-13 17:27:51 +01:00

148 lines
3.5 KiB
Python

"""
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