from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field, field_validator, ConfigDict from typing import Optional, List, Dict, Any import uvicorn import logging import time from datetime import datetime import os from contextlib import asynccontextmanager from search_engine import EnhancedPOISearchEngine # Настройка логирования logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Модели Pydantic class Coordinates(BaseModel): """Модель координат""" lat: float = Field(..., ge=-90, le=90, description="Широта (-90 до 90)") lon: float = Field(..., ge=-180, le=180, description="Долгота (-180 до 180)") @field_validator('lat', 'lon') def validate_coordinates(cls, v): if v == 0: raise ValueError('Координаты не могут быть нулевыми') return v class RouteInfo(BaseModel): """Информация о маршруте""" start: Optional[Coordinates] = Field(None, description="Начальная точка маршрута") end: Optional[Coordinates] = Field(None, description="Конечная точка маршрута") max_distance_km: float = Field(3.0, gt=0, le=50, description="Максимальное расстояние от маршрута в км") class SearchRequest(BaseModel): """Запрос на поиск POI""" query: str = Field(..., min_length=2, max_length=200, description="Текстовый запрос для поиска") route: Optional[RouteInfo] = Field(None, description="Информация о маршруте (опционально)") max_results: int = Field(20, ge=1, le=50, description="Максимальное количество результатов") model_config = ConfigDict( json_schema_extra={ "example": { "query": "кафе и музей", "route": { "start": {"lat": 55.7539, "lon": 37.6208}, "end": {"lat": 55.7601, "lon": 37.6186}, "max_distance_km": 2.0 }, "max_results": 15 } } ) class POIResult(BaseModel): """Результат поиска точки интереса""" id: int name: str category: str type: str lat: float lon: float score: float = Field(..., ge=0, le=1, description="Релевантность от 0 до 1") distance_to_route: Optional[float] = Field(None, description="Расстояние до маршрута в км") description: Optional[str] = None class SearchResponse(BaseModel): """Ответ на запрос поиска""" success: bool query: str results: List[POIResult] count: int processing_time_ms: float categories_found: List[str] timestamp: str class HealthResponse(BaseModel): """Ответ проверки здоровья""" status: str service: str version: str model_loaded: bool points_count: int uptime_seconds: float timestamp: str # Инициализация приложения @asynccontextmanager async def lifespan(app: FastAPI): """Управление жизненным циклом приложения""" global search_engine # Старт logger.info("🚀 Запуск POI Search Service...") try: search_engine = EnhancedPOISearchEngine(model_path='model/enhanced') if search_engine.load_model(): logger.info("✅ Модель успешно загружена") else: logger.error("❌ Не удалось загрузить модель") except Exception as e: logger.error(f"❌ Ошибка при инициализации: {e}") yield # Завершение logger.info("👋 Остановка POI Search Service...") search_engine = None app = FastAPI( title="POI Search API", description="API для поиска точек интереса с учетом маршрута", version="2.0.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan ) # Настройка CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Глобальные переменные search_engine = None start_time = datetime.now() @app.get("/", tags=["Root"]) async def root(): """Корневой эндпоинт""" return { "service": "POI Search API", "version": "2.0.0", "description": "Поиск точек интереса с маршрутизацией", "timestamp": datetime.now().isoformat() } @app.get("/api/v2/health", response_model=HealthResponse, tags=["Health"]) async def health_check(): """Проверка здоровья сервиса""" uptime = (datetime.now() - start_time).total_seconds() model_loaded = search_engine is not None and search_engine.model is not None points_count = len(search_engine.df) if search_engine and search_engine.df is not None else 0 return HealthResponse( status="healthy" if model_loaded else "degraded", service="POI Search API", version="2.0.0", model_loaded=model_loaded, points_count=points_count, uptime_seconds=uptime, timestamp=datetime.now().isoformat() ) @app.post("/api/v2/search", response_model=SearchResponse, tags=["Search"]) async def search_poi(request: SearchRequest): """Основной поиск точек интереса""" if search_engine is None or search_engine.model is None: raise HTTPException(status_code=503, detail="Модель не загружена") start_timer = time.time() try: # Извлекаем параметры маршрута start_coords = None end_coords = None max_distance = 5.0 if request.route and request.route.start and request.route.end: start_coords = (request.route.start.lat, request.route.start.lon) end_coords = (request.route.end.lat, request.route.end.lon) max_distance = request.route.max_distance_km # Выполняем поиск results = search_engine.multi_category_search( query=request.query, start_coords=start_coords, end_coords=end_coords, max_distance_km=max_distance, max_results=request.max_results ) processing_time_ms = (time.time() - start_timer) * 1000 # Собираем найденные категории categories_found = list(set(r['category'] for r in results if r.get('category'))) response = SearchResponse( success=True, query=request.query, results=results, count=len(results), processing_time_ms=processing_time_ms, categories_found=categories_found, timestamp=datetime.now().isoformat() ) logger.info( f"🔍 Поиск '{request.query}': найдено {len(results)} точек ({len(categories_found)} категорий) за {processing_time_ms:.1f}ms") return response except Exception as e: logger.error(f"❌ Ошибка при поиске '{request.query}': {e}") raise HTTPException(status_code=500, detail=f"Ошибка при поиске: {str(e)}") @app.post("/api/v2/search/fast", tags=["Search"]) async def fast_search( query: str = Query(..., min_length=2), max_results: int = Query(10, ge=1, le=50) ): """Быстрый поиск""" if search_engine is None: raise HTTPException(status_code=503, detail="Поисковый движок не загружен") try: results = search_engine.simple_search(query, max_results) categories_found = list(set(r['category'] for r in results if r.get('category'))) return { "success": True, "query": query, "results": results, "count": len(results), "categories_found": categories_found } except Exception as e: logger.error(f"❌ Ошибка быстрого поиска: {e}") raise HTTPException(status_code=500, detail=str(e)) MODEL_PATH = os.path.join(os.path.dirname(__file__), 'model/enhanced') # В lifespan измените: @asynccontextmanager async def lifespan(app: FastAPI): global search_engine logger.info("🚀 Запуск POI Search Service на Hugging Face Spaces...") try: search_engine = EnhancedPOISearchEngine(model_path=MODEL_PATH) if search_engine.load_model(): logger.info("✅ Модель успешно загружена") else: logger.warning("⚠️ Модель не загружена, но сервис работает в ограниченном режиме") except Exception as e: logger.error(f"❌ Ошибка при инициализации: {e}") yield logger.info("👋 Остановка POI Search Service...") search_engine = None # Middleware для логирования @app.middleware("http") async def log_requests(request, call_next): """Логирование всех запросов""" start_time = time.time() response = await call_next(request) process_time = (time.time() - start_time) * 1000 logger.info( f"{request.method} {request.url.path} - " f"Status: {response.status_code} - " f"Time: {process_time:.1f}ms" ) return response if __name__ == "__main__": uvicorn.run( "app:app", host="0.0.0.0", port=7860, reload=False, workers=1, log_level="info" )