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]
|
## [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.
|
||||||
|
|||||||
@@ -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 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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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."""
|
"""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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user