from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, List, Dict
from PIL import Image
import io
import numpy as np
import os
from datetime import datetime
from pymongo import MongoClient
from huggingface_hub import InferenceClient
from embedding_service import JinaClipEmbeddingService
from qdrant_service import QdrantVectorService
from advanced_rag import AdvancedRAG
from pdf_parser import PDFIndexer
from multimodal_pdf_parser import MultimodalPDFIndexer
# Initialize FastAPI app
app = FastAPI(
title="Event Social Media Embeddings & ChatbotRAG API",
description="API để embeddings, search và ChatbotRAG với Jina CLIP v2 + Qdrant + MongoDB + LLM",
version="2.0.0"
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize services
print("Initializing services...")
embedding_service = JinaClipEmbeddingService(model_path="jinaai/jina-clip-v2")
collection_name = os.getenv("COLLECTION_NAME", "event_social_media")
qdrant_service = QdrantVectorService(
collection_name=collection_name,
vector_size=embedding_service.get_embedding_dimension()
)
print(f"✓ Qdrant collection: {collection_name}")
# MongoDB connection
mongodb_uri = os.getenv("MONGODB_URI", "mongodb+srv://truongtn7122003:7KaI9OT5KTUxWjVI@truongtn7122003.xogin4q.mongodb.net/")
mongo_client = MongoClient(mongodb_uri)
db = mongo_client[os.getenv("MONGODB_DB_NAME", "chatbot_rag")]
documents_collection = db["documents"]
chat_history_collection = db["chat_history"]
print("✓ MongoDB connected")
# Hugging Face token
hf_token = os.getenv("HUGGINGFACE_TOKEN")
if hf_token:
print("✓ Hugging Face token configured")
# Initialize Advanced RAG
advanced_rag = AdvancedRAG(
embedding_service=embedding_service,
qdrant_service=qdrant_service
)
print("✓ Advanced RAG pipeline initialized")
# Initialize PDF Indexer
pdf_indexer = PDFIndexer(
embedding_service=embedding_service,
qdrant_service=qdrant_service,
documents_collection=documents_collection
)
print("✓ PDF Indexer initialized")
# Initialize Multimodal PDF Indexer (for PDFs with images)
multimodal_pdf_indexer = MultimodalPDFIndexer(
embedding_service=embedding_service,
qdrant_service=qdrant_service,
documents_collection=documents_collection
)
print("✓ Multimodal PDF Indexer initialized")
print("✓ Services initialized successfully")
# Pydantic models for embeddings
class SearchRequest(BaseModel):
text: Optional[str] = None
limit: int = 10
score_threshold: Optional[float] = None
text_weight: float = 0.5
image_weight: float = 0.5
class SearchResponse(BaseModel):
id: str
confidence: float
metadata: dict
class IndexResponse(BaseModel):
success: bool
id: str
message: str
# Pydantic models for ChatbotRAG
class ChatRequest(BaseModel):
message: str
use_rag: bool = True
top_k: int = 3
system_message: Optional[str] = "You are a helpful AI assistant."
max_tokens: int = 512
temperature: float = 0.7
top_p: float = 0.95
hf_token: Optional[str] = None
# Advanced RAG options
use_advanced_rag: bool = True
use_query_expansion: bool = True
use_reranking: bool = True
use_compression: bool = True
score_threshold: float = 0.5
class ChatResponse(BaseModel):
response: str
context_used: List[Dict]
timestamp: str
rag_stats: Optional[Dict] = None # Stats from advanced RAG pipeline
class AddDocumentRequest(BaseModel):
text: str
metadata: Optional[Dict] = None
class AddDocumentResponse(BaseModel):
success: bool
doc_id: str
message: str
class UploadPDFResponse(BaseModel):
success: bool
document_id: str
filename: str
chunks_indexed: int
message: str
@app.get("/")
async def root():
"""Health check endpoint with comprehensive API documentation"""
return {
"status": "running",
"service": "ChatbotRAG API - Advanced RAG with Multimodal Support",
"version": "3.0.0",
"vector_db": "Qdrant",
"document_db": "MongoDB",
"features": {
"multiple_inputs": "Index up to 10 texts + 10 images per request",
"advanced_rag": "Query expansion, reranking, contextual compression",
"pdf_support": "Upload PDFs and chat about their content",
"multimodal_pdf": "PDFs with text and image URLs - perfect for user guides",
"chat_history": "Track conversation history",
"hybrid_search": "Text + image search with Jina CLIP v2"
},
"endpoints": {
"indexing": {
"POST /index": {
"description": "Index multiple texts and images (NEW: up to 10 each)",
"content_type": "multipart/form-data",
"body": {
"id": "string (required) - Document ID (primary)",
"texts": "List[string] (optional) - Up to 10 texts",
"images": "List[UploadFile] (optional) - Up to 10 images",
"id_use": "string (optional) - ID của SocialMedia hoặc EventCode",
"id_user": "string (optional) - ID của User"
},
"example": "curl -X POST '/index' -F 'id=doc1' -F 'id_use=social_123' -F 'id_user=user_789' -F 'texts=Text 1' -F 'images=@img1.jpg'",
"response": {
"success": True,
"id": "doc1",
"message": "Indexed successfully with 2 texts and 1 images"
},
"use_cases": {
"social_media_post": {
"id": "post_uuid_123",
"id_use": "social_media_456",
"id_user": "user_789",
"description": "Link post to social media account and user"
},
"event_post": {
"id": "post_uuid_789",
"id_use": "event_code_ABC123",
"id_user": "user_101",
"description": "Link post to event and user"
}
}
},
"POST /documents": {
"description": "Add text document to knowledge base",
"content_type": "application/json",
"body": {
"text": "string (required) - Document content",
"metadata": "object (optional) - Additional metadata"
},
"example": {
"text": "How to create event: Click 'Create Event' button...",
"metadata": {"category": "tutorial", "source": "user_guide"}
}
},
"POST /upload-pdf": {
"description": "Upload PDF file (text only)",
"content_type": "multipart/form-data",
"body": {
"file": "UploadFile (required) - PDF file",
"title": "string (optional) - Document title",
"category": "string (optional) - Category",
"description": "string (optional) - Description"
},
"example": "curl -X POST '/upload-pdf' -F 'file=@guide.pdf' -F 'title=User Guide'"
},
"POST /upload-pdf-multimodal": {
"description": "Upload PDF with text and image URLs (RECOMMENDED for user guides)",
"content_type": "multipart/form-data",
"features": [
"Extracts text from PDF",
"Detects image URLs (http://, https://)",
"Supports markdown: ",
"Supports HTML:
",
"Links images to text chunks",
"Returns images with context in chat"
],
"body": {
"file": "UploadFile (required) - PDF file with image URLs",
"title": "string (optional) - Document title",
"category": "string (optional) - e.g. 'user_guide', 'tutorial'",
"description": "string (optional)"
},
"example": "curl -X POST '/upload-pdf-multimodal' -F 'file=@guide_with_images.pdf' -F 'category=user_guide'",
"response": {
"success": True,
"document_id": "pdf_multimodal_20251029_150000",
"chunks_indexed": 25,
"message": "PDF indexed with 25 chunks and 15 images"
},
"use_case": "Perfect for user guides with screenshots, tutorials with diagrams"
}
},
"search": {
"POST /search": {
"description": "Hybrid search with text and/or image",
"body": {
"text": "string (optional) - Query text",
"image": "UploadFile (optional) - Query image",
"limit": "int (default: 10)",
"score_threshold": "float (optional, 0-1)",
"text_weight": "float (default: 0.5)",
"image_weight": "float (default: 0.5)"
}
},
"POST /search/text": {
"description": "Text-only search",
"body": {"text": "string", "limit": "int", "score_threshold": "float"}
},
"POST /search/image": {
"description": "Image-only search",
"body": {"image": "UploadFile", "limit": "int", "score_threshold": "float"}
},
"POST /rag/search": {
"description": "Search in RAG knowledge base",
"body": {"query": "string", "top_k": "int (default: 5)", "score_threshold": "float (default: 0.5)"}
}
},
"chat": {
"POST /chat": {
"description": "Chat với Advanced RAG (Query expansion + Reranking + Compression)",
"content_type": "application/json",
"body": {
"message": "string (required) - User question",
"use_rag": "bool (default: true) - Enable RAG retrieval",
"use_advanced_rag": "bool (default: true) - Use advanced RAG pipeline (RECOMMENDED)",
"use_query_expansion": "bool (default: true) - Expand query with variations",
"use_reranking": "bool (default: true) - Rerank results for accuracy",
"use_compression": "bool (default: true) - Compress context to relevant parts",
"top_k": "int (default: 3) - Number of documents to retrieve",
"score_threshold": "float (default: 0.5) - Min relevance score (0-1)",
"max_tokens": "int (default: 512) - Max response tokens",
"temperature": "float (default: 0.7) - Creativity (0-1)",
"hf_token": "string (optional) - Hugging Face token"
},
"response": {
"response": "string - AI answer",
"context_used": "array - Retrieved documents with metadata",
"timestamp": "string",
"rag_stats": "object - RAG pipeline statistics (query variants, retrieval counts)"
},
"example_advanced": {
"message": "Làm sao để upload PDF có hình ảnh?",
"use_advanced_rag": True,
"use_reranking": True,
"top_k": 5,
"score_threshold": 0.5
},
"example_response_with_images": {
"response": "Để upload PDF có hình ảnh, sử dụng endpoint /upload-pdf-multimodal...",
"context_used": [
{
"id": "pdf_multimodal_...._p2_c1",
"confidence": 0.89,
"metadata": {
"text": "Bước 1: Chuẩn bị PDF với image URLs...",
"has_images": True,
"image_urls": [
"https://example.com/screenshot1.png",
"https://example.com/diagram.jpg"
],
"num_images": 2,
"page": 2
}
}
],
"rag_stats": {
"original_query": "Làm sao để upload PDF có hình ảnh?",
"expanded_queries": ["upload PDF hình ảnh", "PDF có ảnh"],
"initial_results": 10,
"after_rerank": 5,
"after_compression": 5
}
},
"notes": [
"Advanced RAG significantly improves answer quality",
"When multimodal PDF is used, images are returned in metadata",
"Requires HUGGINGFACE_TOKEN for actual LLM generation"
]
},
"GET /history": {
"description": "Get chat history",
"query_params": {"limit": "int (default: 10)", "skip": "int (default: 0)"},
"response": {"history": "array", "total": "int"}
}
},
"management": {
"GET /documents/pdf": {
"description": "List all PDF documents",
"response": {"documents": "array", "total": "int"}
},
"DELETE /documents/pdf/{document_id}": {
"description": "Delete PDF and all its chunks",
"response": {"success": "bool", "message": "string"}
},
"GET /document/{doc_id}": {
"description": "Get document by ID",
"response": {"success": "bool", "data": "object"}
},
"DELETE /delete/{doc_id}": {
"description": "Delete document by ID",
"response": {"success": "bool", "message": "string"}
},
"GET /stats": {
"description": "Get Qdrant collection statistics",
"response": {"vectors_count": "int", "segments": "int", "indexed_vectors_count": "int"}
}
}
},
"quick_start": {
"1_upload_multimodal_pdf": "curl -X POST '/upload-pdf-multimodal' -F 'file=@user_guide.pdf' -F 'title=Guide'",
"2_verify_upload": "curl '/documents/pdf'",
"3_chat_with_rag": "curl -X POST '/chat' -H 'Content-Type: application/json' -d '{\"message\": \"How to...?\", \"use_advanced_rag\": true}'",
"4_see_images_in_context": "response['context_used'][0]['metadata']['image_urls']"
},
"use_cases": {
"user_guide_with_screenshots": {
"endpoint": "/upload-pdf-multimodal",
"description": "PDFs with text instructions + image URLs for visual guidance",
"benefits": ["Images linked to text chunks", "Chatbot returns relevant screenshots", "Perfect for step-by-step guides"]
},
"simple_text_docs": {
"endpoint": "/upload-pdf",
"description": "Simple PDFs with text only (FAQ, policies, etc.)"
},
"social_media_posts": {
"endpoint": "/index",
"description": "Index multiple posts with texts (up to 10) and images (up to 10)"
},
"complex_queries": {
"endpoint": "/chat",
"description": "Use advanced RAG for better accuracy on complex questions",
"settings": {"use_advanced_rag": True, "use_reranking": True, "use_compression": True}
}
},
"best_practices": {
"pdf_format": [
"Include image URLs in text (http://, https://)",
"Use markdown format:  or HTML:
",
"Clear structure with headings and sections",
"Link images close to their related text"
],
"chat_settings": {
"for_accuracy": {"temperature": 0.3, "use_advanced_rag": True, "use_reranking": True},
"for_creativity": {"temperature": 0.8, "use_advanced_rag": False},
"for_factual_answers": {"temperature": 0.3, "use_compression": True, "score_threshold": 0.6}
},
"retrieval_tuning": {
"not_finding_info": "Lower score_threshold to 0.3-0.4, increase top_k to 7-10",
"too_much_context": "Increase score_threshold to 0.6-0.7, decrease top_k to 3-5",
"slow_responses": "Disable compression, use basic RAG, decrease top_k"
}
},
"links": {
"docs": "http://localhost:8000/docs",
"redoc": "http://localhost:8000/redoc",
"openapi": "http://localhost:8000/openapi.json",
"guides": {
"multimodal_pdf": "See MULTIMODAL_PDF_GUIDE.md",
"advanced_rag": "See ADVANCED_RAG_GUIDE.md",
"pdf_general": "See PDF_RAG_GUIDE.md",
"quick_start": "See QUICK_START_PDF.md"
}
},
"system_info": {
"embedding_model": "Jina CLIP v2 (multimodal)",
"vector_db": "Qdrant with HNSW index",
"document_db": "MongoDB",
"rag_pipeline": "Advanced RAG with query expansion, reranking, compression",
"pdf_parser": "pypdfium2 with URL extraction",
"max_inputs": "10 texts + 10 images per /index request"
}
}
@app.post("/index", response_model=IndexResponse)
async def index_data(
id: str = Form(...),
texts: Optional[List[str]] = Form(None),
images: Optional[List[UploadFile]] = File(None),
id_use: Optional[str] = Form(None),
id_user: Optional[str] = Form(None)
):
"""
Index data vào vector database (hỗ trợ nhiều texts và images)
Body:
- id: Document ID (primary ID)
- texts: List of text contents (tiếng Việt supported) - Tối đa 10 texts
- images: List of image files (optional) - Tối đa 10 images
- id_use: ID của SocialMedia hoặc EventCode (optional)
- id_user: ID của User (optional)
Returns:
- success: True/False
- id: Document ID
- message: Status message
Example:
```bash
curl -X POST '/index' \
-F 'id=doc123' \
-F 'id_use=social_media_456' \
-F 'id_user=user_789' \
-F 'texts=Post content 1' \
-F 'texts=Post content 2' \
-F 'images=@image1.jpg'
```
"""
try:
# Validation
if texts is None and images is None:
raise HTTPException(status_code=400, detail="Phải cung cấp ít nhất texts hoặc images")
if texts and len(texts) > 10:
raise HTTPException(status_code=400, detail="Tối đa 10 texts")
if images and len(images) > 10:
raise HTTPException(status_code=400, detail="Tối đa 10 images")
# Prepare embeddings
text_embeddings = []
image_embeddings = []
# Encode multiple texts (tiếng Việt)
if texts:
for text in texts:
if text and text.strip():
text_emb = embedding_service.encode_text(text)
text_embeddings.append(text_emb)
# Encode multiple images
if images:
for image in images:
if image.filename: # Check if image is provided
image_bytes = await image.read()
pil_image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
image_emb = embedding_service.encode_image(pil_image)
image_embeddings.append(image_emb)
# Combine embeddings
all_embeddings = []
if text_embeddings:
# Average all text embeddings
avg_text_embedding = np.mean(text_embeddings, axis=0)
all_embeddings.append(avg_text_embedding)
if image_embeddings:
# Average all image embeddings
avg_image_embedding = np.mean(image_embeddings, axis=0)
all_embeddings.append(avg_image_embedding)
if not all_embeddings:
raise HTTPException(status_code=400, detail="Không có embedding nào được tạo từ texts hoặc images")
# Final combined embedding
combined_embedding = np.mean(all_embeddings, axis=0)
# Normalize
combined_embedding = combined_embedding / np.linalg.norm(combined_embedding, axis=1, keepdims=True)
# Index vào Qdrant
metadata = {
"texts": texts if texts else [],
"text_count": len(texts) if texts else 0,
"image_count": len(images) if images else 0,
"image_filenames": [img.filename for img in images] if images else [],
"id_use": id_use if id_use else None, # ID của SocialMedia hoặc EventCode
"id_user": id_user if id_user else None # ID của User
}
result = qdrant_service.index_data(
doc_id=id,
embedding=combined_embedding,
metadata=metadata
)
return IndexResponse(
success=True,
id=result["original_id"], # Trả về MongoDB ObjectId
message=f"Đã index thành công document {result['original_id']} với {len(texts) if texts else 0} texts và {len(images) if images else 0} images (Qdrant UUID: {result['qdrant_id']})"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Lỗi khi index: {str(e)}")
@app.post("/search", response_model=List[SearchResponse])
async def search(
text: Optional[str] = Form(None),
image: Optional[UploadFile] = File(None),
limit: int = Form(10),
score_threshold: Optional[float] = Form(None),
text_weight: float = Form(0.5),
image_weight: float = Form(0.5)
):
"""
Search similar documents bằng text và/hoặc image
Body:
- text: Query text (tiếng Việt supported)
- image: Query image (optional)
- limit: Số lượng kết quả (default: 10)
- score_threshold: Minimum confidence score (0-1)
- text_weight: Weight cho text search (default: 0.5)
- image_weight: Weight cho image search (default: 0.5)
Returns:
- List of results với id, confidence, và metadata
"""
try:
# Prepare query embeddings
text_embedding = None
image_embedding = None
# Encode text query
if text and text.strip():
text_embedding = embedding_service.encode_text(text)
# Encode image query
if image:
image_bytes = await image.read()
pil_image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
image_embedding = embedding_service.encode_image(pil_image)
# Validate input
if text_embedding is None and image_embedding is None:
raise HTTPException(status_code=400, detail="Phải cung cấp ít nhất text hoặc image để search")
# Hybrid search với Qdrant
results = qdrant_service.hybrid_search(
text_embedding=text_embedding,
image_embedding=image_embedding,
text_weight=text_weight,
image_weight=image_weight,
limit=limit,
score_threshold=score_threshold,
ef=256 # High accuracy search
)
# Format response
return [
SearchResponse(
id=result["id"],
confidence=result["confidence"],
metadata=result["metadata"]
)
for result in results
]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Lỗi khi search: {str(e)}")
@app.post("/search/text", response_model=List[SearchResponse])
async def search_by_text(
text: str = Form(...),
limit: int = Form(10),
score_threshold: Optional[float] = Form(None)
):
"""
Search chỉ bằng text (tiếng Việt)
Body:
- text: Query text (tiếng Việt)
- limit: Số lượng kết quả
- score_threshold: Minimum confidence score
Returns:
- List of results
"""
try:
# Encode text
text_embedding = embedding_service.encode_text(text)
# Search
results = qdrant_service.search(
query_embedding=text_embedding,
limit=limit,
score_threshold=score_threshold,
ef=256
)
return [
SearchResponse(
id=result["id"],
confidence=result["confidence"],
metadata=result["metadata"]
)
for result in results
]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Lỗi khi search: {str(e)}")
@app.post("/search/image", response_model=List[SearchResponse])
async def search_by_image(
image: UploadFile = File(...),
limit: int = Form(10),
score_threshold: Optional[float] = Form(None)
):
"""
Search chỉ bằng image
Body:
- image: Query image
- limit: Số lượng kết quả
- score_threshold: Minimum confidence score
Returns:
- List of results
"""
try:
# Encode image
image_bytes = await image.read()
pil_image = Image.open(io.BytesIO(image_bytes)).convert('RGB')
image_embedding = embedding_service.encode_image(pil_image)
# Search
results = qdrant_service.search(
query_embedding=image_embedding,
limit=limit,
score_threshold=score_threshold,
ef=256
)
return [
SearchResponse(
id=result["id"],
confidence=result["confidence"],
metadata=result["metadata"]
)
for result in results
]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Lỗi khi search: {str(e)}")
@app.delete("/delete/{doc_id}")
async def delete_document(doc_id: str):
"""
Delete document by ID (MongoDB ObjectId hoặc UUID)
Args:
- doc_id: Document ID to delete
Returns:
- Success message
"""
try:
qdrant_service.delete_by_id(doc_id)
return {"success": True, "message": f"Đã xóa document {doc_id}"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Lỗi khi xóa: {str(e)}")
@app.get("/document/{doc_id}")
async def get_document(doc_id: str):
"""
Get document by ID (MongoDB ObjectId hoặc UUID)
Args:
- doc_id: Document ID (MongoDB ObjectId)
Returns:
- Document data
"""
try:
doc = qdrant_service.get_by_id(doc_id)
if doc:
return {
"success": True,
"data": doc
}
raise HTTPException(status_code=404, detail=f"Không tìm thấy document {doc_id}")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Lỗi khi get document: {str(e)}")
@app.get("/stats")
async def get_stats():
"""
Lấy thông tin thống kê collection
Returns:
- Collection statistics
"""
try:
info = qdrant_service.get_collection_info()
return info
except Exception as e:
raise HTTPException(status_code=500, detail=f"Lỗi khi lấy stats: {str(e)}")
# ============================================
# ChatbotRAG Endpoints
# ============================================
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
"""
Chat endpoint với Advanced RAG
Body:
- message: User message
- use_rag: Enable RAG retrieval (default: true)
- top_k: Number of documents to retrieve (default: 3)
- system_message: System prompt (optional)
- max_tokens: Max tokens for response (default: 512)
- temperature: Temperature for generation (default: 0.7)
- hf_token: Hugging Face token (optional, sẽ dùng env nếu không truyền)
- use_advanced_rag: Use advanced RAG pipeline (default: true)
- use_query_expansion: Enable query expansion (default: true)
- use_reranking: Enable reranking (default: true)
- use_compression: Enable context compression (default: true)
- score_threshold: Minimum relevance score (default: 0.5)
Returns:
- response: Generated response
- context_used: Retrieved context documents
- timestamp: Response timestamp
- rag_stats: Statistics from RAG pipeline
"""
try:
# Retrieve context if RAG enabled
context_used = []
rag_stats = None
if request.use_rag:
if request.use_advanced_rag:
# Use Advanced RAG Pipeline
documents, stats = advanced_rag.hybrid_rag_pipeline(
query=request.message,
top_k=request.top_k,
score_threshold=request.score_threshold,
use_reranking=request.use_reranking,
use_compression=request.use_compression,
max_context_tokens=500
)
# Convert to dict format for compatibility
context_used = [
{
"id": doc.id,
"confidence": doc.confidence,
"metadata": doc.metadata
}
for doc in documents
]
rag_stats = stats
# Format context using advanced RAG formatter
context_text = advanced_rag.format_context_for_llm(documents)
else:
# Use basic RAG (original implementation)
query_embedding = embedding_service.encode_text(request.message)
results = qdrant_service.search(
query_embedding=query_embedding,
limit=request.top_k,
score_threshold=request.score_threshold
)
context_used = results
# Build context text (basic format)
context_text = "\n\nRelevant Context:\n"
for i, doc in enumerate(context_used, 1):
doc_text = doc["metadata"].get("text", "")
confidence = doc["confidence"]
context_text += f"\n[{i}] (Confidence: {confidence:.2f})\n{doc_text}\n"
# Build system message with context
if request.use_rag and context_used:
if request.use_advanced_rag:
# Use advanced prompt builder
system_message = advanced_rag.build_rag_prompt(
query=request.message,
context=context_text,
system_message=request.system_message
)
else:
# Basic prompt
system_message = f"{request.system_message}\n{context_text}\n\nPlease use the above context to answer the user's question when relevant."
else:
system_message = request.system_message
# Use token from request or fallback to env
token = request.hf_token or hf_token
# Generate response
if not token:
response = f"""[LLM Response Placeholder]
Context retrieved: {len(context_used)} documents
User question: {request.message}
To enable actual LLM generation:
1. Set HUGGINGFACE_TOKEN environment variable, OR
2. Pass hf_token in request body
Example:
{{
"message": "Your question",
"hf_token": "hf_xxxxxxxxxxxxx"
}}
"""
else:
try:
client = InferenceClient(
token=hf_token,
model="openai/gpt-oss-20b"
)
# Build messages
messages = [
{"role": "system", "content": system_message},
{"role": "user", "content": request.message}
]
# Generate response
response = ""
for msg in client.chat_completion(
messages,
max_tokens=request.max_tokens,
stream=True,
temperature=request.temperature,
top_p=request.top_p,
):
choices = msg.choices
if len(choices) and choices[0].delta.content:
response += choices[0].delta.content
except Exception as e:
response = f"Error generating response with LLM: {str(e)}\n\nContext was retrieved successfully, but LLM generation failed."
# Save to history
chat_data = {
"user_message": request.message,
"assistant_response": response,
"context_used": context_used,
"timestamp": datetime.utcnow()
}
chat_history_collection.insert_one(chat_data)
return ChatResponse(
response=response,
context_used=context_used,
timestamp=datetime.utcnow().isoformat(),
rag_stats=rag_stats
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@app.post("/documents", response_model=AddDocumentResponse)
async def add_document(request: AddDocumentRequest):
"""
Add document to knowledge base
Body:
- text: Document text
- metadata: Additional metadata (optional)
Returns:
- success: True/False
- doc_id: MongoDB document ID
- message: Status message
"""
try:
# Save to MongoDB
doc_data = {
"text": request.text,
"metadata": request.metadata or {},
"created_at": datetime.utcnow()
}
result = documents_collection.insert_one(doc_data)
doc_id = str(result.inserted_id)
# Generate embedding
embedding = embedding_service.encode_text(request.text)
# Index to Qdrant
qdrant_service.index_data(
doc_id=doc_id,
embedding=embedding,
metadata={
"text": request.text,
"source": "api",
**(request.metadata or {})
}
)
return AddDocumentResponse(
success=True,
doc_id=doc_id,
message=f"Document added successfully with ID: {doc_id}"
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@app.post("/rag/search", response_model=List[SearchResponse])
async def rag_search(
query: str = Form(...),
top_k: int = Form(5),
score_threshold: Optional[float] = Form(0.5)
):
"""
Search in knowledge base
Body:
- query: Search query
- top_k: Number of results (default: 5)
- score_threshold: Minimum score (default: 0.5)
Returns:
- results: List of matching documents
"""
try:
# Generate query embedding
query_embedding = embedding_service.encode_text(query)
# Search in Qdrant
results = qdrant_service.search(
query_embedding=query_embedding,
limit=top_k,
score_threshold=score_threshold
)
return [
SearchResponse(
id=result["id"],
confidence=result["confidence"],
metadata=result["metadata"]
)
for result in results
]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@app.get("/history")
async def get_history(limit: int = 10, skip: int = 0):
"""
Get chat history
Query params:
- limit: Number of messages to return (default: 10)
- skip: Number of messages to skip (default: 0)
Returns:
- history: List of chat messages
"""
try:
history = list(
chat_history_collection
.find({}, {"_id": 0})
.sort("timestamp", -1)
.skip(skip)
.limit(limit)
)
# Convert datetime to string
for msg in history:
if "timestamp" in msg:
msg["timestamp"] = msg["timestamp"].isoformat()
return {
"history": history,
"total": chat_history_collection.count_documents({})
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@app.delete("/documents/{doc_id}")
async def delete_document_from_kb(doc_id: str):
"""
Delete document from knowledge base
Args:
- doc_id: Document ID (MongoDB ObjectId)
Returns:
- success: True/False
- message: Status message
"""
try:
# Delete from MongoDB
result = documents_collection.delete_one({"_id": doc_id})
# Delete from Qdrant
if result.deleted_count > 0:
qdrant_service.delete_by_id(doc_id)
return {"success": True, "message": f"Document {doc_id} deleted from knowledge base"}
else:
raise HTTPException(status_code=404, detail=f"Document {doc_id} not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@app.post("/upload-pdf", response_model=UploadPDFResponse)
async def upload_pdf(
file: UploadFile = File(...),
document_id: Optional[str] = Form(None),
title: Optional[str] = Form(None),
description: Optional[str] = Form(None),
category: Optional[str] = Form(None)
):
"""
Upload and index PDF file into knowledge base
Body (multipart/form-data):
- file: PDF file (required)
- document_id: Custom document ID (optional, auto-generated if not provided)
- title: Document title (optional)
- description: Document description (optional)
- category: Document category (optional, e.g., "user_guide", "faq")
Returns:
- success: True/False
- document_id: Document ID
- filename: Original filename
- chunks_indexed: Number of chunks created
- message: Status message
Example:
```bash
curl -X POST "http://localhost:8000/upload-pdf" \
-F "file=@user_guide.pdf" \
-F "title=Hướng dẫn sử dụng ChatbotRAG" \
-F "category=user_guide"
```
"""
try:
# Validate file type
if not file.filename.endswith('.pdf'):
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
# Generate document ID if not provided
if not document_id:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
document_id = f"pdf_{timestamp}"
# Read PDF bytes
pdf_bytes = await file.read()
# Prepare metadata
metadata = {}
if title:
metadata['title'] = title
if description:
metadata['description'] = description
if category:
metadata['category'] = category
# Index PDF
result = pdf_indexer.index_pdf_bytes(
pdf_bytes=pdf_bytes,
document_id=document_id,
filename=file.filename,
document_metadata=metadata
)
return UploadPDFResponse(
success=True,
document_id=result['document_id'],
filename=result['filename'],
chunks_indexed=result['chunks_indexed'],
message=f"PDF '{file.filename}' đã được index thành công với {result['chunks_indexed']} chunks"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error uploading PDF: {str(e)}")
@app.get("/documents/pdf")
async def list_pdf_documents():
"""
List all PDF documents in knowledge base
Returns:
- documents: List of PDF documents with metadata
"""
try:
docs = list(documents_collection.find(
{"type": "pdf"},
{"_id": 0}
))
return {"documents": docs, "total": len(docs)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@app.delete("/documents/pdf/{document_id}")
async def delete_pdf_document(document_id: str):
"""
Delete PDF document and all its chunks from knowledge base
Args:
- document_id: Document ID
Returns:
- success: True/False
- message: Status message
"""
try:
# Get document info
doc = documents_collection.find_one({"document_id": document_id, "type": "pdf"})
if not doc:
raise HTTPException(status_code=404, detail=f"PDF document {document_id} not found")
# Delete all chunks from Qdrant
chunk_ids = doc.get('chunk_ids', [])
for chunk_id in chunk_ids:
try:
qdrant_service.delete_by_id(chunk_id)
except:
pass # Chunk might already be deleted
# Delete from MongoDB
documents_collection.delete_one({"document_id": document_id})
return {
"success": True,
"message": f"PDF document {document_id} and {len(chunk_ids)} chunks deleted"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
@app.post("/upload-pdf-multimodal", response_model=UploadPDFResponse)
async def upload_pdf_multimodal(
file: UploadFile = File(...),
document_id: Optional[str] = Form(None),
title: Optional[str] = Form(None),
description: Optional[str] = Form(None),
category: Optional[str] = Form(None)
):
"""
Upload PDF with text and image URLs (for user guides with screenshots)
This endpoint is optimized for PDFs containing:
- Text instructions
- Image URLs (http://... or https://...)
- Markdown images: 
- HTML images:
The system will:
1. Extract text from PDF
2. Detect all image URLs in the text
3. Link images to their corresponding text chunks
4. Store image URLs in metadata
5. Return images along with text during chat
Body (multipart/form-data):
- file: PDF file (required)
- document_id: Custom document ID (optional, auto-generated if not provided)
- title: Document title (optional)
- description: Document description (optional)
- category: Document category (optional, e.g., "user_guide", "tutorial")
Returns:
- success: True/False
- document_id: Document ID
- filename: Original filename
- chunks_indexed: Number of chunks created
- message: Status message (includes image count)
Example:
```bash
curl -X POST "http://localhost:8000/upload-pdf-multimodal" \
-F "file=@user_guide_with_images.pdf" \
-F "title=Hướng dẫn có ảnh minh họa" \
-F "category=user_guide"
```
Example Response:
```json
{
"success": true,
"document_id": "pdf_20251029_150000",
"filename": "user_guide_with_images.pdf",
"chunks_indexed": 25,
"message": "PDF 'user_guide_with_images.pdf' indexed with 25 chunks and 15 images"
}
```
"""
try:
# Validate file type
if not file.filename.endswith('.pdf'):
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
# Generate document ID if not provided
if not document_id:
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
document_id = f"pdf_multimodal_{timestamp}"
# Read PDF bytes
pdf_bytes = await file.read()
# Prepare metadata
metadata = {'type': 'multimodal'}
if title:
metadata['title'] = title
if description:
metadata['description'] = description
if category:
metadata['category'] = category
# Index PDF with multimodal parser
result = multimodal_pdf_indexer.index_pdf_bytes(
pdf_bytes=pdf_bytes,
document_id=document_id,
filename=file.filename,
document_metadata=metadata
)
return UploadPDFResponse(
success=True,
document_id=result['document_id'],
filename=result['filename'],
chunks_indexed=result['chunks_indexed'],
message=f"PDF '{file.filename}' indexed successfully with {result['chunks_indexed']} chunks and {result.get('images_found', 0)} images"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error uploading multimodal PDF: {str(e)}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_level="info"
)