RAG/api/main.py

731 lines
28 KiB
Python

"""
FastAPI main application
"""
import warnings
# Suppress pkg_resources deprecation warning from debugpy extension
# This warning is harmless and comes from VS Code/Cursor debugpy extension
# It appears when debugpy is loaded, which happens during debugging
warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*", category=UserWarning)
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import StreamingResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
from pydantic import BaseModel, Field
from typing import Optional, List
from loguru import logger
from config import settings
from rag import VectorStoreManager, RAGEngine, FileParser, DocumentProcessor
from sync_service import SyncService
# Global instances
vector_store_manager: Optional[VectorStoreManager] = None
rag_engine: Optional[RAGEngine] = None
sync_service: Optional[SyncService] = None
file_parser: Optional[FileParser] = None
document_processor: Optional[DocumentProcessor] = None
auto_sync_task = None # Keep reference to prevent garbage collection
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events"""
global vector_store_manager, rag_engine, sync_service, file_parser, document_processor, auto_sync_task
import asyncio
# Startup
try:
logger.info("Initializing RAG services...")
# Initialize vector store
try:
vector_store_manager = VectorStoreManager()
logger.info("✓ VectorStoreManager initialized")
except Exception as e:
logger.error(f"Failed to initialize VectorStoreManager: {e}")
raise
# Initialize RAG engine
try:
rag_engine = RAGEngine(vector_store_manager)
logger.info("✓ RAGEngine initialized")
except Exception as e:
logger.error(f"Failed to initialize RAGEngine: {e}")
raise
# Initialize file parser
try:
file_parser = FileParser()
logger.info("✓ FileParser initialized")
except Exception as e:
logger.error(f"Failed to initialize FileParser: {e}")
raise
# Initialize document processor
try:
document_processor = DocumentProcessor()
logger.info("✓ DocumentProcessor initialized")
except Exception as e:
logger.error(f"Failed to initialize DocumentProcessor: {e}")
raise
logger.info("✓ Core RAG services initialized")
# Initialize sync service in background (non-blocking)
# This allows API to start immediately even if MySQL connection fails
async def init_sync_service():
"""Initialize sync service and start sync in background"""
global sync_service
try:
logger.info("Initializing MySQL sync service in background...")
# Run SyncService initialization in thread pool to avoid blocking event loop
# SyncService.__init__() contains synchronous MySQL connection checks
loop = asyncio.get_event_loop()
sync_service = await loop.run_in_executor(None, SyncService)
# Start initial sync in background and wait for it to complete before starting auto sync
# This ensures API starts immediately without blocking, but auto sync waits for initial sync
async def run_sync():
"""Run sync_all in background without blocking"""
try:
logger.info("Starting initial data sync in background...")
await sync_service.sync_all()
logger.info("✓ Initial sync completed")
return True
except Exception as e:
logger.error(f"Error during initial sync: {e}")
return False
# Create task for initial sync (don't await - allows API to start immediately)
initial_sync_task = asyncio.create_task(run_sync())
# Start auto sync if enabled
# Auto sync service will wait for initial sync to complete before starting incremental syncs
if settings.AUTO_SYNC:
logger.info(f"Starting auto sync service (interval: {settings.SYNC_INTERVAL}s)...")
# Start auto sync in background - it will wait for initial sync to complete
async def start_auto_sync_after_init():
"""Wait for initial sync to complete, then start auto sync with error recovery"""
try:
# Wait for initial sync to complete (no timeout - wait indefinitely)
logger.info("Auto sync service waiting for initial sync to complete (no timeout, will wait until completion)...")
try:
initial_sync_success = await initial_sync_task
if initial_sync_success:
logger.info("✓ Initial sync completed successfully")
else:
logger.warning("Initial sync failed, but starting auto sync service anyway...")
except Exception as sync_error:
logger.error(f"Initial sync encountered an error: {sync_error}", exc_info=True)
logger.warning("Starting auto sync service despite initial sync error...")
# Start auto sync service with error recovery (it will skip initial sync since we already did it)
await sync_service.start_auto_sync_with_recovery(skip_initial_sync=True)
except Exception as e:
logger.error(f"Error starting auto sync service: {e}", exc_info=True)
# Start auto sync service in background (don't await - allows API to start immediately)
asyncio.create_task(start_auto_sync_after_init())
else:
# If auto sync is disabled, just run initial sync in background
# Don't await it to avoid blocking
pass
except Exception as e:
logger.error(f"Failed to initialize sync service: {e}")
logger.warning("API is still available, but MySQL sync is disabled. Please check MySQL connection.")
# Don't raise - allow API to continue running
# Start sync service initialization in background (non-blocking)
# This ensures API can respond immediately while sync runs in background
sync_init_task = asyncio.create_task(init_sync_service())
auto_sync_task = sync_init_task # Store reference for shutdown
logger.info("✓ RAG API is now available (sync running in background)")
except Exception as e:
logger.error(f"Failed to initialize core services: {e}")
raise
yield
# Shutdown
if auto_sync_task and not auto_sync_task.done():
logger.info("Stopping sync services...")
# Wait a bit for the task to finish gracefully
try:
await asyncio.wait_for(auto_sync_task, timeout=10.0)
except asyncio.TimeoutError:
logger.warning("Sync task did not stop in time, cancelling...")
auto_sync_task.cancel()
try:
await auto_sync_task
except asyncio.CancelledError:
pass
if sync_service:
if hasattr(sync_service, 'stop_auto_sync'):
sync_service.stop_auto_sync()
if hasattr(sync_service, 'close'):
sync_service.close()
logger.info("RAG services shut down")
# Check if static assets are available for offline use
STATIC_DIR = Path(__file__).parent.parent / "static" / "swagger-ui"
SWAGGER_UI_BUNDLE = STATIC_DIR / "swagger-ui-bundle.js"
SWAGGER_UI_CSS = STATIC_DIR / "swagger-ui.css"
REDOC_BUNDLE = STATIC_DIR / "redoc.standalone.js"
# Determine if we should use local assets or CDN
USE_LOCAL_ASSETS = (
SWAGGER_UI_BUNDLE.exists() and
SWAGGER_UI_CSS.exists() and
REDOC_BUNDLE.exists()
)
if USE_LOCAL_ASSETS:
logger.info("Using local Swagger UI assets for offline mode")
else:
logger.warning(
"Local Swagger UI assets not found. Swagger UI will use CDN resources. "
"For offline use, run: python download_swagger_assets.py"
)
# Initialize FastAPI app
app = FastAPI(
title=settings.API_TITLE,
version=settings.API_VERSION,
description="RAG API for local knowledge base retrieval and generation",
lifespan=lifespan,
swagger_ui_parameters={
"persistAuthorization": True,
},
)
# Mount static files directory if it exists
if STATIC_DIR.exists():
app.mount("/static/swagger-ui", StaticFiles(directory=str(STATIC_DIR)), name="swagger-ui-static")
# Add favicon route to prevent 404 errors
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
"""Return empty favicon to prevent 404 errors"""
# Return a minimal 1x1 transparent PNG
# This prevents browser from requesting favicon and getting 404
return Response(
content=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82',
media_type="image/png"
)
# Override Swagger UI to use local assets when available
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
"""Custom Swagger UI that uses local assets in offline mode"""
if USE_LOCAL_ASSETS:
# Use local static files
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css",
swagger_favicon_url="/favicon.ico",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
init_oauth=app.swagger_ui_init_oauth,
swagger_ui_parameters=app.swagger_ui_parameters,
)
else:
# Fallback to default (CDN)
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
swagger_favicon_url="/favicon.ico",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
init_oauth=app.swagger_ui_init_oauth,
swagger_ui_parameters=app.swagger_ui_parameters,
)
# Override ReDoc to use local assets when available
@app.get("/redoc", include_in_schema=False)
async def custom_redoc_html():
"""Custom ReDoc that uses local assets in offline mode"""
if USE_LOCAL_ASSETS:
# Use local static files
return get_redoc_html(
openapi_url=app.openapi_url,
title=app.title + " - ReDoc",
redoc_js_url="/static/swagger-ui/redoc.standalone.js",
redoc_favicon_url="/favicon.ico",
with_google_fonts=False, # Disable Google Fonts for offline use
)
else:
# Fallback to default (CDN)
return get_redoc_html(
openapi_url=app.openapi_url,
title=app.title + " - ReDoc",
redoc_favicon_url="/favicon.ico",
with_google_fonts=True,
)
# Request/Response models
class QueryRequest(BaseModel):
"""Query request model"""
query: str = Field(..., description="User query string", min_length=1)
top_k: Optional[int] = Field(None, description="Number of documents to retrieve", ge=1, le=20)
stream: bool = Field(True, description="Whether to stream the response")
class RetrieveRequest(BaseModel):
"""Retrieve request model - only retrieves documents from ChromaDB, no LLM generation"""
query: str = Field(..., description="Query string for retrieval", min_length=1)
top_k: Optional[int] = Field(None, description="Number of documents to retrieve", ge=1, le=20)
class RetrievedDocument(BaseModel):
"""Retrieved document model"""
content: str = Field(..., description="Document content/text")
score: Optional[float] = Field(None, description="Similarity score")
metadata: dict = Field(default_factory=dict, description="Document metadata")
doc_id: Optional[str] = Field(None, description="Document ID")
class RetrieveResponse(BaseModel):
"""Retrieve response model"""
query: str = Field(..., description="Original query")
documents: list[RetrievedDocument] = Field(..., description="Retrieved documents")
count: int = Field(..., description="Number of documents retrieved")
class SyncRequest(BaseModel):
"""Manual sync request model"""
full_sync: bool = Field(False, description="Whether to perform full sync")
force: bool = Field(False, description="Whether to force re-processing of all documents (even if they exist)")
class HealthResponse(BaseModel):
"""Health check response"""
status: str
message: str
class DocumentChunk(BaseModel):
"""Document chunk model"""
id: str = Field(..., description="Chunk ID")
text: str = Field(..., description="Chunk content")
metadata: dict = Field(default_factory=dict, description="Chunk metadata")
chunk_index: Optional[int] = Field(None, description="Chunk index (if document was chunked)")
class DocumentResponse(BaseModel):
"""Document response model"""
doc_id: str = Field(..., description="Document ID")
chunks: List[DocumentChunk] = Field(..., description="Document chunks")
total_chunks: int = Field(..., description="Total number of chunks")
full_text: str = Field(..., description="Full document text (all chunks combined)")
class UploadResponse(BaseModel):
"""File upload response model"""
doc_id: str = Field(..., description="Document ID")
filename: str = Field(..., description="Original filename")
file_type: str = Field(..., description="File type/extension")
chunks: int = Field(..., description="Number of chunks created")
message: str = Field(..., description="Upload status message")
# API endpoints
@app.get("/", response_model=HealthResponse)
async def root():
"""Root endpoint"""
return HealthResponse(
status="ok",
message="RAG API is running"
)
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint"""
try:
if vector_store_manager is None or rag_engine is None:
raise HTTPException(status_code=503, detail="Services not initialized")
return HealthResponse(
status="healthy",
message="All services are running"
)
except Exception as e:
raise HTTPException(status_code=503, detail=f"Service unhealthy: {str(e)}")
@app.post("/query")
async def query(request: QueryRequest):
"""
Query the RAG system (retrieves documents and generates LLM response)
Args:
request: Query request with query string and options
Returns:
Streaming response or complete response
"""
if rag_engine is None:
raise HTTPException(status_code=503, detail="RAG engine not initialized")
try:
if request.stream:
# Stream response
async def generate():
try:
async for chunk in rag_engine.query_stream(request.query, request.top_k):
yield chunk
except Exception as e:
logger.error(f"Error in stream generation: {e}")
yield f"\n\nError: {str(e)}"
return StreamingResponse(
generate(),
media_type="text/plain",
headers={
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache"
}
)
else:
# Return complete response (run in thread pool for better concurrency)
import asyncio
response = await asyncio.to_thread(rag_engine.query, request.query, request.top_k)
return {"response": response, "query": request.query}
except Exception as e:
logger.error(f"Error processing query: {e}")
raise HTTPException(status_code=500, detail=f"Error processing query: {str(e)}")
@app.post("/retrieve", response_model=RetrieveResponse)
async def retrieve(request: RetrieveRequest):
"""
Retrieve documents from ChromaDB based on query (no LLM generation)
This endpoint only performs vector similarity search and returns the retrieved
documents with their content, metadata, and similarity scores.
Args:
request: Retrieve request with query string and top_k option
Returns:
Retrieved documents with content, metadata, and scores
"""
if vector_store_manager is None:
raise HTTPException(status_code=503, detail="Vector store not initialized")
try:
import asyncio
# Get retriever (works even if collection is empty, will return empty results)
retriever = vector_store_manager.get_retriever(top_k=request.top_k or settings.TOP_K)
# Retrieve documents (run in thread pool since retriever.retrieve() is synchronous)
def retrieve_docs():
nodes = retriever.retrieve(request.query)
return nodes
nodes = await asyncio.to_thread(retrieve_docs)
# Convert nodes to response format
documents = []
for node in nodes:
# Extract similarity score if available
# LlamaIndex retriever returns NodeWithScore objects
score = None
if hasattr(node, 'score'):
score = node.score
elif hasattr(node, 'node') and hasattr(node.node, 'score'):
score = node.node.score
# Get the actual node object (NodeWithScore.node or the node itself)
actual_node = node.node if hasattr(node, 'node') else node
# Get content
content = actual_node.text if hasattr(actual_node, 'text') else str(actual_node)
# Get metadata
metadata = {}
if hasattr(actual_node, 'metadata'):
metadata = actual_node.metadata.copy() if actual_node.metadata else {}
# Get doc_id
doc_id = metadata.get('doc_id') or metadata.get('original_doc_id')
if not doc_id and hasattr(actual_node, 'node_id'):
doc_id = actual_node.node_id
documents.append(RetrievedDocument(
content=content,
score=score,
metadata=metadata,
doc_id=doc_id
))
return RetrieveResponse(
query=request.query,
documents=documents,
count=len(documents)
)
except Exception as e:
logger.error(f"Error retrieving documents: {e}")
raise HTTPException(status_code=500, detail=f"Error retrieving documents: {str(e)}")
@app.post("/sync")
async def manual_sync(request: SyncRequest):
"""
Manually trigger synchronization
Args:
request: Sync request with options
Returns:
Sync status
"""
if sync_service is None:
raise HTTPException(status_code=503, detail="Sync service not initialized")
try:
if request.full_sync:
await sync_service.sync_all(force=request.force)
message = "Full sync completed" + (" (forced re-processing)" if request.force else "")
return {"status": "success", "message": message}
else:
await sync_service.sync_incremental()
return {"status": "success", "message": "Incremental sync completed"}
except Exception as e:
logger.error(f"Error during sync: {e}")
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
@app.get("/stats")
async def get_stats():
"""
Get system statistics
Returns:
System statistics
"""
if vector_store_manager is None:
raise HTTPException(status_code=503, detail="Vector store not initialized")
try:
collection = vector_store_manager.collection
count = collection.count()
return {
"vector_store": {
"collection_name": settings.CHROMA_COLLECTION_NAME,
"document_count": count
},
"config": {
"model": settings.OLLAMA_MODEL,
"top_k": settings.TOP_K,
"chunk_size": settings.CHUNK_SIZE
}
}
except Exception as e:
logger.error(f"Error getting stats: {e}")
raise HTTPException(status_code=500, detail=f"Error getting stats: {str(e)}")
@app.post("/documents/upload", response_model=UploadResponse)
async def upload_document(
file: UploadFile = File(..., description="File to upload"),
doc_id: Optional[str] = Form(None, description="Custom document ID (optional, will use filename if not provided)"),
metadata: Optional[str] = Form(None, description="Additional metadata as JSON string (optional)")
):
"""
Upload and parse a document file
Supports various file formats:
- Text files: .txt, .md, .markdown
- PDF files: .pdf
- Word documents: .docx, .doc
- HTML files: .html, .htm
- CSV files: .csv
- JSON files: .json
File size limit: Configurable via MAX_UPLOAD_SIZE_MB in .env (default: 10MB)
Args:
file: File to upload (max size configured in MAX_UPLOAD_SIZE_MB)
doc_id: Optional custom document ID
metadata: Optional JSON string with additional metadata
Returns:
Upload response with document ID and chunk count
Raises:
HTTPException: 400 if file format is unsupported
HTTPException: 413 if file size exceeds 10MB
HTTPException: 400 if document parsing fails
"""
if file_parser is None or vector_store_manager is None or document_processor is None:
raise HTTPException(status_code=503, detail="Services not initialized")
try:
import json
import asyncio
# Check if file format is supported
if not file_parser.is_supported(file.filename):
raise HTTPException(
status_code=400,
detail=f"Unsupported file format. Supported formats: {', '.join(file_parser.SUPPORTED_EXTENSIONS)}"
)
# Read file content
content = await file.read()
# Check file size limit (from config, default: 10MB)
MAX_FILE_SIZE = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 # Convert MB to bytes
file_size = len(content)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"File size ({file_size / (1024 * 1024):.2f}MB) exceeds maximum allowed size ({settings.MAX_UPLOAD_SIZE_MB}MB)"
)
# Parse metadata if provided
extra_metadata = {}
if metadata:
try:
extra_metadata = json.loads(metadata)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid metadata JSON format")
# Use custom doc_id or generate from filename
if not doc_id:
doc_id = Path(file.filename).stem
# Parse file (run in thread pool to avoid blocking event loop for large files)
logger.info(f"Parsing uploaded file: {file.filename} (doc_id: {doc_id}, size: {file_size / 1024:.2f}KB)")
try:
import asyncio
loop = asyncio.get_event_loop()
documents = await loop.run_in_executor(
None,
file_parser.parse_file_content,
content,
file.filename,
doc_id,
extra_metadata
)
except Exception as parse_error:
logger.error(f"Error parsing file {file.filename}: {parse_error}", exc_info=True)
raise HTTPException(
status_code=400,
detail=f"Failed to parse document: {str(parse_error)}"
)
if not documents:
raise HTTPException(status_code=400, detail="Failed to parse document or document is empty")
logger.info(f"Successfully parsed {file.filename}: {len(documents)} document(s)")
# Chunk documents if needed
logger.info(f"Chunking {len(documents)} document(s)...")
chunked_documents = document_processor.chunk_documents(documents)
logger.info(f"Chunked into {len(chunked_documents)} chunk(s)")
# Add to vector store (this may take time for large documents due to embedding generation)
logger.info(f"Adding {len(chunked_documents)} chunks to vector store (this may take time for large files)...")
try:
# Run in thread pool to avoid blocking event loop during embedding generation
import asyncio
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
vector_store_manager.add_documents,
chunked_documents,
False # skip_existing
)
logger.info(f"Successfully added {len(chunked_documents)} chunks to vector store")
except Exception as add_error:
logger.error(f"Error adding documents to vector store: {add_error}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to add documents to vector store: {str(add_error)}"
)
file_type = Path(file.filename).suffix.lstrip('.')
return UploadResponse(
doc_id=doc_id,
filename=file.filename,
file_type=file_type,
chunks=len(chunked_documents),
message=f"Document uploaded and processed successfully"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error uploading document: {e}")
raise HTTPException(status_code=500, detail=f"Error uploading document: {str(e)}")
@app.get("/documents/{doc_id}", response_model=DocumentResponse)
async def get_document_by_id(doc_id: str):
"""
Get all content for a document by its ID
This endpoint retrieves all chunks/content that belong to a document,
even if the document was split into multiple chunks during processing.
Args:
doc_id: Document ID to retrieve
Returns:
Document response with all chunks and full text
"""
if vector_store_manager is None:
raise HTTPException(status_code=503, detail="Vector store not initialized")
try:
# Get all chunks for this document
chunks = vector_store_manager.get_document_by_id(doc_id)
if not chunks:
raise HTTPException(status_code=404, detail=f"Document with ID '{doc_id}' not found")
# Convert to response format
document_chunks = []
full_text_parts = []
for chunk in chunks:
document_chunks.append(DocumentChunk(
id=chunk['id'],
text=chunk['text'],
metadata=chunk.get('metadata', {}),
chunk_index=chunk.get('chunk_index')
))
full_text_parts.append(chunk['text'])
# Combine all chunks into full text
full_text = "\n\n".join(full_text_parts)
return DocumentResponse(
doc_id=doc_id,
chunks=document_chunks,
total_chunks=len(document_chunks),
full_text=full_text
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error retrieving document {doc_id}: {e}")
raise HTTPException(status_code=500, detail=f"Error retrieving document: {str(e)}")