Spaces:
Sleeping
Sleeping
| #!/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] | |
| 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("✨ Сервер готов!") | |
| 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" | |
| } | |
| 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)}") | |
| 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)) | |
| 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" | |
| ) | |