fix: WS5 hazards batch - CORS, scheduler auth, reranker dedupe, wiring, write txns
- CORS: drop allow_credentials (wildcard origin + credentials told
browsers to attach credentials for any site); origins configurable via
CORS_ALLOW_ORIGINS (default * is safe without credentials). Verified
live: preflight no longer advertises access-control-allow-credentials.
- Scheduler tasks: auth moved from a plain Authorization header (which
the Scheduler's rest_api_executor does NOT env-substitute) to its
auth {type: bearer, token: ${LIBRARY_API_KEY}} block, substituted from
the Scheduler's own environment at execution time. The registrar no
longer resolves the real key client-side, so it can never be persisted
into the scheduled_tasks.config JSONB column. Also fixed: JSON bodies
moved from the ignored "body" key to "payload" (the executor only
reads config["payload"], so the tasks would have POSTed empty bodies
and failed required-user validation).
- Reranker: parsed ranking indices are deduplicated preserving first
occurrence (an LLM answer like "3,3,1" duplicated a result).
- HybridRAG wiring consolidated into dependencies.get_hybrid_rag_service
(now including volatile_service); the inline copies in /query/hybrid
and /wiki/pages/smart-create are gone - smart-create previously ran
without the volatile leg, and the singleton was unused.
- Remaining Neo4j writes (GraphService ingestion/deletes/purges/entity
mentions, webhook rename+delete cleanup, document-sync _index_graph,
consolidation mark-processed/add-entity) moved from auto-commit
execute_query to execute_write managed transactions with retry.
Verified end-to-end on the local dev server as llm_tester: /query/hybrid
200 with all five legs ok (volatile now active), background persistence
landed as one transaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
This commit is contained in:
@@ -95,6 +95,18 @@ class Settings(BaseSettings):
|
||||
app_version: str = Field(default=__version__, description="Application version")
|
||||
debug: bool = Field(default=False, description="Debug mode")
|
||||
|
||||
# CORS: comma-separated list of allowed browser origins. The default "*"
|
||||
# is only acceptable because allow_credentials is disabled (see main.py).
|
||||
cors_allow_origins: str = Field(
|
||||
default="*",
|
||||
description="Comma-separated CORS allowed origins (credentials are never allowed)"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_allow_origins_list(self) -> list[str]:
|
||||
"""cors_allow_origins parsed into a list for CORSMiddleware."""
|
||||
return [o.strip() for o in self.cors_allow_origins.split(",") if o.strip()]
|
||||
|
||||
# RAG Search Configuration
|
||||
search_cache_ttl: int = Field(default=300, ge=0, le=3600, description="Search cache TTL in seconds")
|
||||
search_timeout: int = Field(default=10, ge=1, le=60, description="SearXNG timeout in seconds")
|
||||
|
||||
@@ -718,7 +718,14 @@ def get_ingestion_service() -> "IngestionService":
|
||||
|
||||
@lru_cache
|
||||
def get_hybrid_rag_service() -> "HybridRAGService":
|
||||
"""Get HybridRAGService singleton."""
|
||||
"""
|
||||
Get HybridRAGService singleton.
|
||||
|
||||
The single wiring point for HybridRAG — routers must depend on this
|
||||
instead of constructing their own instance (previous inline copies in
|
||||
the /query/hybrid and /wiki/smart-create routers diverged on
|
||||
volatile_service).
|
||||
"""
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
return HybridRAGService(
|
||||
vector_service=get_vector_service(),
|
||||
@@ -726,7 +733,8 @@ def get_hybrid_rag_service() -> "HybridRAGService":
|
||||
searxng_client=get_searxng_client(),
|
||||
ollama_client=get_ollama_client(),
|
||||
content_extractor=get_content_extractor(),
|
||||
settings=get_settings()
|
||||
settings=get_settings(),
|
||||
volatile_service=get_volatile_cache_service()
|
||||
)
|
||||
|
||||
|
||||
|
||||
+10
-3
@@ -39,11 +39,18 @@ app = FastAPI(
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
# CORS middleware.
|
||||
# allow_credentials is deliberately False: combined with a wildcard origin it
|
||||
# would tell browsers to attach cookies/credentials for ANY site, which is the
|
||||
# classic CORS misconfiguration. All real callers (tatlock, the Scheduler) are
|
||||
# server-to-server and use the Authorization header, which wildcard-origin
|
||||
# CORS without credentials still permits. Origins can be restricted via the
|
||||
# CORS_ALLOW_ORIGINS env (comma-separated) once a cross-origin browser UI
|
||||
# exists; the bundled static UI is served same-origin and needs no CORS.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_origins=get_settings().cors_allow_origins_list,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -11,49 +11,14 @@ 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,
|
||||
RequiredUserQuery
|
||||
verify_api_key, get_hybrid_rag_service, RequiredUserQuery
|
||||
)
|
||||
from src.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/query", tags=["HybridRAG"])
|
||||
|
||||
|
||||
# Dependency to get HybridRAG service
|
||||
def get_hybrid_rag_service(
|
||||
neo4j_client: Neo4jDep,
|
||||
wiki_client: WikiJSDep,
|
||||
qdrant_client: QdrantDep,
|
||||
ollama_client: OllamaDep,
|
||||
searxng_client: SearXNGDep,
|
||||
content_extractor: ContentExtractorDep,
|
||||
settings: Settings = Depends(get_settings)
|
||||
) -> HybridRAGService:
|
||||
"""Get HybridRAG service instance with all dependencies."""
|
||||
from src.services.vector_service import VectorService
|
||||
from src.services.graph_service import GraphService
|
||||
from src.services.volatile_service import VolatileCacheService
|
||||
|
||||
# Create component services
|
||||
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
||||
graph_service = GraphService(neo4j_client, wiki_client)
|
||||
volatile_service = VolatileCacheService(qdrant_client, ollama_client, settings)
|
||||
|
||||
# Create HybridRAG service
|
||||
return HybridRAGService(
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
searxng_client=searxng_client,
|
||||
ollama_client=ollama_client,
|
||||
content_extractor=content_extractor,
|
||||
settings=settings,
|
||||
volatile_service=volatile_service
|
||||
)
|
||||
|
||||
|
||||
@router.post("/hybrid", response_model=HybridRAGResponse)
|
||||
async def hybrid_search(
|
||||
request: HybridRAGRequest,
|
||||
|
||||
@@ -320,7 +320,7 @@ async def process_page_rename(
|
||||
"""
|
||||
|
||||
try:
|
||||
await neo4j.execute_query(update_query, {
|
||||
await neo4j.execute_write(update_query, {
|
||||
"page_id": page_id,
|
||||
"new_path": new_path,
|
||||
"new_title": new_title
|
||||
@@ -441,7 +441,7 @@ async def cleanup_deleted_page(
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await neo4j.execute_query(delete_doc_query, {"page_id": page_id})
|
||||
result = await neo4j.execute_write(delete_doc_query, {"page_id": page_id})
|
||||
deleted_count = result[0]["deleted_count"] if result else 0
|
||||
logger.info(f"Deleted {deleted_count} Document node(s) for page {page_id}")
|
||||
except Exception as e:
|
||||
@@ -462,7 +462,7 @@ async def cleanup_deleted_page(
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await neo4j.execute_query(delete_entity_query, {"entity_id": entity_id})
|
||||
result = await neo4j.execute_write(delete_entity_query, {"entity_id": entity_id})
|
||||
deleted = result[0]["deleted_count"] if result else 0
|
||||
if deleted > 0:
|
||||
logger.info(f"Deleted orphaned entity: {entity_name}")
|
||||
@@ -478,7 +478,7 @@ async def cleanup_deleted_page(
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await neo4j.execute_query(cleanup_search_query, {})
|
||||
result = await neo4j.execute_write(cleanup_search_query, {})
|
||||
cleaned = result[0]["cleaned_count"] if result else 0
|
||||
if cleaned > 0:
|
||||
logger.info(f"Cleaned up {cleaned} broken SearchQuery relationships")
|
||||
|
||||
+5
-13
@@ -23,11 +23,10 @@ from src.clients.neo4j_client import Neo4jClient
|
||||
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,
|
||||
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep,
|
||||
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service,
|
||||
RequiredUserQuery
|
||||
)
|
||||
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
|
||||
from src.config import Settings
|
||||
@@ -180,8 +179,6 @@ async def smart_create_page(
|
||||
neo4j_client: Neo4jDep,
|
||||
qdrant_client: QdrantDep,
|
||||
ollama_client: OllamaDep,
|
||||
searxng_client: SearXNGDep,
|
||||
content_extractor: ContentExtractorDep,
|
||||
settings: Settings = Depends(get_settings),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -218,18 +215,13 @@ async def smart_create_page(
|
||||
try:
|
||||
user = request.user
|
||||
|
||||
# Build services
|
||||
# Build services. HybridRAG comes from the single wiring point in
|
||||
# dependencies so it includes volatile_service (a previous inline
|
||||
# copy here lacked it).
|
||||
wiki_service = WikiService(wiki_client)
|
||||
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
||||
graph_service = GraphService(neo4j_client, wiki_client)
|
||||
hybrid_rag_service = HybridRAGService(
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
searxng_client=searxng_client,
|
||||
ollama_client=ollama_client,
|
||||
content_extractor=content_extractor,
|
||||
settings=settings
|
||||
)
|
||||
hybrid_rag_service = get_hybrid_rag_service()
|
||||
wiki_page_writer = WikiPageWriter(ollama_client=ollama_client, settings=settings)
|
||||
|
||||
# Step 1-5: Research + Generate + Create page
|
||||
|
||||
@@ -613,7 +613,7 @@ JSON:"""
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.neo4j.execute_query(query, {"search_id": search_id})
|
||||
await self.neo4j.execute_write(query, {"search_id": search_id})
|
||||
logger.debug(f"Marked search {search_id} as processed")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark search as processed: {e}")
|
||||
@@ -1006,7 +1006,7 @@ JSON:"""
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.neo4j.execute_query(query, {
|
||||
await self.neo4j.execute_write(query, {
|
||||
"name": entity_name,
|
||||
"description": description,
|
||||
"search_id": source_search_id
|
||||
|
||||
@@ -281,7 +281,7 @@ class DocumentSyncService:
|
||||
d.updated_at = datetime()
|
||||
RETURN d
|
||||
"""
|
||||
await self.neo4j.execute_query(
|
||||
await self.neo4j.execute_write(
|
||||
query,
|
||||
{
|
||||
"paperless_id": document_id,
|
||||
|
||||
@@ -469,7 +469,7 @@ class GraphService:
|
||||
RETURN d
|
||||
"""
|
||||
|
||||
await self.neo4j.execute_query(doc_query, {
|
||||
await self.neo4j.execute_write(doc_query, {
|
||||
"page_id": page_id,
|
||||
"title": page.get("title"),
|
||||
"path": page.get("path"),
|
||||
@@ -493,7 +493,7 @@ class GraphService:
|
||||
RETURN e, r
|
||||
"""
|
||||
|
||||
result = await self.neo4j.execute_query(entity_query, {
|
||||
result = await self.neo4j.execute_write(entity_query, {
|
||||
"name": entity.text,
|
||||
"page_id": page_id,
|
||||
"confidence": entity.confidence
|
||||
@@ -557,7 +557,7 @@ class GraphService:
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_query(
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"page_id": page_id}
|
||||
)
|
||||
@@ -1353,7 +1353,7 @@ Feel free to expand it with more details!
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_query(
|
||||
results = await self.neo4j.execute_write(
|
||||
query,
|
||||
{"page_id": page_id, "entity_names": names}
|
||||
)
|
||||
@@ -1392,7 +1392,7 @@ Feel free to expand it with more details!
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_query(
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"document_id": document_id}
|
||||
)
|
||||
@@ -1434,7 +1434,7 @@ Feel free to expand it with more details!
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_query(
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"paperless_id": paperless_id}
|
||||
)
|
||||
@@ -1478,7 +1478,7 @@ Feel free to expand it with more details!
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_query(
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"collection_id": collection_id}
|
||||
)
|
||||
@@ -1568,7 +1568,7 @@ Feel free to expand it with more details!
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_query(query, {})
|
||||
results = await self.neo4j.execute_write(query, {})
|
||||
purged_count = results[0]["purged_count"] if results else 0
|
||||
|
||||
logger.info(f"Purged {purged_count} orphan entities for user {user}")
|
||||
@@ -1651,7 +1651,7 @@ Feel free to expand it with more details!
|
||||
DETACH DELETE d
|
||||
RETURN count(d) as purged_count
|
||||
"""
|
||||
results = await self.neo4j.execute_query(query, {"page_ids": page_ids})
|
||||
results = await self.neo4j.execute_write(query, {"page_ids": page_ids})
|
||||
count = results[0]["purged_count"] if results else 0
|
||||
total_purged += count
|
||||
logger.info(f"Purged {count} wiki Document nodes")
|
||||
@@ -1664,7 +1664,7 @@ Feel free to expand it with more details!
|
||||
DETACH DELETE d
|
||||
RETURN count(d) as purged_count
|
||||
"""
|
||||
results = await self.neo4j.execute_query(query, {"document_ids": document_ids})
|
||||
results = await self.neo4j.execute_write(query, {"document_ids": document_ids})
|
||||
count = results[0]["purged_count"] if results else 0
|
||||
total_purged += count
|
||||
logger.info(f"Purged {count} Document Store Document nodes")
|
||||
@@ -1702,7 +1702,7 @@ Feel free to expand it with more details!
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_query(query, {})
|
||||
results = await self.neo4j.execute_write(query, {})
|
||||
cleaned_count = results[0]["cleaned_count"] if results else 0
|
||||
|
||||
if cleaned_count > 0:
|
||||
|
||||
@@ -864,9 +864,15 @@ Ranking:"""
|
||||
timeout=LLM_CALL_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
# Parse response: "3,1,5,2,4" → [2, 0, 4, 1, 3] (0-indexed)
|
||||
# Parse response: "3,1,5,2,4" → [2, 0, 4, 1, 3] (0-indexed).
|
||||
# Deduplicated preserving first occurrence: an LLM answer like
|
||||
# "3,3,1" must not put the same result in the ranking twice.
|
||||
indices_str = response.strip().split('\n')[0] # Take first line
|
||||
indices = [int(x.strip()) - 1 for x in indices_str.split(",") if x.strip().isdigit()]
|
||||
indices = list(dict.fromkeys(
|
||||
int(x.strip()) - 1
|
||||
for x in indices_str.split(",")
|
||||
if x.strip().isdigit()
|
||||
))
|
||||
|
||||
# Reorder results according to LLM ranking
|
||||
reranked = []
|
||||
|
||||
Reference in New Issue
Block a user