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:
2025-12-13 17:27:51 +01:00
co-authored by Claude Opus 4.5
parent c049c1e354
commit 4c6ac89808
11 changed files with 1465 additions and 24 deletions
+36 -23
View File
@@ -13,6 +13,7 @@ import httpx
from pydantic import BaseModel, Field
from src.core.config import config
from src.core.context import get_user
from src.core.logging_config import get_logger
logger = get_logger(__name__)
@@ -179,7 +180,7 @@ class LibraryDeskClient:
async def hybrid_search(
self,
query: str,
user: str = "jpmschweitzer",
user: str | None = None,
vector_limit: int = 10,
graph_limit: int = 10,
web_limit: int = 5,
@@ -191,7 +192,7 @@ class LibraryDeskClient:
Args:
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
graph_limit: Max results from graph search
web_limit: Max results from web search
@@ -201,6 +202,7 @@ class LibraryDeskClient:
Returns:
HybridRAGResponse with ranked results and context
"""
user = user or get_user()
client = self._ensure_client()
payload = {
@@ -255,7 +257,7 @@ class LibraryDeskClient:
async def search_wiki(
self,
query: str,
user: str = "jpmschweitzer",
user: str | None = None,
limit: int = 20,
) -> list[WikiSearchResult]:
"""
@@ -263,12 +265,13 @@ class LibraryDeskClient:
Args:
query: Search query
user: User identifier
user: User identifier (defaults to request context)
limit: Maximum results
Returns:
List of matching wiki pages
"""
user = user or get_user()
client = self._ensure_client()
logger.debug("library_desk_wiki_search", query=query, user=user)
@@ -285,18 +288,19 @@ class LibraryDeskClient:
async def get_wiki_page(
self,
page_id: int,
user: str = "jpmschweitzer",
user: str | None = None,
) -> WikiPage:
"""
Get a wiki page by ID.
Args:
page_id: Page ID
user: User identifier
user: User identifier (defaults to request context)
Returns:
WikiPage with full content
"""
user = user or get_user()
client = self._ensure_client()
response = await client.get(
@@ -309,7 +313,7 @@ class LibraryDeskClient:
async def list_wiki_pages(
self,
user: str = "jpmschweitzer",
user: str | None = None,
tag: Optional[str] = None,
limit: int = 50,
) -> list[WikiPage]:
@@ -317,13 +321,14 @@ class LibraryDeskClient:
List wiki pages, optionally filtered by tag.
Args:
user: User identifier
user: User identifier (defaults to request context)
tag: Optional tag (dossier) to filter by
limit: Maximum pages to return
Returns:
List of wiki pages
"""
user = user or get_user()
client = self._ensure_client()
params: dict[str, Any] = {"user": user, "limit": limit}
@@ -341,7 +346,7 @@ class LibraryDeskClient:
title: str,
path: str,
content: str,
user: str = "jpmschweitzer",
user: str | None = None,
description: str = "",
tags: Optional[list[str]] = None,
) -> WikiPage:
@@ -352,13 +357,14 @@ class LibraryDeskClient:
title: Page title
path: Page path (e.g., "/projects/my-project")
content: Markdown content
user: User identifier
user: User identifier (defaults to request context)
description: Short description
tags: List of tags (dossiers)
Returns:
Created WikiPage
"""
user = user or get_user()
client = self._ensure_client()
payload = {
@@ -380,7 +386,7 @@ class LibraryDeskClient:
async def update_wiki_page(
self,
page_id: int,
user: str = "jpmschweitzer",
user: str | None = None,
content: Optional[str] = None,
title: Optional[str] = None,
tags: Optional[list[str]] = None,
@@ -394,7 +400,7 @@ class LibraryDeskClient:
Args:
page_id: ID of the page to update
user: User identifier
user: User identifier (defaults to request context)
content: New content (optional)
title: New title (optional)
tags: New tags list (optional)
@@ -403,6 +409,7 @@ class LibraryDeskClient:
Returns:
Updated WikiPage
"""
user = user or get_user()
client = self._ensure_client()
# Build update payload with only provided fields
@@ -435,7 +442,7 @@ class LibraryDeskClient:
self,
topic: str,
tags: list[str],
user: str = "jpmschweitzer",
user: str | None = None,
path: Optional[str] = None,
include_web_research: bool = True,
include_wiki_search: bool = True,
@@ -460,6 +467,7 @@ class LibraryDeskClient:
Returns:
SmartCreateResponse with page and research metadata
"""
user = user or get_user()
client = self._ensure_client()
payload: dict[str, Any] = {
@@ -499,17 +507,18 @@ class LibraryDeskClient:
async def list_dossiers(
self,
user: str = "jpmschweitzer",
user: str | None = None,
) -> list[Dossier]:
"""
List all dossiers (tag collections) for a user.
Args:
user: User identifier
user: User identifier (defaults to request context)
Returns:
List of dossiers with page counts
"""
user = user or get_user()
client = self._ensure_client()
response = await client.get(
@@ -528,7 +537,7 @@ class LibraryDeskClient:
async def semantic_search(
self,
query: str,
user: str = "jpmschweitzer",
user: str | None = None,
limit: int = 10,
score_threshold: float = 0.5,
) -> list[VectorSearchResult]:
@@ -537,13 +546,14 @@ class LibraryDeskClient:
Args:
query: Natural language query
user: User identifier
user: User identifier (defaults to request context)
limit: Maximum results
score_threshold: Minimum similarity score
Returns:
List of matching document chunks with scores
"""
user = user or get_user()
client = self._ensure_client()
payload = {
@@ -568,7 +578,7 @@ class LibraryDeskClient:
async def query_graph(
self,
cypher_query: str,
user: str = "jpmschweitzer",
user: str | None = None,
parameters: Optional[dict[str, Any]] = None,
) -> list[dict[str, Any]]:
"""
@@ -578,12 +588,13 @@ class LibraryDeskClient:
Args:
cypher_query: Cypher query string
user: User identifier
user: User identifier (defaults to request context)
parameters: Query parameters
Returns:
List of result records
"""
user = user or get_user()
client = self._ensure_client()
payload = {
@@ -601,7 +612,7 @@ class LibraryDeskClient:
async def list_graph_nodes(
self,
user: str = "jpmschweitzer",
user: str | None = None,
node_type: Optional[str] = None,
limit: int = 100,
) -> list[GraphNode]:
@@ -609,13 +620,14 @@ class LibraryDeskClient:
List nodes in the knowledge graph.
Args:
user: User identifier
user: User identifier (defaults to request context)
node_type: Optional filter by type (Document, Person, Concept, etc.)
limit: Maximum nodes
Returns:
List of graph nodes
"""
user = user or get_user()
client = self._ensure_client()
params: dict[str, Any] = {"user": user, "limit": limit}
@@ -631,18 +643,19 @@ class LibraryDeskClient:
async def get_graph_node(
self,
node_id: str,
user: str = "jpmschweitzer",
user: str | None = None,
) -> dict[str, Any]:
"""
Get detailed information about a graph node.
Args:
node_id: Node ID
user: User identifier
user: User identifier (defaults to request context)
Returns:
Node with relationships and connected nodes
"""
user = user or get_user()
client = self._ensure_client()
response = await client.get(