feat!: require explicit user on every tenant-data endpoint

Remove the implicit jpmschweitzer default tenant (DEFAULT_USER) from
src/core/multi_tenancy.py and every endpoint and request model that
inherited it (~40 endpoints across /query, /wiki, /vector, /graph,
/ingest, /volatile, /documents, /stats, /rag).

- Add validate_required_user() + RequiredUser pydantic type in
  multi_tenancy and a shared require_user FastAPI dependency
  (RequiredUserQuery) that rejects missing, empty, and whitespace-only
  users with 422, following the /maintenance/* pattern.
- Wiki page create / smart-create / dossier request models now require
  user (no fallback in wiki_service).
- /maintenance/cleanup/test-data derives the tenant from the page path
  instead of using the production tenant collection.
- Wiki.js change listener skips changes when no tenant user can be
  derived from the notification email instead of defaulting to the
  production tenant.
- Consolidation service internal helpers no longer default to the
  production tenant.
- Tool catalog marks user as required with honest descriptions.
- OpenAPI descriptions updated honestly; CHANGELOG notes that callers
  (tatlock, Scheduler ingest tasks) must now send explicit user.
- Offline tests: 422 coverage for query/body endpoints, required-user
  validator tests; updated legacy tests that assumed a default tenant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:06:11 +02:00
co-authored by Claude Fable 5
parent a66d801abd
commit 84e9185371
27 changed files with 481 additions and 153 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- **BREAKING: `user` is now required on every tenant-data endpoint** - The implicit `jpmschweitzer` default tenant (`DEFAULT_USER`) has been removed everywhere. All endpoints that read or write tenant data (`/query/*`, `/wiki/*`, `/vector/*`, `/graph/*`, `/ingest/*`, `/volatile/*`, `/documents/*`, `/stats`, `/rag/search`) now reject requests without an explicit, non-empty, non-whitespace `user` (HTTP 422), matching the existing `/maintenance/*` pattern. A shared validator (`require_user` dependency / `RequiredUser` model type) also rejects blank users. The Wiki.js change listener now skips changes whose notification email yields no user instead of attributing them to the production tenant. **Caller coordination required:** tatlock and the Scheduler ingest/prefetch/consolidation tasks must send an explicit `user` on every call — see the deploy checklist.
### Added ### Added
- **Degradation signaling** - `HybridRAGResponse` now includes `source_status` (per-leg `'ok'`/`'failed'`/`'disabled'` for vector, graph, web, volatile, documents) and `degraded` (true when any enabled leg failed). Retrieval legs report errors instead of silently swallowing them; failed legs are logged at WARNING. Both fields are additive and optional, so clients that ignore them are unaffected. - **Degradation signaling** - `HybridRAGResponse` now includes `source_status` (per-leg `'ok'`/`'failed'`/`'disabled'` for vector, graph, web, volatile, documents) and `degraded` (true when any enabled leg failed). Retrieval legs report errors instead of silently swallowing them; failed legs are logged at WARNING. Both fields are additive and optional, so clients that ignore them are unaffected.
+59
View File
@@ -0,0 +1,59 @@
# Tatlock Claudification Handover - library-desk
## Context
Tatlock (the butler) is being upgraded to use Claude as its primary LLM backend instead of Ollama. This gives all agents 200k token context and improved reasoning. Library-desk is the backend for **The Librarian** agent.
**Parent Issue:** See `/mnt/media/Projects/tatlock/PROJECT_CLAUDIFICATION.md`
## Impact on library-desk
Library-desk's API is consumed by The Librarian agent via `LibraryDeskClient`. With Claude's larger context and better reasoning:
1. **Larger response payloads are now viable** - Claude can process more search results
2. **Better synthesis** - Claude can better combine HybridRAG sources
3. **Faster processing** - May need to review rate limiting
## Required Changes
### Priority: Low (No blocking changes)
Library-desk likely works as-is. These are optimizations:
- [ ] **Review `hybrid_search` response size limits**
- Current defaults may be conservative for 8k Ollama context
- Consider increasing `max_results` defaults for Claude's 200k context
- Add optional `context_budget` parameter?
- [ ] **Review `smart_create` endpoint**
- Claude's reasoning may benefit from more research context
- Consider returning more source material for synthesis
- [ ] **Evaluate response formats**
- Are responses optimized for LLM consumption?
- Could structured metadata help Claude's reasoning?
### Priority: None (Infrastructure)
- No API key changes needed (library-desk doesn't call LLMs directly)
- No authentication changes
- Existing endpoints remain compatible
## Testing
Once Tatlock is running with Claude backend:
```bash
# Test from Tatlock
curl -X POST http://localhost:8777/v1/responses \
-H "Content-Type: application/json" \
-d '{"input": "Search for Docker networking best practices"}'
# Verify Librarian delegation works with Claude
```
## Timeline
- **Blocking:** No
- **When to implement:** After Tatlock Phase 1 is tested and stable
- **Effort:** ~2-4 hours for optimizations
+30 -1
View File
@@ -10,7 +10,7 @@ Provides FastAPI dependencies for service clients with:
from functools import lru_cache from functools import lru_cache
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends, HTTPException, Query
import logging import logging
import redis.asyncio as aioredis import redis.asyncio as aioredis
@@ -38,6 +38,35 @@ logger = logging.getLogger(__name__)
SettingsDep = Annotated[Settings, Depends(get_settings)] SettingsDep = Annotated[Settings, Depends(get_settings)]
# Tenant user dependency
def require_user(
user: str = Query(
...,
description=(
"User identifier (tenant). Required — every operation is scoped to "
"this tenant's namespace (Qdrant collection, Neo4j labels, wiki path, "
"Redis keys). Requests without an explicit non-empty user are "
"rejected with 422. There is no default tenant."
),
)
) -> str:
"""
FastAPI dependency: required tenant user query parameter.
Rejects missing (FastAPI returns 422 automatically), empty, and
whitespace-only user values. Use via the RequiredUserQuery alias.
"""
from src.core.multi_tenancy import validate_required_user
try:
return validate_required_user(user)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
RequiredUserQuery = Annotated[str, Depends(require_user)]
# Client factory functions with @lru_cache for singletons # Client factory functions with @lru_cache for singletons
@lru_cache @lru_cache
def get_neo4j_client() -> Neo4jClient: def get_neo4j_client() -> Neo4jClient:
+41 -2
View File
@@ -8,9 +8,9 @@ Provides utilities for user namespace management across:
""" """
import re import re
from typing import Annotated
# Default user for all operations from pydantic import AfterValidator
DEFAULT_USER = "jpmschweitzer"
def sanitize_user_id(user_id: str) -> str: def sanitize_user_id(user_id: str) -> str:
@@ -186,6 +186,45 @@ def validate_user_id(user_id: str) -> bool:
return True return True
def validate_required_user(user: str) -> str:
"""
Validate that a tenant user identifier is present and usable.
There is NO default tenant: every operation that touches tenant data
must receive an explicit user. Empty or whitespace-only values are
rejected, as are values that fail :func:`validate_user_id`.
Args:
user: Raw user identifier from a request
Returns:
The stripped user identifier
Raises:
ValueError: If the user is missing, blank, or invalid
Examples:
>>> validate_required_user("llm_tester")
'llm_tester'
>>> validate_required_user(" ")
Traceback (most recent call last):
...
ValueError: user is required and must be a non-empty, non-whitespace string
"""
if user is None or not str(user).strip():
raise ValueError(
"user is required and must be a non-empty, non-whitespace string"
)
stripped = str(user).strip()
if not validate_user_id(stripped):
raise ValueError(f"Invalid user identifier: {user!r}")
return stripped
# Pydantic annotated type for request models: a required, validated tenant user.
RequiredUser = Annotated[str, AfterValidator(validate_required_user)]
def is_path_in_user_namespace(path: str, user_id: str) -> bool: def is_path_in_user_namespace(path: str, user_id: str) -> bool:
""" """
Check if a Wiki.js path belongs to user's namespace. Check if a Wiki.js path belongs to user's namespace.
+5 -5
View File
@@ -18,9 +18,9 @@ from pathlib import Path
from src.config import Settings, get_settings, __version__ from src.config import Settings, get_settings, __version__
from src.core.dependencies import ( from src.core.dependencies import (
verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep,
RequiredUserQuery
) )
from src.core.multi_tenancy import DEFAULT_USER
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -151,7 +151,7 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
@app.get("/stats", response_model=StatsResponse, tags=["System"]) @app.get("/stats", response_model=StatsResponse, tags=["System"])
async def stats( async def stats(
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
neo4j: Neo4jDep = None, neo4j: Neo4jDep = None,
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
wikijs: WikiJSDep = None, wikijs: WikiJSDep = None,
@@ -290,8 +290,8 @@ async def get_repo_status(
@app.post("/query/semantic", tags=["Query"]) @app.post("/query/semantic", tags=["Query"])
async def semantic_query( async def semantic_query(
user: RequiredUserQuery,
query: str = Query(..., min_length=1, description="Search query text"), query: str = Query(..., min_length=1, description="Search query text"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=10, ge=1, le=100, description="Maximum results"), limit: int = Query(default=10, ge=1, le=100, description="Maximum results"),
score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"), score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"),
qdrant_client: QdrantDep = None, qdrant_client: QdrantDep = None,
@@ -331,8 +331,8 @@ async def semantic_query(
@app.post("/query/graph", tags=["Query"]) @app.post("/query/graph", tags=["Query"])
async def graph_query( async def graph_query(
user: RequiredUserQuery,
query: str = Query(..., description="Cypher query to execute"), query: str = Query(..., description="Cypher query to execute"),
user: str = Query(default=DEFAULT_USER, description="User for scoping (auto-filters results)"),
neo4j_client: Neo4jDep = None, neo4j_client: Neo4jDep = None,
wiki_client: WikiJSDep = None, wiki_client: WikiJSDep = None,
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
+12 -6
View File
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from datetime import datetime from datetime import datetime
from src.core.multi_tenancy import RequiredUser
class GraphNode(BaseModel): class GraphNode(BaseModel):
"""Graph node representation.""" """Graph node representation."""
@@ -45,9 +47,13 @@ class CypherQueryRequest(BaseModel):
default_factory=dict, default_factory=dict,
description="Query parameters" description="Query parameters"
) )
user: str = Field( user: RequiredUser = Field(
default="jpmschweitzer", ...,
description="User for filtering (automatically scopes query)" description=(
"User identifier (tenant). Required. NOTE: raw Cypher queries are NOT "
"automatically scoped to this tenant — the endpoint is read-only and "
"intended for admin/debug use. Results may span all tenants."
)
) )
@@ -61,9 +67,9 @@ class CypherQueryResponse(BaseModel):
class UpdateFromPageRequest(BaseModel): class UpdateFromPageRequest(BaseModel):
"""Request to update graph from a wiki page.""" """Request to update graph from a wiki page."""
page_id: int = Field(..., description="Wiki page ID to process") page_id: int = Field(..., description="Wiki page ID to process")
user: str = Field( user: RequiredUser = Field(
default="jpmschweitzer", ...,
description="User identifier for namespace scoping" description="User identifier (tenant). Required — graph writes are scoped to this tenant's labels."
) )
force_refresh: bool = Field( force_refresh: bool = Field(
default=False, default=False,
+4 -2
View File
@@ -5,11 +5,13 @@ from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
from src.core.multi_tenancy import RequiredUser
class IngestionRequest(BaseModel): class IngestionRequest(BaseModel):
"""Request to ingest a wiki page.""" """Request to ingest a wiki page."""
page_id: int = Field(..., description="Wiki page ID to ingest") page_id: int = Field(..., description="Wiki page ID to ingest")
user: str = Field(default="jpmschweitzer", description="User identifier") user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
force_refresh: bool = Field( force_refresh: bool = Field(
default=False, default=False,
description="Force re-ingestion even if page hasn't changed" description="Force re-ingestion even if page hasn't changed"
@@ -21,7 +23,7 @@ class IngestionRequest(BaseModel):
class BatchIngestionRequest(BaseModel): class BatchIngestionRequest(BaseModel):
"""Request to ingest multiple wiki pages.""" """Request to ingest multiple wiki pages."""
page_ids: List[int] = Field(..., description="List of wiki page IDs to ingest") page_ids: List[int] = Field(..., description="List of wiki page IDs to ingest")
user: str = Field(default="jpmschweitzer", description="User identifier") user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
force_refresh: bool = Field(default=False) force_refresh: bool = Field(default=False)
skip_vectors: bool = Field(default=False) skip_vectors: bool = Field(default=False)
skip_graph: bool = Field(default=False) skip_graph: bool = Field(default=False)
+4 -4
View File
@@ -8,7 +8,7 @@ from enum import Enum
from typing import Optional, List from typing import Optional, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.core.multi_tenancy import DEFAULT_USER from src.core.multi_tenancy import RequiredUser
class SearchType(str, Enum): class SearchType(str, Enum):
@@ -37,9 +37,9 @@ class RAGSearchRequest(BaseModel):
le=20, le=20,
description="Maximum number of results (1-20)" description="Maximum number of results (1-20)"
) )
user: str = Field( user: RequiredUser = Field(
default=DEFAULT_USER, ...,
description="User identifier for rate limiting/personalization" description="User identifier (tenant). Required — used for rate limiting/personalization."
) )
+7 -5
View File
@@ -7,6 +7,8 @@ Provides models for semantic search, document chunks, and embeddings.
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from src.core.multi_tenancy import RequiredUser
class DocumentChunk(BaseModel): class DocumentChunk(BaseModel):
"""Document chunk with embedding.""" """Document chunk with embedding."""
@@ -32,7 +34,7 @@ class SearchResult(BaseModel):
class SearchRequest(BaseModel): class SearchRequest(BaseModel):
"""Semantic search request.""" """Semantic search request."""
query: str = Field(..., min_length=1, description="Search query") query: str = Field(..., min_length=1, description="Search query")
user: str = Field(default="jpmschweitzer", description="User identifier") user: RequiredUser = Field(..., description="User identifier (tenant). Required — search is scoped to this tenant's collection.")
limit: int = Field(default=10, ge=1, le=100, description="Maximum results") limit: int = Field(default=10, ge=1, le=100, description="Maximum results")
score_threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score") score_threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score")
@@ -48,9 +50,9 @@ class SearchResponse(BaseModel):
class VectorUpdateRequest(BaseModel): class VectorUpdateRequest(BaseModel):
"""Request to update vectors from a wiki page.""" """Request to update vectors from a wiki page."""
page_id: int = Field(..., description="Wiki page ID to process") page_id: int = Field(..., description="Wiki page ID to process")
user: str = Field( user: RequiredUser = Field(
default="jpmschweitzer", ...,
description="User identifier for namespace scoping" description="User identifier (tenant). Required — vectors are written to this tenant's collection."
) )
force_refresh: bool = Field( force_refresh: bool = Field(
default=False, default=False,
@@ -89,7 +91,7 @@ class CollectionListResponse(BaseModel):
class DeletePageChunksRequest(BaseModel): class DeletePageChunksRequest(BaseModel):
"""Request to delete all chunks for a page.""" """Request to delete all chunks for a page."""
page_id: int = Field(..., description="Wiki page ID") page_id: int = Field(..., description="Wiki page ID")
user: str = Field(default="jpmschweitzer", description="User identifier") user: RequiredUser = Field(..., description="User identifier (tenant). Required — deletion is scoped to this tenant's collection.")
class DeletePageChunksResponse(BaseModel): class DeletePageChunksResponse(BaseModel):
+5 -3
View File
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
from src.core.multi_tenancy import RequiredUser
# Base models # Base models
class WikiPageBase(BaseModel): class WikiPageBase(BaseModel):
@@ -35,7 +37,7 @@ class WikiPageCreate(WikiPageBase):
content: str = Field(..., description="Page content (markdown)") content: str = Field(..., description="Page content (markdown)")
path: str = Field(..., min_length=1, max_length=500, description="Page path (e.g., '/projects/library-desk')") path: str = Field(..., min_length=1, max_length=500, description="Page path (e.g., '/projects/library-desk')")
editor: str = Field(default="markdown", description="Editor type") editor: str = Field(default="markdown", description="Editor type")
user: Optional[str] = Field(None, description="User identifier (defaults to configured user)") user: RequiredUser = Field(..., description="User identifier (tenant). Required — the page is created inside this tenant's namespace.")
@field_validator("path") @field_validator("path")
@classmethod @classmethod
@@ -106,7 +108,7 @@ class DossierCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200, description="Human-readable title") title: str = Field(..., min_length=1, max_length=200, description="Human-readable title")
description: str = Field(..., min_length=1, description="Dossier description") description: str = Field(..., min_length=1, description="Dossier description")
create_index_page: bool = Field(default=True, description="Create an index page for the dossier") create_index_page: bool = Field(default=True, description="Create an index page for the dossier")
user: Optional[str] = Field(None, description="User identifier") user: RequiredUser = Field(..., description="User identifier (tenant). Required.")
@field_validator("name") @field_validator("name")
@classmethod @classmethod
@@ -198,7 +200,7 @@ class WikiSmartCreateRequest(BaseModel):
topic: str = Field(..., min_length=1, max_length=500, description="Topic to research and create page about") topic: str = Field(..., min_length=1, max_length=500, description="Topic to research and create page about")
path: Optional[str] = Field(None, description="Page path (auto-generated from topic if not provided)") path: Optional[str] = Field(None, description="Page path (auto-generated from topic if not provided)")
tags: List[str] = Field(default_factory=list, description="Tags for the page") tags: List[str] = Field(default_factory=list, description="Tags for the page")
user: Optional[str] = Field(None, description="User identifier") user: RequiredUser = Field(..., description="User identifier (tenant). Required — research results and the created page are scoped to this tenant.")
include_web_research: bool = Field(default=True, description="Include web search results") include_web_research: bool = Field(default=True, description="Include web search results")
include_wiki_search: bool = Field(default=True, description="Include existing wiki knowledge") include_wiki_search: bool = Field(default=True, description="Include existing wiki knowledge")
+4 -4
View File
@@ -29,7 +29,7 @@ from src.core.dependencies import (
Neo4jDep, Neo4jDep,
WikiJSDep, WikiJSDep,
) )
from src.core.multi_tenancy import DEFAULT_USER from src.core.dependencies import RequiredUserQuery
from src.config import get_settings from src.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,7 +50,7 @@ async def receive_webhook(
ollama: OllamaDep, ollama: OllamaDep,
neo4j: Neo4jDep, neo4j: Neo4jDep,
wiki: WikiJSDep, wiki: WikiJSDep,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
): ):
""" """
Receive webhook events from Paperless-ngx. Receive webhook events from Paperless-ngx.
@@ -161,9 +161,9 @@ async def capture_webhook(request: Request):
@router.post("/webhook-simple", response_model=WebhookResponse) @router.post("/webhook-simple", response_model=WebhookResponse)
async def receive_webhook_simple( async def receive_webhook_simple(
user: RequiredUserQuery,
doc_url: str = Query(..., description="Paperless document URL containing ID"), doc_url: str = Query(..., description="Paperless document URL containing ID"),
title: str = Query(default="", description="Document title"), title: str = Query(default="", description="Document title"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
paperless: PaperlessDep = None, paperless: PaperlessDep = None,
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
@@ -347,7 +347,7 @@ async def search_documents(
request: DocumentSearchRequest, request: DocumentSearchRequest,
qdrant: QdrantDep, qdrant: QdrantDep,
ollama: OllamaDep, ollama: OllamaDep,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
api_key: str = Depends(verify_api_key), api_key: str = Depends(verify_api_key),
): ):
""" """
+12 -11
View File
@@ -15,8 +15,9 @@ from src.models.graph import (
MindMapResponse MindMapResponse
) )
from src.services.graph_service import GraphService from src.services.graph_service import GraphService
from src.core.dependencies import Neo4jDep, WikiJSDep, verify_api_key from src.core.dependencies import (
from src.core.multi_tenancy import DEFAULT_USER Neo4jDep, WikiJSDep, verify_api_key, RequiredUserQuery
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,7 +50,7 @@ async def execute_cypher_query(
{ {
"query": "MATCH (d:Document) RETURN d LIMIT 10", "query": "MATCH (d:Document) RETURN d LIMIT 10",
"parameters": {}, "parameters": {},
"user": "jpmschweitzer" "user": "<tenant>"
} }
``` ```
@@ -70,7 +71,7 @@ async def execute_cypher_query(
@router.get("/nodes", response_model=NodeListResponse) @router.get("/nodes", response_model=NodeListResponse)
async def list_nodes( async def list_nodes(
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
node_type: Optional[str] = Query(default=None, description="Node type filter"), node_type: Optional[str] = Query(default=None, description="Node type filter"),
limit: int = Query(default=100, ge=1, le=500, description="Maximum nodes"), limit: int = Query(default=100, ge=1, le=500, description="Maximum nodes"),
graph_service: GraphService = Depends(get_graph_service), graph_service: GraphService = Depends(get_graph_service),
@@ -81,7 +82,7 @@ async def list_nodes(
Optionally filter by node type (Document, Person, Project, Concept, etc.). Optionally filter by node type (Document, Person, Project, Concept, etc.).
**Example:** `/graph/nodes?user=jpmschweitzer&node_type=Document&limit=50` **Example:** `/graph/nodes?user=<tenant>&node_type=Document&limit=50`
""" """
try: try:
return await graph_service.list_nodes( return await graph_service.list_nodes(
@@ -97,7 +98,7 @@ async def list_nodes(
@router.get("/nodes/{node_id}", response_model=GraphNodeDetail) @router.get("/nodes/{node_id}", response_model=GraphNodeDetail)
async def get_node( async def get_node(
node_id: str, node_id: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
graph_service: GraphService = Depends(get_graph_service), graph_service: GraphService = Depends(get_graph_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
): ):
@@ -106,7 +107,7 @@ async def get_node(
Returns the node, its relationships, and connected nodes. Returns the node, its relationships, and connected nodes.
**Example:** `/graph/nodes/4:abc123def:0?user=jpmschweitzer` **Example:** `/graph/nodes/4:abc123def:0?user=<tenant>`
""" """
try: try:
node = await graph_service.get_node(node_id, user) node = await graph_service.get_node(node_id, user)
@@ -123,7 +124,7 @@ async def get_node(
@router.post("/update-from-page/{page_id}", response_model=GraphUpdateSummary) @router.post("/update-from-page/{page_id}", response_model=GraphUpdateSummary)
async def update_graph_from_page( async def update_graph_from_page(
page_id: int, page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
force_refresh: bool = Query(default=False, description="Force re-extraction"), force_refresh: bool = Query(default=False, description="Force re-extraction"),
graph_service: GraphService = Depends(get_graph_service), graph_service: GraphService = Depends(get_graph_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -143,7 +144,7 @@ async def update_graph_from_page(
- Called manually by user/Librarian to refresh graph - Called manually by user/Librarian to refresh graph
- Called by Scheduler for batch processing - Called by Scheduler for batch processing
**Example:** `POST /graph/update-from-page/4?user=jpmschweitzer` **Example:** `POST /graph/update-from-page/4?user=<tenant>`
**Returns:** Summary with nodes/relationships created and entities extracted **Returns:** Summary with nodes/relationships created and entities extracted
""" """
@@ -171,8 +172,8 @@ async def update_graph_from_page(
@router.post("/mindmap", response_model=MindMapResponse) @router.post("/mindmap", response_model=MindMapResponse)
async def generate_mindmap( async def generate_mindmap(
user: RequiredUserQuery,
center_node_id: str = Query(..., description="Central node ID"), center_node_id: str = Query(..., description="Central node ID"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
depth: int = Query(default=2, ge=1, le=5, description="Traversal depth"), depth: int = Query(default=2, ge=1, le=5, description="Traversal depth"),
graph_service: GraphService = Depends(get_graph_service), graph_service: GraphService = Depends(get_graph_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -205,7 +206,7 @@ async def generate_mindmap(
@router.post("/generate-entity-pages") @router.post("/generate-entity-pages")
async def generate_entity_pages( async def generate_entity_pages(
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
min_mentions: int = Query(default=5, ge=1, le=100, description="Minimum mentions threshold"), min_mentions: int = Query(default=5, ge=1, le=100, description="Minimum mentions threshold"),
entity_types: Optional[List[str]] = Query(default=None, description="Entity types to process"), entity_types: Optional[List[str]] = Query(default=None, description="Entity types to process"),
graph_service: GraphService = Depends(get_graph_service), graph_service: GraphService = Depends(get_graph_service),
+8 -5
View File
@@ -5,14 +5,15 @@ Provides endpoint for combining vector, graph, volatile cache, and web search
with RRF fusion and LLM re-ranking. with RRF fusion and LLM re-ranking.
""" """
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends
import logging import logging
from src.models.hybrid_rag import HybridRAGRequest, HybridRAGResponse from src.models.hybrid_rag import HybridRAGRequest, HybridRAGResponse
from src.services.hybrid_rag_service import HybridRAGService from src.services.hybrid_rag_service import HybridRAGService
from src.core.dependencies import ( from src.core.dependencies import (
Neo4jDep, WikiJSDep, QdrantDep, OllamaDep, Neo4jDep, WikiJSDep, QdrantDep, OllamaDep,
SearXNGDep, ContentExtractorDep, verify_api_key, get_settings SearXNGDep, ContentExtractorDep, verify_api_key, get_settings,
RequiredUserQuery
) )
from src.config import Settings from src.config import Settings
@@ -56,7 +57,7 @@ def get_hybrid_rag_service(
@router.post("/hybrid", response_model=HybridRAGResponse) @router.post("/hybrid", response_model=HybridRAGResponse)
async def hybrid_search( async def hybrid_search(
request: HybridRAGRequest, request: HybridRAGRequest,
user: str = Query(default="jpmschweitzer", description="User identifier for multi-tenancy"), user: RequiredUserQuery,
hybrid_rag_service: HybridRAGService = Depends(get_hybrid_rag_service), hybrid_rag_service: HybridRAGService = Depends(get_hybrid_rag_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
): ):
@@ -72,11 +73,13 @@ async def hybrid_search(
6. **Context Formatting**: Format for LLM consumption 6. **Context Formatting**: Format for LLM consumption
7. **Persistence**: Store for Librarian knowledge consolidation 7. **Persistence**: Store for Librarian knowledge consolidation
**Example Request:** **Multi-tenancy:** the `user` query parameter is REQUIRED — all retrieval
legs and persistence are scoped to that tenant's namespaces.
**Example Request** (`POST /query/hybrid?user=<tenant>`):
```json ```json
{ {
"query": "What's the weather in Rotterdam?", "query": "What's the weather in Rotterdam?",
"user": "jpmschweitzer",
"config": { "config": {
"vector_limit": 10, "vector_limit": 10,
"graph_limit": 10, "graph_limit": 10,
+7 -7
View File
@@ -13,7 +13,7 @@ from src.models.ingestion import (
BatchIngestionRequest, BatchIngestionRequest,
BatchIngestionResult BatchIngestionResult
) )
from src.core.dependencies import get_ingestion_service, verify_api_key from src.core.dependencies import get_ingestion_service, verify_api_key, RequiredUserQuery
router = APIRouter(prefix="/ingest", tags=["Document Ingestion"]) router = APIRouter(prefix="/ingest", tags=["Document Ingestion"])
@@ -52,7 +52,7 @@ async def ingest_page(
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"page_id": 19, "page_id": 19,
"user": "jpmschweitzer", "user": "<tenant>",
"force_refresh": false "force_refresh": false
}' }'
``` ```
@@ -104,7 +104,7 @@ async def ingest_batch(
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"page_ids": [19, 20, 21, 22], "page_ids": [19, 20, 21, 22],
"user": "jpmschweitzer", "user": "<tenant>",
"max_concurrent": 3 "max_concurrent": 3
}' }'
``` ```
@@ -123,8 +123,8 @@ async def ingest_batch(
@router.post("/all", response_model=BatchIngestionResult) @router.post("/all", response_model=BatchIngestionResult)
async def ingest_all_pages( async def ingest_all_pages(
user: str = Query(default="jpmschweitzer", description="User identifier"), user: RequiredUserQuery,
path_prefix: Optional[str] = Query(None, description="Path prefix filter (e.g., 'users/jpmschweitzer/tech')"), path_prefix: Optional[str] = Query(None, description="Path prefix filter within the user's namespace (e.g., 'users/<tenant>/tech')"),
force_refresh: bool = Query(False, description="Force re-ingestion of all pages"), force_refresh: bool = Query(False, description="Force re-ingestion of all pages"),
max_concurrent: int = Query(3, ge=1, le=10, description="Maximum concurrent ingestion tasks"), max_concurrent: int = Query(3, ge=1, le=10, description="Maximum concurrent ingestion tasks"),
ingestion: IngestionService = Depends(get_ingestion_service), ingestion: IngestionService = Depends(get_ingestion_service),
@@ -151,11 +151,11 @@ async def ingest_all_pages(
```bash ```bash
# Ingest all pages for user # Ingest all pages for user
curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer" \ curl -X POST "http://192.168.86.149:8089/ingest/all?user=<tenant>" \
-H "Authorization: Bearer $API_KEY" -H "Authorization: Bearer $API_KEY"
# Ingest only tech docs # Ingest only tech docs
curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer&path_prefix=users/jpmschweitzer/tech" \ curl -X POST "http://192.168.86.149:8089/ingest/all?user=<tenant>&path_prefix=users/<tenant>/tech" \
-H "Authorization: Bearer $API_KEY" -H "Authorization: Bearer $API_KEY"
``` ```
""" """
+13 -4
View File
@@ -22,7 +22,6 @@ from src.core.dependencies import (
QdrantDep, OllamaDep, PaperlessDep, verify_api_key QdrantDep, OllamaDep, PaperlessDep, verify_api_key
) )
from src.config import get_settings from src.config import get_settings
from src.core.multi_tenancy import DEFAULT_USER
from datetime import datetime, timezone from datetime import datetime, timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -262,6 +261,14 @@ def _matches_test_user_path(path: str) -> bool:
return any(path_lower.startswith(prefix) for prefix in TEST_USER_PATH_PREFIXES) return any(path_lower.startswith(prefix) for prefix in TEST_USER_PATH_PREFIXES)
def _tenant_from_path(path: str) -> str:
"""Extract the tenant user from a 'users/{tenant}/...' wiki path."""
parts = path.lstrip("/").split("/")
if len(parts) >= 2 and parts[0] == "users":
return parts[1]
raise ValueError(f"Cannot derive tenant from path: {path}")
# ========== Endpoints ========== # ========== Endpoints ==========
@router.post("/cleanup/vectors", response_model=VectorCleanupResponse) @router.post("/cleanup/vectors", response_model=VectorCleanupResponse)
@@ -652,12 +659,14 @@ async def cleanup_test_data(
page_path = page["path"] page_path = page["path"]
try: try:
# Delete vector chunks for this page (using DEFAULT_USER collection) # Delete vector chunks/graph node scoped to the tenant that
chunks_removed = await vector_service.delete_page_chunks(page_id, DEFAULT_USER) # owns the page (derived from its users/{tenant}/ path)
tenant = _tenant_from_path(page_path)
chunks_removed = await vector_service.delete_page_chunks(page_id, tenant)
vector_deleted += chunks_removed vector_deleted += chunks_removed
# Delete graph node for this page (returns count, may be 0 if no node) # Delete graph node for this page (returns count, may be 0 if no node)
graph_removed = await graph_service.delete_page(page_id, DEFAULT_USER) graph_removed = await graph_service.delete_page(page_id, tenant)
graph_deleted += graph_removed graph_deleted += graph_removed
# Delete wiki page (raises exception on failure, returns None on success) # Delete wiki page (raises exception on failure, returns None on success)
+18 -26
View File
@@ -25,10 +25,9 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier", description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=False, required=True,
default="jpmschweitzer", example="llm_tester"
example="jpmschweitzer"
), ),
ToolParameter( ToolParameter(
name="tag", name="tag",
@@ -66,9 +65,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier for access control", description="User identifier (tenant) for access control. Required",
required=False, required=True
default="jpmschweitzer"
), ),
], ],
returns="Complete page object with content", returns="Complete page object with content",
@@ -119,9 +117,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier", description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=False, required=True
default="jpmschweitzer"
), ),
], ],
returns="Created page object", returns="Created page object",
@@ -130,7 +127,7 @@ def get_wiki_tools() -> list[ToolDefinition]:
"path": "/projects/my-project", "path": "/projects/my-project",
"content": "# My Project\n\nProject description here.", "content": "# My Project\n\nProject description here.",
"tags": ["projects"], "tags": ["projects"],
"user": "jpmschweitzer" "user": "<tenant>"
}, },
fast=True fast=True
), ),
@@ -175,9 +172,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier", description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=False, required=True
default="jpmschweitzer"
), ),
], ],
returns="Updated page object", returns="Updated page object",
@@ -200,9 +196,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier", description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=False, required=True
default="jpmschweitzer"
), ),
], ],
returns="Success confirmation", returns="Success confirmation",
@@ -225,9 +220,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier", description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=False, required=True
default="jpmschweitzer"
), ),
ToolParameter( ToolParameter(
name="limit", name="limit",
@@ -250,9 +244,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier", description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=False, required=True
default="jpmschweitzer"
), ),
], ],
returns="List of dossiers with page counts", returns="List of dossiers with page counts",
@@ -275,9 +268,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter( ToolParameter(
name="user", name="user",
type=ParameterType.STRING, type=ParameterType.STRING,
description="User identifier", description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=False, required=True
default="jpmschweitzer"
), ),
ToolParameter( ToolParameter(
name="limit", name="limit",
+8 -7
View File
@@ -18,8 +18,9 @@ from src.services.vector_service import VectorService
from src.clients.qdrant_client import QdrantClientWrapper from src.clients.qdrant_client import QdrantClientWrapper
from src.clients.wikijs_client import WikiJSClient from src.clients.wikijs_client import WikiJSClient
from src.clients.ollama_client import OllamaClient from src.clients.ollama_client import OllamaClient
from src.core.dependencies import QdrantDep, WikiJSDep, OllamaDep, verify_api_key from src.core.dependencies import (
from src.core.multi_tenancy import DEFAULT_USER QdrantDep, WikiJSDep, OllamaDep, verify_api_key, RequiredUserQuery
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,7 +53,7 @@ async def semantic_search(
```json ```json
{ {
"query": "how to configure docker", "query": "how to configure docker",
"user": "jpmschweitzer", "user": "<tenant>",
"limit": 10, "limit": 10,
"score_threshold": 0.5 "score_threshold": 0.5
} }
@@ -77,7 +78,7 @@ async def semantic_search(
@router.post("/update-from-page/{page_id}", response_model=VectorUpdateSummary) @router.post("/update-from-page/{page_id}", response_model=VectorUpdateSummary)
async def update_vectors_from_page( async def update_vectors_from_page(
page_id: int, page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
force_refresh: bool = Query(default=False, description="Force re-embedding"), force_refresh: bool = Query(default=False, description="Force re-embedding"),
vector_service: VectorService = Depends(get_vector_service), vector_service: VectorService = Depends(get_vector_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -96,7 +97,7 @@ async def update_vectors_from_page(
- Called manually by user/Librarian to refresh vectors - Called manually by user/Librarian to refresh vectors
- Called by Scheduler for batch processing - Called by Scheduler for batch processing
**Example:** `POST /vector/update-from-page/5?user=jpmschweitzer` **Example:** `POST /vector/update-from-page/5?user=<tenant> (user is REQUIRED)`
**Returns:** Summary with chunks created and processing time **Returns:** Summary with chunks created and processing time
""" """
@@ -125,7 +126,7 @@ async def update_vectors_from_page(
@router.delete("/pages/{page_id}", response_model=DeletePageChunksResponse) @router.delete("/pages/{page_id}", response_model=DeletePageChunksResponse)
async def delete_page_chunks( async def delete_page_chunks(
page_id: int, page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
vector_service: VectorService = Depends(get_vector_service), vector_service: VectorService = Depends(get_vector_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
): ):
@@ -134,7 +135,7 @@ async def delete_page_chunks(
This is automatically called when a page is deleted from the wiki. This is automatically called when a page is deleted from the wiki.
**Example:** `DELETE /vector/pages/5?user=jpmschweitzer` **Example:** `DELETE /vector/pages/5?user=<tenant> (user is REQUIRED)`
""" """
try: try:
deleted_count = await vector_service.delete_page_chunks( deleted_count = await vector_service.delete_page_chunks(
+24 -24
View File
@@ -28,7 +28,7 @@ from src.core.dependencies import (
get_news_provider, get_news_provider,
get_alphavantage_provider, get_alphavantage_provider,
) )
from src.core.multi_tenancy import DEFAULT_USER from src.core.dependencies import RequiredUserQuery
from src.config import get_settings from src.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,7 +48,7 @@ def get_volatile_service(qdrant: QdrantDep, ollama: OllamaDep) -> VolatileCacheS
@router.get("/stats", response_model=VolatileStatsResponse) @router.get("/stats", response_model=VolatileStatsResponse)
async def get_stats( async def get_stats(
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -72,7 +72,7 @@ async def get_stats(
@router.get("/scheduled", response_model=VolatileScheduledResponse) @router.get("/scheduled", response_model=VolatileScheduledResponse)
async def get_scheduled( async def get_scheduled(
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -134,8 +134,8 @@ def _get_namespace_description(ns: VolatileNamespace) -> str:
@router.get("/search") @router.get("/search")
async def search_volatile( async def search_volatile(
user: RequiredUserQuery,
q: str = Query(..., min_length=1, description="Search query"), q: str = Query(..., min_length=1, description="Search query"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=5, ge=1, le=20, description="Maximum results"), limit: int = Query(default=5, ge=1, le=20, description="Maximum results"),
threshold: float = Query(default=0.75, ge=0.5, le=1.0, description="Minimum similarity score"), threshold: float = Query(default=0.75, ge=0.5, le=1.0, description="Minimum similarity score"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
@@ -166,10 +166,10 @@ async def search_volatile(
@router.post("/store", response_model=VolatileRecordResponse) @router.post("/store", response_model=VolatileRecordResponse)
async def store_volatile( async def store_volatile(
user: RequiredUserQuery,
namespace: str = Query(..., description="Data namespace (weather, news, etc.)"), namespace: str = Query(..., description="Data namespace (weather, news, etc.)"),
key: str = Query(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')"), key: str = Query(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')"),
request: VolatileRecordCreate = None, request: VolatileRecordCreate = None,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -232,7 +232,7 @@ async def store_volatile(
@router.post("/fetch/weather/{city}") @router.post("/fetch/weather/{city}")
async def fetch_weather( async def fetch_weather(
city: str, city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"), ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
@@ -246,7 +246,7 @@ async def fetch_weather(
**Example:** **Example:**
``` ```
POST /volatile/fetch/weather/amsterdam?user=jpmschweitzer POST /volatile/fetch/weather/amsterdam?user=<tenant>
``` ```
""" """
volatile_service = get_volatile_service(qdrant, ollama) volatile_service = get_volatile_service(qdrant, ollama)
@@ -273,7 +273,7 @@ async def fetch_weather(
@router.post("/fetch/forecast/{city}") @router.post("/fetch/forecast/{city}")
async def fetch_forecast( async def fetch_forecast(
city: str, city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
days: int = Query(default=7, ge=1, le=16, description="Forecast days (1-16)"), days: int = Query(default=7, ge=1, le=16, description="Forecast days (1-16)"),
ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds (default 24 hours)"), ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds (default 24 hours)"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
@@ -288,7 +288,7 @@ async def fetch_forecast(
**Example:** **Example:**
``` ```
POST /volatile/fetch/forecast/amsterdam?user=jpmschweitzer&days=7 POST /volatile/fetch/forecast/amsterdam?user=<tenant>&days=7
``` ```
""" """
volatile_service = get_volatile_service(qdrant, ollama) volatile_service = get_volatile_service(qdrant, ollama)
@@ -314,8 +314,8 @@ async def fetch_forecast(
@router.post("/fetch/news/{category}") @router.post("/fetch/news/{category}")
async def fetch_news( async def fetch_news(
user: RequiredUserQuery,
category: str = "general", category: str = "general",
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=10, ge=1, le=50, description="Max headlines"), limit: int = Query(default=10, ge=1, le=50, description="Max headlines"),
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds"), ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
@@ -330,7 +330,7 @@ async def fetch_news(
**Example:** **Example:**
``` ```
POST /volatile/fetch/news/tech?user=jpmschweitzer&limit=15 POST /volatile/fetch/news/tech?user=<tenant>&limit=15
``` ```
""" """
volatile_service = get_volatile_service(qdrant, ollama) volatile_service = get_volatile_service(qdrant, ollama)
@@ -359,7 +359,7 @@ async def fetch_news(
@router.post("/fetch/stock/{symbol}") @router.post("/fetch/stock/{symbol}")
async def fetch_stock( async def fetch_stock(
symbol: str, symbol: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"), ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
@@ -372,7 +372,7 @@ async def fetch_stock(
**Example:** **Example:**
``` ```
POST /volatile/fetch/stock/AAPL?user=jpmschweitzer POST /volatile/fetch/stock/AAPL?user=<tenant>
``` ```
""" """
volatile_service = get_volatile_service(qdrant, ollama) volatile_service = get_volatile_service(qdrant, ollama)
@@ -407,8 +407,8 @@ async def fetch_stock(
@router.post("/fetch/crypto/{symbol}") @router.post("/fetch/crypto/{symbol}")
async def fetch_crypto( async def fetch_crypto(
symbol: str, symbol: str,
user: RequiredUserQuery,
market: str = Query(default="USD", description="Market currency"), market: str = Query(default="USD", description="Market currency"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"), ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
@@ -421,7 +421,7 @@ async def fetch_crypto(
**Example:** **Example:**
``` ```
POST /volatile/fetch/crypto/BTC?market=EUR&user=jpmschweitzer POST /volatile/fetch/crypto/BTC?market=EUR&user=<tenant>
``` ```
""" """
volatile_service = get_volatile_service(qdrant, ollama) volatile_service = get_volatile_service(qdrant, ollama)
@@ -456,7 +456,7 @@ async def fetch_crypto(
@router.post("/fetch/sun/{city}") @router.post("/fetch/sun/{city}")
async def fetch_sun_times( async def fetch_sun_times(
city: str, city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
ttl: int = Query(default=172800, ge=60, le=604800, description="TTL in seconds (default 48 hours)"), ttl: int = Query(default=172800, ge=60, le=604800, description="TTL in seconds (default 48 hours)"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
@@ -469,7 +469,7 @@ async def fetch_sun_times(
**Example:** **Example:**
``` ```
POST /volatile/fetch/sun/rotterdam?user=jpmschweitzer POST /volatile/fetch/sun/rotterdam?user=<tenant>
``` ```
**Response data includes:** **Response data includes:**
@@ -502,7 +502,7 @@ async def fetch_sun_times(
@router.post("/fetch/air_quality/{city}") @router.post("/fetch/air_quality/{city}")
async def fetch_air_quality( async def fetch_air_quality(
city: str, city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"), ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
@@ -515,7 +515,7 @@ async def fetch_air_quality(
**Example:** **Example:**
``` ```
POST /volatile/fetch/air_quality/rotterdam?user=jpmschweitzer POST /volatile/fetch/air_quality/rotterdam?user=<tenant>
``` ```
**Response data includes:** **Response data includes:**
@@ -548,7 +548,7 @@ async def fetch_air_quality(
@router.post("/fetch/environment/{city}") @router.post("/fetch/environment/{city}")
async def fetch_environment( async def fetch_environment(
city: str, city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
weather_ttl: int = Query(default=7200, ge=60, le=86400, description="Weather TTL in seconds (default 2 hours)"), weather_ttl: int = Query(default=7200, ge=60, le=86400, description="Weather TTL in seconds (default 2 hours)"),
air_quality_ttl: int = Query(default=7200, ge=60, le=86400, description="Air quality TTL in seconds (default 2 hours)"), air_quality_ttl: int = Query(default=7200, ge=60, le=86400, description="Air quality TTL in seconds (default 2 hours)"),
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
@@ -564,7 +564,7 @@ async def fetch_environment(
**Example:** **Example:**
``` ```
POST /volatile/fetch/environment/rotterdam?user=jpmschweitzer POST /volatile/fetch/environment/rotterdam?user=<tenant>
``` ```
**Response includes:** **Response includes:**
@@ -607,7 +607,7 @@ async def fetch_environment(
async def get_record( async def get_record(
namespace: str, namespace: str,
key: str, key: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -617,7 +617,7 @@ async def get_record(
**Example:** **Example:**
``` ```
GET /volatile/weather/rotterdam?user=jpmschweitzer GET /volatile/weather/rotterdam?user=<tenant>
``` ```
""" """
service = get_volatile_service(qdrant, ollama) service = get_volatile_service(qdrant, ollama)
@@ -636,7 +636,7 @@ async def get_record(
async def delete_record( async def delete_record(
namespace: str, namespace: str,
key: str, key: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
qdrant: QdrantDep = None, qdrant: QdrantDep = None,
ollama: OllamaDep = None, ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
+15 -13
View File
@@ -24,9 +24,9 @@ from src.clients.qdrant_client import QdrantClientWrapper
from src.clients.ollama_client import OllamaClient from src.clients.ollama_client import OllamaClient
from src.core.dependencies import ( from src.core.dependencies import (
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep, ContentExtractorDep, WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep, ContentExtractorDep,
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service,
RequiredUserQuery
) )
from src.core.multi_tenancy import DEFAULT_USER
from src.services.hybrid_rag_service import HybridRAGService from src.services.hybrid_rag_service import HybridRAGService
from src.services.wiki_page_writer import WikiPageWriter from src.services.wiki_page_writer import WikiPageWriter
from src.services.entity_linking_utils import apply_bidirectional_entity_linking from src.services.entity_linking_utils import apply_bidirectional_entity_linking
@@ -62,7 +62,7 @@ def get_vector_service(
# Page operations # Page operations
@router.get("/pages", response_model=WikiPageList) @router.get("/pages", response_model=WikiPageList)
async def list_pages( async def list_pages(
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
tag: Optional[str] = Query(default=None, description="Filter by tag (dossier)"), tag: Optional[str] = Query(default=None, description="Filter by tag (dossier)"),
limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"), limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"),
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
@@ -87,7 +87,7 @@ async def list_pages(
@router.get("/pages/{page_id}", response_model=WikiPage) @router.get("/pages/{page_id}", response_model=WikiPage)
async def get_page( async def get_page(
page_id: int, page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
): ):
@@ -139,14 +139,16 @@ async def create_page(
"content": "# Architecture\\n\\nThis describes...", "content": "# Architecture\\n\\nThis describes...",
"description": "Architecture documentation", "description": "Architecture documentation",
"tags": ["projects", "architecture"], "tags": ["projects", "architecture"],
"user": "jpmschweitzer" "user": "<tenant>"
} }
``` ```
The `user` field is REQUIRED (no default tenant).
""" """
try: try:
page = await wiki_service.create_page(page_data) page = await wiki_service.create_page(page_data)
user = page_data.user or DEFAULT_USER user = page_data.user
# Schedule BOTH graph and vector updates in background (non-blocking) # Schedule BOTH graph and vector updates in background (non-blocking)
background_tasks.add_task( background_tasks.add_task(
@@ -214,7 +216,7 @@ async def smart_create_page(
- Entity linking statistics (forward/backward links) - Entity linking statistics (forward/backward links)
""" """
try: try:
user = request.user or DEFAULT_USER user = request.user
# Build services # Build services
wiki_service = WikiService(wiki_client) wiki_service = WikiService(wiki_client)
@@ -294,7 +296,7 @@ async def update_page(
page_id: int, page_id: int,
page_data: WikiPageUpdate, page_data: WikiPageUpdate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
graph_service: GraphService = Depends(get_graph_service), graph_service: GraphService = Depends(get_graph_service),
vector_service: VectorService = Depends(get_vector_service), vector_service: VectorService = Depends(get_vector_service),
@@ -353,7 +355,7 @@ async def update_page(
async def delete_page( async def delete_page(
page_id: int, page_id: int,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
vector_service: VectorService = Depends(get_vector_service), vector_service: VectorService = Depends(get_vector_service),
graph_service: GraphService = Depends(get_graph_service), graph_service: GraphService = Depends(get_graph_service),
@@ -402,7 +404,7 @@ async def delete_page(
async def move_page( async def move_page(
page_id: int, page_id: int,
move_data: WikiPageMove, move_data: WikiPageMove,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
): ):
@@ -445,8 +447,8 @@ async def move_page(
# Search operations # Search operations
@router.get("/search", response_model=WikiSearchResponse) @router.get("/search", response_model=WikiSearchResponse)
async def search_pages( async def search_pages(
user: RequiredUserQuery,
q: str = Query(..., min_length=1, description="Search query"), q: str = Query(..., min_length=1, description="Search query"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=20, ge=1, le=100, description="Maximum results"), limit: int = Query(default=20, ge=1, le=100, description="Maximum results"),
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
@@ -487,7 +489,7 @@ async def search_pages(
# Dossier operations # Dossier operations
@router.get("/dossiers", response_model=DossierList) @router.get("/dossiers", response_model=DossierList)
async def list_dossiers( async def list_dossiers(
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
): ):
@@ -510,7 +512,7 @@ async def list_dossiers(
@router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList) @router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList)
async def get_dossier_pages( async def get_dossier_pages(
dossier_name: str, dossier_name: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"), user: RequiredUserQuery,
limit: int = Query(default=100, ge=1, le=500, description="Maximum pages"), limit: int = Query(default=100, ge=1, le=500, description="Maximum pages"),
wiki_service: WikiService = Depends(get_wiki_service), wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key) api_key: str = Depends(verify_api_key)
+2 -2
View File
@@ -398,7 +398,7 @@ class ConsolidationService:
query: str, query: str,
web_results: List[Dict[str, Any]], web_results: List[Dict[str, Any]],
keywords: List[str], keywords: List[str],
user: str = "jpmschweitzer" user: str
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Analyze web results with Ollama for novel information. Analyze web results with Ollama for novel information.
@@ -977,7 +977,7 @@ JSON:"""
query: str, query: str,
web_results: List[Dict[str, Any]], web_results: List[Dict[str, Any]],
keywords: List[str], keywords: List[str],
user: str = "jpmschweitzer" user: str
) -> MemoryRoutingResult: ) -> MemoryRoutingResult:
""" """
Unified classification of web results for memory routing. Unified classification of web results for memory routing.
+11 -2
View File
@@ -103,8 +103,17 @@ class WikiChangeListener:
} }
event = event_map.get(operation, 'page.update') event = event_map.get(operation, 'page.update')
# Extract user from email # Extract user from email. There is NO default tenant: if no user
user = user_email.split('@')[0] if '@' in user_email else 'jpmschweitzer' # can be derived from the notification, skip processing instead of
# attributing the change to an arbitrary tenant.
if '@' in user_email and user_email.split('@')[0].strip():
user = user_email.split('@')[0].strip()
else:
logger.warning(
f"Skipping page {page_id} change: cannot derive tenant user "
f"from notification email {user_email!r}"
)
return
# Process the change # Process the change
await self._process_page_change( await self._process_page_change(
+2 -2
View File
@@ -12,7 +12,7 @@ from typing import List, Optional, Dict, Any
import logging import logging
from src.clients.wikijs_client import WikiJSClient from src.clients.wikijs_client import WikiJSClient
from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id, DEFAULT_USER from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id
from src.models.wiki import ( from src.models.wiki import (
WikiPage, WikiPageSummary, WikiPageList, WikiPage, WikiPageSummary, WikiPageList,
WikiPageCreate, WikiPageUpdate, WikiPageCreate, WikiPageUpdate,
@@ -184,7 +184,7 @@ class WikiService:
Raises: Raises:
ValueError: If creation fails ValueError: If creation fails
""" """
user = page_data.user or DEFAULT_USER user = page_data.user
# Ensure path is in user's namespace # Ensure path is in user's namespace
full_path = self._ensure_user_path(page_data.path, user) full_path = self._ensure_user_path(page_data.path, user)
+38 -3
View File
@@ -7,8 +7,8 @@ from src.core.multi_tenancy import (
get_wikijs_namespace, get_wikijs_namespace,
get_neo4j_user_label, get_neo4j_user_label,
validate_user_id, validate_user_id,
validate_required_user,
is_path_in_user_namespace, is_path_in_user_namespace,
DEFAULT_USER
) )
@@ -46,8 +46,8 @@ class TestQdrantCollectionName:
def test_email_user(self): def test_email_user(self):
assert get_qdrant_collection_name("john@example.com") == "library_desk_john_at_example_com" assert get_qdrant_collection_name("john@example.com") == "library_desk_john_at_example_com"
def test_default_user(self): def test_test_tenant(self):
assert get_qdrant_collection_name(DEFAULT_USER) == f"library_desk_{DEFAULT_USER}" assert get_qdrant_collection_name("llm_tester") == "library_desk_llm_tester"
class TestWikijsNamespace: class TestWikijsNamespace:
@@ -99,6 +99,41 @@ class TestValidateUserId:
assert validate_user_id("___") is False assert validate_user_id("___") is False
class TestValidateRequiredUser:
"""Test the required-user validator (no default tenant)."""
def test_no_default_user_constant(self):
"""The DEFAULT_USER escape hatch must not exist anymore."""
import src.core.multi_tenancy as mt
assert not hasattr(mt, "DEFAULT_USER")
def test_valid_user_returned(self):
assert validate_required_user("llm_tester") == "llm_tester"
def test_valid_user_stripped(self):
assert validate_required_user(" llm_tester ") == "llm_tester"
def test_empty_rejected(self):
with pytest.raises(ValueError):
validate_required_user("")
def test_whitespace_rejected(self):
with pytest.raises(ValueError):
validate_required_user(" ")
def test_none_rejected(self):
with pytest.raises(ValueError):
validate_required_user(None)
def test_no_alphanumeric_rejected(self):
with pytest.raises(ValueError):
validate_required_user("___")
def test_too_long_rejected(self):
with pytest.raises(ValueError):
validate_required_user("a" * 101)
class TestPathInNamespace: class TestPathInNamespace:
"""Test path namespace checking.""" """Test path namespace checking."""
+9 -2
View File
@@ -37,12 +37,19 @@ class TestRAGSearchModels:
"""Tests for RAG search Pydantic models.""" """Tests for RAG search Pydantic models."""
def test_search_request_defaults(self): def test_search_request_defaults(self):
"""Test RAGSearchRequest with default values.""" """Test RAGSearchRequest defaults (user is required, no default tenant)."""
request = RAGSearchRequest(query="test query") request = RAGSearchRequest(query="test query", user="llm_tester")
assert request.query == "test query" assert request.query == "test query"
assert request.search_type == SearchType.WEB assert request.search_type == SearchType.WEB
assert request.limit == 10 assert request.limit == 10
assert request.user == "llm_tester"
def test_search_request_requires_user(self):
"""A request without an explicit user must be rejected."""
import pytest
with pytest.raises(ValueError):
RAGSearchRequest(query="test query")
def test_search_request_custom_values(self): def test_search_request_custom_values(self):
"""Test RAGSearchRequest with custom values.""" """Test RAGSearchRequest with custom values."""
+117
View File
@@ -0,0 +1,117 @@
"""
Offline unit tests: every tenant-data endpoint must REQUIRE an explicit user.
A request without a user (query param or body field) must be rejected with
422 before any service is touched. Empty/whitespace users are also rejected.
No external services are contacted: validation failures short-circuit the
request before the endpoint body executes.
"""
import pytest
from fastapi.testclient import TestClient
from src.main import app
from src.core.dependencies import verify_api_key
@pytest.fixture(scope="module")
def client():
"""TestClient with API-key auth stubbed out (no lifespan startup)."""
app.dependency_overrides[verify_api_key] = lambda: "test-key"
try:
# No context manager: startup/lifespan events are NOT triggered,
# so no connections to external services are attempted.
yield TestClient(app)
finally:
app.dependency_overrides.pop(verify_api_key, None)
QUERY_PARAM_ENDPOINTS = [
("GET", "/stats"),
("POST", "/query/semantic?query=test"),
("POST", "/query/graph?query=MATCH%20(n)%20RETURN%20n"),
("GET", "/wiki/pages"),
("GET", "/wiki/pages/1"),
("PUT", "/wiki/pages/1"),
("DELETE", "/wiki/pages/1"),
("GET", "/wiki/search?q=test"),
("GET", "/wiki/dossiers"),
("POST", "/vector/update-from-page/1"),
("DELETE", "/vector/pages/1"),
("GET", "/graph/nodes"),
("POST", "/graph/update-from-page/1"),
("POST", "/graph/generate-entity-pages"),
("POST", "/ingest/all"),
("GET", "/volatile/stats"),
("GET", "/volatile/search?q=test"),
("POST", "/volatile/store?namespace=weather&key=test"),
("GET", "/volatile/weather/rotterdam"),
("DELETE", "/volatile/weather/rotterdam"),
("POST", "/documents/webhook-simple?doc_url=http://x/documents/1/"),
]
@pytest.mark.unit
class TestUserQueryParamRequired:
"""Endpoints with a user query parameter must 422 without it."""
@pytest.mark.parametrize("method,path", QUERY_PARAM_ENDPOINTS)
def test_missing_user_is_422(self, client, method, path):
response = client.request(method, path, json={})
assert response.status_code == 422, (
f"{method} {path} returned {response.status_code}, expected 422"
)
@pytest.mark.parametrize("blank", ["", " ", "%20%20"])
def test_blank_user_is_422(self, client, blank):
response = client.get(f"/wiki/pages?user={blank}")
assert response.status_code == 422
def test_hybrid_query_missing_user_is_422(self, client):
response = client.post("/query/hybrid", json={"query": "test"})
assert response.status_code == 422
def test_hybrid_query_whitespace_user_is_422(self, client):
response = client.post("/query/hybrid?user=%20", json={"query": "test"})
assert response.status_code == 422
@pytest.mark.unit
class TestUserBodyFieldRequired:
"""Request models with a user field must reject missing/blank values."""
def test_ingest_page_missing_user_is_422(self, client):
response = client.post("/ingest/page", json={"page_id": 1})
assert response.status_code == 422
def test_ingest_page_blank_user_is_422(self, client):
response = client.post("/ingest/page", json={"page_id": 1, "user": " "})
assert response.status_code == 422
def test_ingest_batch_missing_user_is_422(self, client):
response = client.post("/ingest/batch", json={"page_ids": [1]})
assert response.status_code == 422
def test_wiki_create_page_missing_user_is_422(self, client):
response = client.post(
"/wiki/pages",
json={"title": "T", "path": "/t", "content": "c"},
)
assert response.status_code == 422
def test_wiki_smart_create_missing_user_is_422(self, client):
response = client.post("/wiki/pages/smart-create", json={"topic": "T"})
assert response.status_code == 422
def test_vector_search_missing_user_is_422(self, client):
response = client.post("/vector/search", json={"query": "test"})
assert response.status_code == 422
def test_graph_query_missing_user_is_422(self, client):
response = client.post("/graph/query", json={"query": "MATCH (n) RETURN n"})
assert response.status_code == 422
def test_rag_search_missing_user_is_422(self, client):
response = client.post("/rag/search", json={"query": "test"})
assert response.status_code == 422
+20 -10
View File
@@ -26,15 +26,20 @@ class TestWikiSmartCreateRequest:
"""Tests for WikiSmartCreateRequest model validation.""" """Tests for WikiSmartCreateRequest model validation."""
def test_minimal_request(self): def test_minimal_request(self):
"""Test request with only required field.""" """Test request with only required fields (topic AND user)."""
request = WikiSmartCreateRequest(topic="Docker containers") request = WikiSmartCreateRequest(topic="Docker containers", user="llm_tester")
assert request.topic == "Docker containers" assert request.topic == "Docker containers"
assert request.path is None assert request.path is None
assert request.tags == [] assert request.tags == []
assert request.user is None assert request.user == "llm_tester"
assert request.include_web_research is True assert request.include_web_research is True
assert request.include_wiki_search is True assert request.include_wiki_search is True
def test_user_is_required(self):
"""A request without an explicit user must be rejected."""
with pytest.raises(ValueError):
WikiSmartCreateRequest(topic="Docker containers")
def test_full_request(self): def test_full_request(self):
"""Test request with all fields.""" """Test request with all fields."""
request = WikiSmartCreateRequest( request = WikiSmartCreateRequest(
@@ -68,7 +73,8 @@ class TestWikiSmartCreateRequest:
"""Test that path without leading slash gets one added.""" """Test that path without leading slash gets one added."""
request = WikiSmartCreateRequest( request = WikiSmartCreateRequest(
topic="Test", topic="Test",
path="technology/test" path="technology/test",
user="llm_tester"
) )
assert request.path == "/technology/test" assert request.path == "/technology/test"
@@ -76,7 +82,8 @@ class TestWikiSmartCreateRequest:
"""Test that trailing slash is removed.""" """Test that trailing slash is removed."""
request = WikiSmartCreateRequest( request = WikiSmartCreateRequest(
topic="Test", topic="Test",
path="/technology/test/" path="/technology/test/",
user="llm_tester"
) )
assert request.path == "/technology/test" assert request.path == "/technology/test"
@@ -84,7 +91,8 @@ class TestWikiSmartCreateRequest:
"""Test that duplicate tags are removed.""" """Test that duplicate tags are removed."""
request = WikiSmartCreateRequest( request = WikiSmartCreateRequest(
topic="Test", topic="Test",
tags=["devops", "devops", "containers", "devops"] tags=["devops", "devops", "containers", "devops"],
user="llm_tester"
) )
assert len(request.tags) == 2 assert len(request.tags) == 2
assert "devops" in request.tags assert "devops" in request.tags
@@ -94,7 +102,8 @@ class TestWikiSmartCreateRequest:
"""Test that tag whitespace is cleaned.""" """Test that tag whitespace is cleaned."""
request = WikiSmartCreateRequest( request = WikiSmartCreateRequest(
topic="Test", topic="Test",
tags=[" devops ", "containers", " ", ""] tags=[" devops ", "containers", " ", ""],
user="llm_tester"
) )
assert "devops" in request.tags assert "devops" in request.tags
assert "containers" in request.tags assert "containers" in request.tags
@@ -536,7 +545,8 @@ class TestSmartCreateEndpoint:
# For now, we test the model validation # For now, we test the model validation
request = WikiSmartCreateRequest( request = WikiSmartCreateRequest(
topic="Test Topic", topic="Test Topic",
tags=["test"] tags=["test"],
user="llm_tester"
) )
assert request.topic == "Test Topic" assert request.topic == "Test Topic"
@@ -546,8 +556,8 @@ class TestSmartCreateEndpoint:
WikiSmartCreateRequest(topic="") WikiSmartCreateRequest(topic="")
def test_request_accepts_minimal_input(self): def test_request_accepts_minimal_input(self):
"""Test that only topic is required.""" """Test that topic and user are the only required fields."""
request = WikiSmartCreateRequest(topic="Minimal test") request = WikiSmartCreateRequest(topic="Minimal test", user="llm_tester")
assert request.topic == "Minimal test" assert request.topic == "Minimal test"
assert request.include_web_research is True # default assert request.include_web_research is True # default
assert request.include_wiki_search is True # default assert request.include_wiki_search is True # default
+2 -3
View File
@@ -231,9 +231,8 @@ class TestWikiChangeListener:
'UPDATE:123:invaliduser' 'UPDATE:123:invaliduser'
) )
# Should use default user # No default tenant: change must be skipped entirely
call_args = mock_process.call_args[1] mock_process.assert_not_awaited()
assert call_args['user'] == 'jpmschweitzer'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_page_delete_calls_cleanup(self, listener): async def test_process_page_delete_calls_cleanup(self, listener):