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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user