File size: 10,408 Bytes
412553b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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"
    )