|
|
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__)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
)
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
|
@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"
|
|
|
) |