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:
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
@@ -10,7 +10,7 @@ Provides FastAPI dependencies for service clients with:
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
@@ -38,6 +38,35 @@ logger = logging.getLogger(__name__)
|
||||
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
|
||||
@lru_cache
|
||||
def get_neo4j_client() -> Neo4jClient:
|
||||
|
||||
@@ -8,9 +8,9 @@ Provides utilities for user namespace management across:
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
# Default user for all operations
|
||||
DEFAULT_USER = "jpmschweitzer"
|
||||
from pydantic import AfterValidator
|
||||
|
||||
|
||||
def sanitize_user_id(user_id: str) -> str:
|
||||
@@ -186,6 +186,45 @@ def validate_user_id(user_id: str) -> bool:
|
||||
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:
|
||||
"""
|
||||
Check if a Wiki.js path belongs to user's namespace.
|
||||
|
||||
+5
-5
@@ -18,9 +18,9 @@ from pathlib import Path
|
||||
|
||||
from src.config import Settings, get_settings, __version__
|
||||
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
|
||||
logging.basicConfig(
|
||||
@@ -151,7 +151,7 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
|
||||
@app.get("/stats", response_model=StatsResponse, tags=["System"])
|
||||
async def stats(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
neo4j: Neo4jDep = None,
|
||||
qdrant: QdrantDep = None,
|
||||
wikijs: WikiJSDep = None,
|
||||
@@ -290,8 +290,8 @@ async def get_repo_status(
|
||||
|
||||
@app.post("/query/semantic", tags=["Query"])
|
||||
async def semantic_query(
|
||||
user: RequiredUserQuery,
|
||||
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"),
|
||||
score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"),
|
||||
qdrant_client: QdrantDep = None,
|
||||
@@ -331,8 +331,8 @@ async def semantic_query(
|
||||
|
||||
@app.post("/query/graph", tags=["Query"])
|
||||
async def graph_query(
|
||||
user: RequiredUserQuery,
|
||||
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,
|
||||
wiki_client: WikiJSDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
|
||||
+12
-6
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
"""Graph node representation."""
|
||||
@@ -45,9 +47,13 @@ class CypherQueryRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="Query parameters"
|
||||
)
|
||||
user: str = Field(
|
||||
default="jpmschweitzer",
|
||||
description="User for filtering (automatically scopes query)"
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
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):
|
||||
"""Request to update graph from a wiki page."""
|
||||
page_id: int = Field(..., description="Wiki page ID to process")
|
||||
user: str = Field(
|
||||
default="jpmschweitzer",
|
||||
description="User identifier for namespace scoping"
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — graph writes are scoped to this tenant's labels."
|
||||
)
|
||||
force_refresh: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -5,11 +5,13 @@ from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class IngestionRequest(BaseModel):
|
||||
"""Request to ingest a wiki page."""
|
||||
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(
|
||||
default=False,
|
||||
description="Force re-ingestion even if page hasn't changed"
|
||||
@@ -21,7 +23,7 @@ class IngestionRequest(BaseModel):
|
||||
class BatchIngestionRequest(BaseModel):
|
||||
"""Request to ingest multiple wiki pages."""
|
||||
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)
|
||||
skip_vectors: bool = Field(default=False)
|
||||
skip_graph: bool = Field(default=False)
|
||||
|
||||
@@ -8,7 +8,7 @@ from enum import Enum
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class SearchType(str, Enum):
|
||||
@@ -37,9 +37,9 @@ class RAGSearchRequest(BaseModel):
|
||||
le=20,
|
||||
description="Maximum number of results (1-20)"
|
||||
)
|
||||
user: str = Field(
|
||||
default=DEFAULT_USER,
|
||||
description="User identifier for rate limiting/personalization"
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — used for rate limiting/personalization."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ Provides models for semantic search, document chunks, and embeddings.
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class DocumentChunk(BaseModel):
|
||||
"""Document chunk with embedding."""
|
||||
@@ -32,7 +34,7 @@ class SearchResult(BaseModel):
|
||||
class SearchRequest(BaseModel):
|
||||
"""Semantic search request."""
|
||||
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")
|
||||
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):
|
||||
"""Request to update vectors from a wiki page."""
|
||||
page_id: int = Field(..., description="Wiki page ID to process")
|
||||
user: str = Field(
|
||||
default="jpmschweitzer",
|
||||
description="User identifier for namespace scoping"
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — vectors are written to this tenant's collection."
|
||||
)
|
||||
force_refresh: bool = Field(
|
||||
default=False,
|
||||
@@ -89,7 +91,7 @@ class CollectionListResponse(BaseModel):
|
||||
class DeletePageChunksRequest(BaseModel):
|
||||
"""Request to delete all chunks for a page."""
|
||||
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):
|
||||
|
||||
+5
-3
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
# Base models
|
||||
class WikiPageBase(BaseModel):
|
||||
@@ -35,7 +37,7 @@ class WikiPageCreate(WikiPageBase):
|
||||
content: str = Field(..., description="Page content (markdown)")
|
||||
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")
|
||||
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")
|
||||
@classmethod
|
||||
@@ -106,7 +108,7 @@ class DossierCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200, description="Human-readable title")
|
||||
description: str = Field(..., min_length=1, description="Dossier description")
|
||||
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")
|
||||
@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")
|
||||
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")
|
||||
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_wiki_search: bool = Field(default=True, description="Include existing wiki knowledge")
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from src.core.dependencies import (
|
||||
Neo4jDep,
|
||||
WikiJSDep,
|
||||
)
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.core.dependencies import RequiredUserQuery
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,7 +50,7 @@ async def receive_webhook(
|
||||
ollama: OllamaDep,
|
||||
neo4j: Neo4jDep,
|
||||
wiki: WikiJSDep,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
):
|
||||
"""
|
||||
Receive webhook events from Paperless-ngx.
|
||||
@@ -161,9 +161,9 @@ async def capture_webhook(request: Request):
|
||||
|
||||
@router.post("/webhook-simple", response_model=WebhookResponse)
|
||||
async def receive_webhook_simple(
|
||||
user: RequiredUserQuery,
|
||||
doc_url: str = Query(..., description="Paperless document URL containing ID"),
|
||||
title: str = Query(default="", description="Document title"),
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
paperless: PaperlessDep = None,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
@@ -347,7 +347,7 @@ async def search_documents(
|
||||
request: DocumentSearchRequest,
|
||||
qdrant: QdrantDep,
|
||||
ollama: OllamaDep,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
api_key: str = Depends(verify_api_key),
|
||||
):
|
||||
"""
|
||||
|
||||
+12
-11
@@ -15,8 +15,9 @@ from src.models.graph import (
|
||||
MindMapResponse
|
||||
)
|
||||
from src.services.graph_service import GraphService
|
||||
from src.core.dependencies import Neo4jDep, WikiJSDep, verify_api_key
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.core.dependencies import (
|
||||
Neo4jDep, WikiJSDep, verify_api_key, RequiredUserQuery
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,7 +50,7 @@ async def execute_cypher_query(
|
||||
{
|
||||
"query": "MATCH (d:Document) RETURN d LIMIT 10",
|
||||
"parameters": {},
|
||||
"user": "jpmschweitzer"
|
||||
"user": "<tenant>"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,7 +71,7 @@ async def execute_cypher_query(
|
||||
|
||||
@router.get("/nodes", response_model=NodeListResponse)
|
||||
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"),
|
||||
limit: int = Query(default=100, ge=1, le=500, description="Maximum nodes"),
|
||||
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.).
|
||||
|
||||
**Example:** `/graph/nodes?user=jpmschweitzer&node_type=Document&limit=50`
|
||||
**Example:** `/graph/nodes?user=<tenant>&node_type=Document&limit=50`
|
||||
"""
|
||||
try:
|
||||
return await graph_service.list_nodes(
|
||||
@@ -97,7 +98,7 @@ async def list_nodes(
|
||||
@router.get("/nodes/{node_id}", response_model=GraphNodeDetail)
|
||||
async def get_node(
|
||||
node_id: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -106,7 +107,7 @@ async def get_node(
|
||||
|
||||
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:
|
||||
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)
|
||||
async def update_graph_from_page(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
force_refresh: bool = Query(default=False, description="Force re-extraction"),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
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 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
|
||||
"""
|
||||
@@ -171,8 +172,8 @@ async def update_graph_from_page(
|
||||
|
||||
@router.post("/mindmap", response_model=MindMapResponse)
|
||||
async def generate_mindmap(
|
||||
user: RequiredUserQuery,
|
||||
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"),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -205,7 +206,7 @@ async def generate_mindmap(
|
||||
|
||||
@router.post("/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"),
|
||||
entity_types: Optional[List[str]] = Query(default=None, description="Entity types to process"),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
|
||||
@@ -5,14 +5,15 @@ Provides endpoint for combining vector, graph, volatile cache, and web search
|
||||
with RRF fusion and LLM re-ranking.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import logging
|
||||
|
||||
from src.models.hybrid_rag import HybridRAGRequest, HybridRAGResponse
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
from src.core.dependencies import (
|
||||
Neo4jDep, WikiJSDep, QdrantDep, OllamaDep,
|
||||
SearXNGDep, ContentExtractorDep, verify_api_key, get_settings
|
||||
SearXNGDep, ContentExtractorDep, verify_api_key, get_settings,
|
||||
RequiredUserQuery
|
||||
)
|
||||
from src.config import Settings
|
||||
|
||||
@@ -56,7 +57,7 @@ def get_hybrid_rag_service(
|
||||
@router.post("/hybrid", response_model=HybridRAGResponse)
|
||||
async def hybrid_search(
|
||||
request: HybridRAGRequest,
|
||||
user: str = Query(default="jpmschweitzer", description="User identifier for multi-tenancy"),
|
||||
user: RequiredUserQuery,
|
||||
hybrid_rag_service: HybridRAGService = Depends(get_hybrid_rag_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -72,11 +73,13 @@ async def hybrid_search(
|
||||
6. **Context Formatting**: Format for LLM consumption
|
||||
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
|
||||
{
|
||||
"query": "What's the weather in Rotterdam?",
|
||||
"user": "jpmschweitzer",
|
||||
"config": {
|
||||
"vector_limit": 10,
|
||||
"graph_limit": 10,
|
||||
|
||||
@@ -13,7 +13,7 @@ from src.models.ingestion import (
|
||||
BatchIngestionRequest,
|
||||
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"])
|
||||
|
||||
@@ -52,7 +52,7 @@ async def ingest_page(
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"page_id": 19,
|
||||
"user": "jpmschweitzer",
|
||||
"user": "<tenant>",
|
||||
"force_refresh": false
|
||||
}'
|
||||
```
|
||||
@@ -104,7 +104,7 @@ async def ingest_batch(
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"page_ids": [19, 20, 21, 22],
|
||||
"user": "jpmschweitzer",
|
||||
"user": "<tenant>",
|
||||
"max_concurrent": 3
|
||||
}'
|
||||
```
|
||||
@@ -123,8 +123,8 @@ async def ingest_batch(
|
||||
|
||||
@router.post("/all", response_model=BatchIngestionResult)
|
||||
async def ingest_all_pages(
|
||||
user: str = Query(default="jpmschweitzer", description="User identifier"),
|
||||
path_prefix: Optional[str] = Query(None, description="Path prefix filter (e.g., 'users/jpmschweitzer/tech')"),
|
||||
user: RequiredUserQuery,
|
||||
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"),
|
||||
max_concurrent: int = Query(3, ge=1, le=10, description="Maximum concurrent ingestion tasks"),
|
||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
||||
@@ -151,11 +151,11 @@ async def ingest_all_pages(
|
||||
|
||||
```bash
|
||||
# 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"
|
||||
|
||||
# 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"
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -22,7 +22,6 @@ from src.core.dependencies import (
|
||||
QdrantDep, OllamaDep, PaperlessDep, verify_api_key
|
||||
)
|
||||
from src.config import get_settings
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 ==========
|
||||
|
||||
@router.post("/cleanup/vectors", response_model=VectorCleanupResponse)
|
||||
@@ -652,12 +659,14 @@ async def cleanup_test_data(
|
||||
page_path = page["path"]
|
||||
|
||||
try:
|
||||
# Delete vector chunks for this page (using DEFAULT_USER collection)
|
||||
chunks_removed = await vector_service.delete_page_chunks(page_id, DEFAULT_USER)
|
||||
# Delete vector chunks/graph node scoped to the tenant that
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# Delete wiki page (raises exception on failure, returns None on success)
|
||||
|
||||
+18
-26
@@ -25,10 +25,9 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer",
|
||||
example="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True,
|
||||
example="llm_tester"
|
||||
),
|
||||
ToolParameter(
|
||||
name="tag",
|
||||
@@ -66,9 +65,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier for access control",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant) for access control. Required",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Complete page object with content",
|
||||
@@ -119,9 +117,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Created page object",
|
||||
@@ -130,7 +127,7 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
"path": "/projects/my-project",
|
||||
"content": "# My Project\n\nProject description here.",
|
||||
"tags": ["projects"],
|
||||
"user": "jpmschweitzer"
|
||||
"user": "<tenant>"
|
||||
},
|
||||
fast=True
|
||||
),
|
||||
@@ -175,9 +172,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Updated page object",
|
||||
@@ -200,9 +196,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Success confirmation",
|
||||
@@ -225,9 +220,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
ToolParameter(
|
||||
name="limit",
|
||||
@@ -250,9 +244,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="List of dossiers with page counts",
|
||||
@@ -275,9 +268,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
ToolParameter(
|
||||
name="limit",
|
||||
|
||||
@@ -18,8 +18,9 @@ from src.services.vector_service import VectorService
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.core.dependencies import QdrantDep, WikiJSDep, OllamaDep, verify_api_key
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.core.dependencies import (
|
||||
QdrantDep, WikiJSDep, OllamaDep, verify_api_key, RequiredUserQuery
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,7 +53,7 @@ async def semantic_search(
|
||||
```json
|
||||
{
|
||||
"query": "how to configure docker",
|
||||
"user": "jpmschweitzer",
|
||||
"user": "<tenant>",
|
||||
"limit": 10,
|
||||
"score_threshold": 0.5
|
||||
}
|
||||
@@ -77,7 +78,7 @@ async def semantic_search(
|
||||
@router.post("/update-from-page/{page_id}", response_model=VectorUpdateSummary)
|
||||
async def update_vectors_from_page(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
force_refresh: bool = Query(default=False, description="Force re-embedding"),
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
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 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
|
||||
"""
|
||||
@@ -125,7 +126,7 @@ async def update_vectors_from_page(
|
||||
@router.delete("/pages/{page_id}", response_model=DeletePageChunksResponse)
|
||||
async def delete_page_chunks(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
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.
|
||||
|
||||
**Example:** `DELETE /vector/pages/5?user=jpmschweitzer`
|
||||
**Example:** `DELETE /vector/pages/5?user=<tenant> (user is REQUIRED)`
|
||||
"""
|
||||
try:
|
||||
deleted_count = await vector_service.delete_page_chunks(
|
||||
|
||||
+24
-24
@@ -28,7 +28,7 @@ from src.core.dependencies import (
|
||||
get_news_provider,
|
||||
get_alphavantage_provider,
|
||||
)
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.core.dependencies import RequiredUserQuery
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,7 +48,7 @@ def get_volatile_service(qdrant: QdrantDep, ollama: OllamaDep) -> VolatileCacheS
|
||||
|
||||
@router.get("/stats", response_model=VolatileStatsResponse)
|
||||
async def get_stats(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -72,7 +72,7 @@ async def get_stats(
|
||||
|
||||
@router.get("/scheduled", response_model=VolatileScheduledResponse)
|
||||
async def get_scheduled(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -134,8 +134,8 @@ def _get_namespace_description(ns: VolatileNamespace) -> str:
|
||||
|
||||
@router.get("/search")
|
||||
async def search_volatile(
|
||||
user: RequiredUserQuery,
|
||||
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"),
|
||||
threshold: float = Query(default=0.75, ge=0.5, le=1.0, description="Minimum similarity score"),
|
||||
qdrant: QdrantDep = None,
|
||||
@@ -166,10 +166,10 @@ async def search_volatile(
|
||||
|
||||
@router.post("/store", response_model=VolatileRecordResponse)
|
||||
async def store_volatile(
|
||||
user: RequiredUserQuery,
|
||||
namespace: str = Query(..., description="Data namespace (weather, news, etc.)"),
|
||||
key: str = Query(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')"),
|
||||
request: VolatileRecordCreate = None,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -232,7 +232,7 @@ async def store_volatile(
|
||||
@router.post("/fetch/weather/{city}")
|
||||
async def fetch_weather(
|
||||
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)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
@@ -246,7 +246,7 @@ async def fetch_weather(
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/weather/amsterdam?user=jpmschweitzer
|
||||
POST /volatile/fetch/weather/amsterdam?user=<tenant>
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
@@ -273,7 +273,7 @@ async def fetch_weather(
|
||||
@router.post("/fetch/forecast/{city}")
|
||||
async def fetch_forecast(
|
||||
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)"),
|
||||
ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds (default 24 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
@@ -288,7 +288,7 @@ async def fetch_forecast(
|
||||
|
||||
**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)
|
||||
@@ -314,8 +314,8 @@ async def fetch_forecast(
|
||||
|
||||
@router.post("/fetch/news/{category}")
|
||||
async def fetch_news(
|
||||
user: RequiredUserQuery,
|
||||
category: str = "general",
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
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"),
|
||||
qdrant: QdrantDep = None,
|
||||
@@ -330,7 +330,7 @@ async def fetch_news(
|
||||
|
||||
**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)
|
||||
@@ -359,7 +359,7 @@ async def fetch_news(
|
||||
@router.post("/fetch/stock/{symbol}")
|
||||
async def fetch_stock(
|
||||
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)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
@@ -372,7 +372,7 @@ async def fetch_stock(
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/stock/AAPL?user=jpmschweitzer
|
||||
POST /volatile/fetch/stock/AAPL?user=<tenant>
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
@@ -407,8 +407,8 @@ async def fetch_stock(
|
||||
@router.post("/fetch/crypto/{symbol}")
|
||||
async def fetch_crypto(
|
||||
symbol: str,
|
||||
user: RequiredUserQuery,
|
||||
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)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
@@ -421,7 +421,7 @@ async def fetch_crypto(
|
||||
|
||||
**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)
|
||||
@@ -456,7 +456,7 @@ async def fetch_crypto(
|
||||
@router.post("/fetch/sun/{city}")
|
||||
async def fetch_sun_times(
|
||||
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)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
@@ -469,7 +469,7 @@ async def fetch_sun_times(
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/sun/rotterdam?user=jpmschweitzer
|
||||
POST /volatile/fetch/sun/rotterdam?user=<tenant>
|
||||
```
|
||||
|
||||
**Response data includes:**
|
||||
@@ -502,7 +502,7 @@ async def fetch_sun_times(
|
||||
@router.post("/fetch/air_quality/{city}")
|
||||
async def fetch_air_quality(
|
||||
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)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
@@ -515,7 +515,7 @@ async def fetch_air_quality(
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/air_quality/rotterdam?user=jpmschweitzer
|
||||
POST /volatile/fetch/air_quality/rotterdam?user=<tenant>
|
||||
```
|
||||
|
||||
**Response data includes:**
|
||||
@@ -548,7 +548,7 @@ async def fetch_air_quality(
|
||||
@router.post("/fetch/environment/{city}")
|
||||
async def fetch_environment(
|
||||
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)"),
|
||||
air_quality_ttl: int = Query(default=7200, ge=60, le=86400, description="Air quality TTL in seconds (default 2 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
@@ -564,7 +564,7 @@ async def fetch_environment(
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/environment/rotterdam?user=jpmschweitzer
|
||||
POST /volatile/fetch/environment/rotterdam?user=<tenant>
|
||||
```
|
||||
|
||||
**Response includes:**
|
||||
@@ -607,7 +607,7 @@ async def fetch_environment(
|
||||
async def get_record(
|
||||
namespace: str,
|
||||
key: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -617,7 +617,7 @@ async def get_record(
|
||||
|
||||
**Example:**
|
||||
```
|
||||
GET /volatile/weather/rotterdam?user=jpmschweitzer
|
||||
GET /volatile/weather/rotterdam?user=<tenant>
|
||||
```
|
||||
"""
|
||||
service = get_volatile_service(qdrant, ollama)
|
||||
@@ -636,7 +636,7 @@ async def get_record(
|
||||
async def delete_record(
|
||||
namespace: str,
|
||||
key: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
|
||||
+15
-13
@@ -24,9 +24,9 @@ from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.core.dependencies import (
|
||||
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.wiki_page_writer import WikiPageWriter
|
||||
from src.services.entity_linking_utils import apply_bidirectional_entity_linking
|
||||
@@ -62,7 +62,7 @@ def get_vector_service(
|
||||
# Page operations
|
||||
@router.get("/pages", response_model=WikiPageList)
|
||||
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)"),
|
||||
limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"),
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
@@ -87,7 +87,7 @@ async def list_pages(
|
||||
@router.get("/pages/{page_id}", response_model=WikiPage)
|
||||
async def get_page(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -139,14 +139,16 @@ async def create_page(
|
||||
"content": "# Architecture\\n\\nThis describes...",
|
||||
"description": "Architecture documentation",
|
||||
"tags": ["projects", "architecture"],
|
||||
"user": "jpmschweitzer"
|
||||
"user": "<tenant>"
|
||||
}
|
||||
```
|
||||
|
||||
The `user` field is REQUIRED (no default tenant).
|
||||
"""
|
||||
try:
|
||||
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)
|
||||
background_tasks.add_task(
|
||||
@@ -214,7 +216,7 @@ async def smart_create_page(
|
||||
- Entity linking statistics (forward/backward links)
|
||||
"""
|
||||
try:
|
||||
user = request.user or DEFAULT_USER
|
||||
user = request.user
|
||||
|
||||
# Build services
|
||||
wiki_service = WikiService(wiki_client)
|
||||
@@ -294,7 +296,7 @@ async def update_page(
|
||||
page_id: int,
|
||||
page_data: WikiPageUpdate,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
@@ -353,7 +355,7 @@ async def update_page(
|
||||
async def delete_page(
|
||||
page_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
@@ -402,7 +404,7 @@ async def delete_page(
|
||||
async def move_page(
|
||||
page_id: int,
|
||||
move_data: WikiPageMove,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -445,8 +447,8 @@ async def move_page(
|
||||
# Search operations
|
||||
@router.get("/search", response_model=WikiSearchResponse)
|
||||
async def search_pages(
|
||||
user: RequiredUserQuery,
|
||||
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"),
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -487,7 +489,7 @@ async def search_pages(
|
||||
# Dossier operations
|
||||
@router.get("/dossiers", response_model=DossierList)
|
||||
async def list_dossiers(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -510,7 +512,7 @@ async def list_dossiers(
|
||||
@router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList)
|
||||
async def get_dossier_pages(
|
||||
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"),
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
|
||||
@@ -398,7 +398,7 @@ class ConsolidationService:
|
||||
query: str,
|
||||
web_results: List[Dict[str, Any]],
|
||||
keywords: List[str],
|
||||
user: str = "jpmschweitzer"
|
||||
user: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Analyze web results with Ollama for novel information.
|
||||
@@ -977,7 +977,7 @@ JSON:"""
|
||||
query: str,
|
||||
web_results: List[Dict[str, Any]],
|
||||
keywords: List[str],
|
||||
user: str = "jpmschweitzer"
|
||||
user: str
|
||||
) -> MemoryRoutingResult:
|
||||
"""
|
||||
Unified classification of web results for memory routing.
|
||||
|
||||
@@ -103,8 +103,17 @@ class WikiChangeListener:
|
||||
}
|
||||
event = event_map.get(operation, 'page.update')
|
||||
|
||||
# Extract user from email
|
||||
user = user_email.split('@')[0] if '@' in user_email else 'jpmschweitzer'
|
||||
# Extract user from email. There is NO default tenant: if no user
|
||||
# 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
|
||||
await self._process_page_change(
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
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 (
|
||||
WikiPage, WikiPageSummary, WikiPageList,
|
||||
WikiPageCreate, WikiPageUpdate,
|
||||
@@ -184,7 +184,7 @@ class WikiService:
|
||||
Raises:
|
||||
ValueError: If creation fails
|
||||
"""
|
||||
user = page_data.user or DEFAULT_USER
|
||||
user = page_data.user
|
||||
|
||||
# Ensure path is in user's namespace
|
||||
full_path = self._ensure_user_path(page_data.path, user)
|
||||
|
||||
@@ -7,8 +7,8 @@ from src.core.multi_tenancy import (
|
||||
get_wikijs_namespace,
|
||||
get_neo4j_user_label,
|
||||
validate_user_id,
|
||||
validate_required_user,
|
||||
is_path_in_user_namespace,
|
||||
DEFAULT_USER
|
||||
)
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ class TestQdrantCollectionName:
|
||||
def test_email_user(self):
|
||||
assert get_qdrant_collection_name("john@example.com") == "library_desk_john_at_example_com"
|
||||
|
||||
def test_default_user(self):
|
||||
assert get_qdrant_collection_name(DEFAULT_USER) == f"library_desk_{DEFAULT_USER}"
|
||||
def test_test_tenant(self):
|
||||
assert get_qdrant_collection_name("llm_tester") == "library_desk_llm_tester"
|
||||
|
||||
|
||||
class TestWikijsNamespace:
|
||||
@@ -99,6 +99,41 @@ class TestValidateUserId:
|
||||
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:
|
||||
"""Test path namespace checking."""
|
||||
|
||||
|
||||
@@ -37,12 +37,19 @@ class TestRAGSearchModels:
|
||||
"""Tests for RAG search Pydantic models."""
|
||||
|
||||
def test_search_request_defaults(self):
|
||||
"""Test RAGSearchRequest with default values."""
|
||||
request = RAGSearchRequest(query="test query")
|
||||
"""Test RAGSearchRequest defaults (user is required, no default tenant)."""
|
||||
request = RAGSearchRequest(query="test query", user="llm_tester")
|
||||
|
||||
assert request.query == "test query"
|
||||
assert request.search_type == SearchType.WEB
|
||||
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):
|
||||
"""Test RAGSearchRequest with custom values."""
|
||||
|
||||
@@ -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
@@ -26,15 +26,20 @@ class TestWikiSmartCreateRequest:
|
||||
"""Tests for WikiSmartCreateRequest model validation."""
|
||||
|
||||
def test_minimal_request(self):
|
||||
"""Test request with only required field."""
|
||||
request = WikiSmartCreateRequest(topic="Docker containers")
|
||||
"""Test request with only required fields (topic AND user)."""
|
||||
request = WikiSmartCreateRequest(topic="Docker containers", user="llm_tester")
|
||||
assert request.topic == "Docker containers"
|
||||
assert request.path is None
|
||||
assert request.tags == []
|
||||
assert request.user is None
|
||||
assert request.user == "llm_tester"
|
||||
assert request.include_web_research 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):
|
||||
"""Test request with all fields."""
|
||||
request = WikiSmartCreateRequest(
|
||||
@@ -68,7 +73,8 @@ class TestWikiSmartCreateRequest:
|
||||
"""Test that path without leading slash gets one added."""
|
||||
request = WikiSmartCreateRequest(
|
||||
topic="Test",
|
||||
path="technology/test"
|
||||
path="technology/test",
|
||||
user="llm_tester"
|
||||
)
|
||||
assert request.path == "/technology/test"
|
||||
|
||||
@@ -76,7 +82,8 @@ class TestWikiSmartCreateRequest:
|
||||
"""Test that trailing slash is removed."""
|
||||
request = WikiSmartCreateRequest(
|
||||
topic="Test",
|
||||
path="/technology/test/"
|
||||
path="/technology/test/",
|
||||
user="llm_tester"
|
||||
)
|
||||
assert request.path == "/technology/test"
|
||||
|
||||
@@ -84,7 +91,8 @@ class TestWikiSmartCreateRequest:
|
||||
"""Test that duplicate tags are removed."""
|
||||
request = WikiSmartCreateRequest(
|
||||
topic="Test",
|
||||
tags=["devops", "devops", "containers", "devops"]
|
||||
tags=["devops", "devops", "containers", "devops"],
|
||||
user="llm_tester"
|
||||
)
|
||||
assert len(request.tags) == 2
|
||||
assert "devops" in request.tags
|
||||
@@ -94,7 +102,8 @@ class TestWikiSmartCreateRequest:
|
||||
"""Test that tag whitespace is cleaned."""
|
||||
request = WikiSmartCreateRequest(
|
||||
topic="Test",
|
||||
tags=[" devops ", "containers", " ", ""]
|
||||
tags=[" devops ", "containers", " ", ""],
|
||||
user="llm_tester"
|
||||
)
|
||||
assert "devops" in request.tags
|
||||
assert "containers" in request.tags
|
||||
@@ -536,7 +545,8 @@ class TestSmartCreateEndpoint:
|
||||
# For now, we test the model validation
|
||||
request = WikiSmartCreateRequest(
|
||||
topic="Test Topic",
|
||||
tags=["test"]
|
||||
tags=["test"],
|
||||
user="llm_tester"
|
||||
)
|
||||
assert request.topic == "Test Topic"
|
||||
|
||||
@@ -546,8 +556,8 @@ class TestSmartCreateEndpoint:
|
||||
WikiSmartCreateRequest(topic="")
|
||||
|
||||
def test_request_accepts_minimal_input(self):
|
||||
"""Test that only topic is required."""
|
||||
request = WikiSmartCreateRequest(topic="Minimal test")
|
||||
"""Test that topic and user are the only required fields."""
|
||||
request = WikiSmartCreateRequest(topic="Minimal test", user="llm_tester")
|
||||
assert request.topic == "Minimal test"
|
||||
assert request.include_web_research is True # default
|
||||
assert request.include_wiki_search is True # default
|
||||
|
||||
@@ -231,9 +231,8 @@ class TestWikiChangeListener:
|
||||
'UPDATE:123:invaliduser'
|
||||
)
|
||||
|
||||
# Should use default user
|
||||
call_args = mock_process.call_args[1]
|
||||
assert call_args['user'] == 'jpmschweitzer'
|
||||
# No default tenant: change must be skipped entirely
|
||||
mock_process.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_page_delete_calls_cleanup(self, listener):
|
||||
|
||||
Reference in New Issue
Block a user