rag-onboarding / app.py
Baktabek's picture
Deploy RAG assistant
6d87dc1 verified
raw
history blame
16 kB
#!/usr/bin/env python3
"""
Standalone RAG сервер для демонстрации
Работает напрямую с Qdrant и OpenRouter API без микросервисов
"""
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
import uvicorn
import os
from pathlib import Path
import uuid
from datetime import datetime
import httpx
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
from sentence_transformers import SentenceTransformer
# Конфигурация
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
QDRANT_API_KEY = os.getenv("QDRANT_API_KEY") # Для Qdrant Cloud
COLLECTION_NAME = "onboarding_documents"
# OpenRouter Configuration
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "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")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat-v3-0324")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/chat/completions"
# Redis (опционально, для кэширования)
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD")
# Инициализация
app = FastAPI(title="AI-RAG Onboarding Demo", version="1.0.0")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Глобальные объекты
qdrant_client = None
embedding_model = None
httpx_client = None
class QueryRequest(BaseModel):
query: str
dept_id: str = "onboarding"
user_id: str = "demo_user"
session_id: Optional[str] = None
class Source(BaseModel):
text: str
score: float
metadata: Dict[str, Any]
class QueryResponse(BaseModel):
answer: str
sources: List[Source]
metadata: Dict[str, Any]
class DocumentUploadResponse(BaseModel):
document_id: str
chunks_created: int
message: str
metadata: Dict[str, Any]
@app.on_event("startup")
async def startup_event():
"""Инициализация при запуске"""
global qdrant_client, embedding_model, httpx_client
print("🚀 Запуск RAG сервера...")
# Qdrant
print("🔌 Подключение к Qdrant...")
if QDRANT_API_KEY:
# Qdrant Cloud
qdrant_client = QdrantClient(
host=QDRANT_HOST,
port=QDRANT_PORT,
api_key=QDRANT_API_KEY,
https=True
)
print("✅ Qdrant Cloud подключен")
else:
# Локальный Qdrant
qdrant_client = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT)
print("✅ Qdrant подключен")
# Embedding model
print("🧠 Загрузка модели эмбеддингов...")
# Используем многоязычную модель для правильной работы с русским языком
embedding_model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
print("✅ Модель эмбеддингов загружена (многоязычная)")
# HTTP client для OpenRouter
print("🤖 Настройка OpenRouter API...")
httpx_client = httpx.AsyncClient(timeout=30.0)
print(f"✅ OpenRouter настроен (модель: {OPENROUTER_MODEL})")
print("✨ Сервер готов!")
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"qdrant": "connected" if qdrant_client else "disconnected",
"embedding_model": "loaded" if embedding_model else "not loaded",
"llm": "configured" if httpx_client else "not configured"
}
@app.post("/api/v1/query", response_model=QueryResponse)
async def query(request: QueryRequest):
"""Основной endpoint для запросов"""
try:
import time
start_time = time.time()
# 1. Генерация эмбеддинга запроса
query_embedding = embedding_model.encode(request.query).tolist()
# 2. Поиск в Qdrant с ограничением количества
# Используем только limit без score_threshold чтобы получить топ-3 САМЫХ релевантных
# даже если их score не очень высокий
search_results = qdrant_client.search(
collection_name=COLLECTION_NAME,
query_vector=query_embedding,
limit=3, # Только топ-3 самых релевантных по score
with_payload=True
)
# 3. Подготовка контекста
sources = []
context_parts = []
for idx, hit in enumerate(search_results, 1):
source = Source(
text=hit.payload.get('text', ''),
score=hit.score,
metadata={
'title': hit.payload.get('title', 'Unknown'),
'chunk_index': hit.payload.get('chunk_index', 0),
'doc_type': hit.payload.get('doc_type', 'unknown'),
'department': hit.payload.get('department', 'unknown'),
'last_updated': hit.payload.get('last_updated', 'unknown'),
}
)
sources.append(source)
context_parts.append(f"[Источник {idx}] {hit.payload.get('text', '')}")
context = "\n\n".join(context_parts)
# 4. Генерация ответа с помощью Gemini
prompt = f"""Ты - помощник по онбордингу новых сотрудников компании. Используй предоставленный контекст для ответа на вопрос.
КОНТЕКСТ:
{context}
ВОПРОС: {request.query}
ИНСТРУКЦИИ:
- Отвечай на русском языке используя Markdown форматирование
- Используй только информацию из контекста
- Структурируй ответ понятно и кратко с помощью заголовков (##, ###), списков (-, *), жирного текста (**текст**)
- **ВАЖНО ДЛЯ ССЫЛОК:** Оборачивай ЦЕЛЫЕ ФРАЗЫ в ссылки, а не отдельные слова. Ссылка должна читаться естественно как часть предложения.
✅ ПРАВИЛЬНО (естественное чтение):
- "Встреча в [кабинете 101](source:1)"
- "Обратитесь к [HR-менеджеру](source:2)"
- "Перейдите на [https://account.company.kz](source:3)"
- "Временный пароль действителен [24 часа](source:1)"
- "Получите логин от [IT-отдела](source:2)"
❌ НЕПРАВИЛЬНО (разрывает текст):
- "Временный пароль от [HR-менеджера](source:1) для входа" (разрывает фразу)
- "Действителен только [24 часа](source:1) !" (отдельные слова)
- Делай ссылки МИНИМАЛЬНЫМИ - только ключевой факт, не целое предложение
- Используй ссылки для конкретных фактов: адреса сайтов, номера кабинетов, имена должностей, временные интервалы
- Если информации недостаточно, честно скажи об этом
- Будь дружелюбным и помогающим
ОТВЕТ:"""
# 4. Генерация ответа с помощью OpenRouter
try:
response = await httpx_client.post(
OPENROUTER_BASE_URL,
headers={
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"HTTP-Referer": "https://github.com/baltabekpro/ai-rag-core",
"X-Title": "AI-RAG Onboarding"
},
json={
"model": OPENROUTER_MODEL,
"messages": [
{
"role": "user",
"content": prompt
}
],
"temperature": 0.3,
"max_tokens": 500
},
timeout=30.0
)
response.raise_for_status()
result = response.json()
answer = result["choices"][0]["message"]["content"]
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
raise HTTPException(
status_code=503,
detail="AI сервис временно перегружен. Попробуйте через минуту."
)
raise HTTPException(
status_code=500,
detail=f"Ошибка OpenRouter API: {e.response.text}"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Ошибка генерации ответа: {str(e)}"
)
# 5. Метаданные
processing_time = int((time.time() - start_time) * 1000)
return QueryResponse(
answer=answer,
sources=sources,
metadata={
"processing_time": processing_time,
"model": OPENROUTER_MODEL,
"sources_count": len(sources),
"department": request.dept_id
}
)
except Exception as e:
import traceback
error_trace = traceback.format_exc()
print(f"❌ ОШИБКА в /api/v1/query: {error_trace}")
raise HTTPException(status_code=500, detail=f"Ошибка обработки запроса: {str(e)}")
@app.get("/api/v1/stats")
async def get_stats():
"""Статистика системы"""
try:
collection_info = qdrant_client.get_collection(COLLECTION_NAME)
return {
"collection": COLLECTION_NAME,
"documents_count": collection_info.points_count,
"vector_size": collection_info.config.params.vectors.size,
"status": "operational"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/documents/upload", response_model=DocumentUploadResponse)
async def upload_document(
file: UploadFile = File(...),
title: Optional[str] = Form(None),
department: str = Form("onboarding"),
doc_type: str = Form("guide")
):
"""
Загрузка и индексация документа
Поддерживаемые форматы: .txt, .md
Процесс:
1. Чтение файла
2. Разбивка на чанки (512 токенов)
3. Генерация эмбеддингов
4. Сохранение в Qdrant
"""
try:
import time
start_time = time.time()
# Проверка формата файла
allowed_extensions = ['.txt', '.md']
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Неподдерживаемый формат файла. Разрешены: {', '.join(allowed_extensions)}"
)
# Чтение содержимого
content = await file.read()
text = content.decode('utf-8')
if not text.strip():
raise HTTPException(status_code=400, detail="Файл пустой")
# Используем название файла если title не указан
doc_title = title or file.filename
# ID документа
document_id = str(uuid.uuid4())
# Разбивка на чанки (простая - по 512 токенов ~2000 символов)
chunk_size = 2000
overlap = 200
chunks = []
for i in range(0, len(text), chunk_size - overlap):
chunk = text[i:i + chunk_size]
if chunk.strip():
chunks.append(chunk.strip())
if not chunks:
raise HTTPException(status_code=400, detail="Не удалось создать чанки из документа")
# Генерация эмбеддингов и сохранение
points = []
for idx, chunk in enumerate(chunks):
# Эмбеддинг
embedding = embedding_model.encode(chunk).tolist()
# Point для Qdrant
point = PointStruct(
id=str(uuid.uuid4()),
vector=embedding,
payload={
"text": chunk,
"document_id": document_id,
"chunk_index": idx,
"title": doc_title,
"department": department,
"doc_type": doc_type,
"last_updated": datetime.utcnow().isoformat(),
"filename": file.filename
}
)
points.append(point)
# Загрузка в Qdrant
qdrant_client.upsert(
collection_name=COLLECTION_NAME,
points=points
)
processing_time = int((time.time() - start_time) * 1000)
return DocumentUploadResponse(
document_id=document_id,
chunks_created=len(chunks),
message=f"Документ '{doc_title}' успешно загружен и проиндексирован",
metadata={
"processing_time_ms": processing_time,
"filename": file.filename,
"file_size": len(content),
"chunk_size": chunk_size,
"department": department,
"doc_type": doc_type
}
)
except HTTPException:
raise
except Exception as e:
import traceback
error_trace = traceback.format_exc()
print(f"❌ ОШИБКА в /api/v1/documents/upload: {error_trace}")
raise HTTPException(status_code=500, detail=f"Ошибка загрузки документа: {str(e)}")
if __name__ == "__main__":
print("=" * 70)
print(" AI-RAG Onboarding - Standalone Demo Server")
print("=" * 70)
print()
print("📍 Сервер запускается на: http://localhost:8081")
print("📄 API документация: http://localhost:8081/docs")
print("💬 Откройте frontend/chat.html и измените API URL на http://localhost:8081")
print()
uvicorn.run(
app,
host="0.0.0.0",
port=8081,
log_level="info"
)