Calcifer0323 commited on
Commit
e46589a
·
1 Parent(s): 5ee6501

Deploy FastAPI embedding service: optimized for HuggingFace with Docker, torch 2.1.2, numpy<2

Browse files
Files changed (6) hide show
  1. .env.example +6 -0
  2. .gitignore +31 -0
  3. Dockerfile +34 -0
  4. README.md +78 -7
  5. main.py +1133 -0
  6. requirements.txt +14 -0
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Environment variables for HuggingFace Space
2
+ EMBEDDING_DIMENSIONS=384
3
+ EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L6-v2
4
+
5
+ # Не используются напрямую в HuggingFace, но могут быть настроены в Settings
6
+
.gitignore ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ ENV/
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+
14
+ # Environment
15
+ .env
16
+ .env.local
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # Cache
29
+ .cache/
30
+ *.log
31
+
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
2
+ # Dockerfile for HuggingFace Spaces
3
+
4
+ FROM python:3.11-slim
5
+
6
+ # Create user (required by HuggingFace)
7
+ RUN useradd -m -u 1000 user
8
+ USER user
9
+
10
+ # Set PATH
11
+ ENV PATH="/home/user/.local/bin:$PATH"
12
+
13
+ # Set working directory
14
+ WORKDIR /app
15
+
16
+ # Environment variables for optimization
17
+ ENV PYTHONUNBUFFERED=1
18
+ ENV TRANSFORMERS_CACHE=/home/user/.cache/transformers
19
+ ENV SENTENCE_TRANSFORMERS_HOME=/home/user/.cache/sentence_transformers
20
+ ENV HF_HOME=/home/user/.cache/huggingface
21
+
22
+ # Copy requirements and install dependencies
23
+ COPY --chown=user requirements.txt .
24
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
25
+
26
+ # Copy application files
27
+ COPY --chown=user main.py .
28
+
29
+ # Expose port 7860 (HuggingFace Spaces standard)
30
+ EXPOSE 7860
31
+
32
+ # Start the application
33
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
34
+
README.md CHANGED
@@ -1,10 +1,81 @@
1
  ---
2
- title: Matching
3
- emoji: 🌖
4
- colorFrom: green
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
 
 
 
 
 
 
 
 
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ ```
3
+ docker run -p 7860:7860 matching-service
4
+ docker build -t matching-service .
5
+ ```bash
6
+ Docker:
7
+
8
+ ```
9
+ uvicorn main:app --host 0.0.0.0 --port 7860
10
+ pip install -r requirements.txt
11
+ ```bash
12
+ Локальный запуск:
13
+
14
+ ## Разработка
15
+
16
+ ```
17
+ embedding = response.json()["embedding"]
18
+ )
19
+ json={"text": "Уютная квартира в центре"}
20
+ "https://huggingface.co/spaces/Calcifer0323/matching/embed",
21
+ response = requests.post(
22
+ # Генерация эмбеддинга
23
+
24
+ print(response.json())
25
+ response = requests.get("https://huggingface.co/spaces/Calcifer0323/matching")
26
+ # Health check
27
+
28
+ import requests
29
+ ```python
30
+
31
+ ## Использование
32
+
33
+ - `GET /store/stats` - статистика хранилища
34
+ ### Статистика
35
+
36
+ - `POST /register` - регистрация объекта с эмбеддингом
37
+ - `POST /match-text` - поиск похожих объектов по тексту
38
+ ### Матчинг
39
+
40
+ - `POST /embed-batch` - пакетная генерация эмбеддингов
41
+ - `POST /embed` - генерация эмбеддинга для текста
42
+ - `GET /health` - проверка работоспособности
43
+ ### Основные
44
+
45
+ ## Endpoints
46
+
47
+ - Оптимизирована для семантического поиска
48
+ - Размерность векторов: 384
49
+ - Поддержка 50+ языков (включая русский)
50
+ Используется модель: `paraphrase-multilingual-MiniLM-L6-v2`
51
+
52
+ ## Модель
53
+
54
+ - ReDoc: `/redoc`
55
+ - Swagger UI: `/docs`
56
+ После запуска доступна по адресам:
57
+
58
+ ## API Документация
59
+
60
+ - 🌐 CORS-ready для интеграции
61
+ - 🚀 FastAPI с автоматической документацией
62
+ - 📊 In-memory хранилище векторов
63
+ - 🔍 Семантический поиск и матчинг
64
+ - 🔢 Генерация эмбеддингов для русского и английского текста
65
+
66
+ ## Возможности
67
+
68
+ Сервис для генерации эмбеддингов текста и семантического поиска объектов недвижимости.
69
+
70
+ # Matching Embedding Service
71
+
72
  ---
73
+ app_port: 7860
74
+ license: mit
75
+ pinned: false
76
+ sdk: docker
77
+ colorTo: green
78
+ colorFrom: blue
79
+ emoji: 🏠
80
+ title: Matching Embedding Service
81
 
 
main.py ADDED
@@ -0,0 +1,1133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding Service - FastAPI сервис для генерации эмбеддингов текста.
3
+
4
+ Используется для матчинга лидов с объектами недвижимости на основе семантической близости.
5
+ """
6
+
7
+ import os
8
+ from typing import List, Optional, Dict, Any
9
+ from contextlib import asynccontextmanager
10
+ from uuid import uuid4
11
+
12
+ from fastapi import FastAPI, HTTPException
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from pydantic import BaseModel, Field
15
+ from sentence_transformers import SentenceTransformer
16
+ import numpy as np
17
+ from dotenv import load_dotenv
18
+
19
+ load_dotenv()
20
+
21
+ # Конфигурация
22
+ MODEL_NAME = os.getenv("EMBEDDING_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L6-v2")
23
+ EMBEDDING_DIMENSIONS = int(os.getenv("EMBEDDING_DIMENSIONS", "384"))
24
+
25
+ # Глобальная модель (загружается при старте)
26
+ model: Optional[SentenceTransformer] = None
27
+
28
+ # In-memory хранилище эмбеддингов (для прототипа, в продакшене используется pgvector)
29
+ # Структура: {entity_type: {entity_id: {"embedding": [...], "metadata": {...}}}}
30
+ embedding_store: Dict[str, Dict[str, Dict[str, Any]]] = {
31
+ "leads": {},
32
+ "properties": {}
33
+ }
34
+
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ """Загрузка модели при старте приложения."""
39
+ global model
40
+ print(f"Loading embedding model: {MODEL_NAME}")
41
+ # Оптимизация для минимального потребления памяти
42
+ model = SentenceTransformer(MODEL_NAME, device='cpu')
43
+ # Используем half precision для экономии памяти (если доступно)
44
+ try:
45
+ model.half()
46
+ print("Model converted to half precision (float16)")
47
+ except Exception as e:
48
+ print(f"Could not convert to half precision: {e}")
49
+ print(f"Model loaded successfully. Embedding dimensions: {model.get_sentence_embedding_dimension()}")
50
+ yield
51
+ # Cleanup
52
+ model = None
53
+
54
+
55
+ app = FastAPI(
56
+ title="Embedding Service",
57
+ description="Сервис для генерации эмбеддингов текста",
58
+ version="1.0.0",
59
+ lifespan=lifespan
60
+ )
61
+
62
+ # CORS для локальной разработки
63
+ app.add_middleware(
64
+ CORSMiddleware,
65
+ allow_origins=["*"],
66
+ allow_credentials=True,
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+
72
+ # --- Pydantic Models ---
73
+
74
+ class EmbedRequest(BaseModel):
75
+ """Запрос на генерацию эмбеддинга для одного текста."""
76
+ text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
77
+
78
+
79
+ class EmbedResponse(BaseModel):
80
+ """Ответ с эмбеддингом."""
81
+ embedding: List[float] = Field(..., description="Векторное представление текста")
82
+ model: str = Field(..., description="Название используемой модели")
83
+ dimensions: int = Field(..., description="Размерность вектора")
84
+
85
+
86
+ class EmbedBatchRequest(BaseModel):
87
+ """Запрос на пакетную генерацию эмбеддингов."""
88
+ texts: List[str] = Field(..., min_length=1, description="Список текстов")
89
+
90
+
91
+ class EmbedBatchResponse(BaseModel):
92
+ """Ответ с пакетными эмбеддингами."""
93
+ embeddings: List[List[float]] = Field(..., description="Список векторных представлений")
94
+ model: str = Field(..., description="Название используемой модели")
95
+ dimensions: int = Field(..., description="Размерность векторов")
96
+
97
+
98
+ class SimilarityRequest(BaseModel):
99
+ """Запрос на вычисление косинусной близости."""
100
+ embedding1: List[float] = Field(..., description="Первый эмбеддинг")
101
+ embedding2: List[float] = Field(..., description="Второй эмбеддинг")
102
+
103
+
104
+ class SimilarityResponse(BaseModel):
105
+ """Ответ с косинусной близостью."""
106
+ similarity: float = Field(..., description="Косинусная близость от -1 до 1")
107
+
108
+
109
+ class HealthResponse(BaseModel):
110
+ """Ответ на health check."""
111
+ status: str
112
+ model: str
113
+ dimensions: int
114
+
115
+
116
+ # --- Match Models ---
117
+
118
+ class MatchRequest(BaseModel):
119
+ """Запрос на поиск похожих объектов по эмбеддингу."""
120
+ embedding: List[float] = Field(..., description="Эмбеддинг для поиска")
121
+ entity_type: str = Field(default="properties", description="Тип сущности для поиска (leads, properties)")
122
+ top_k: int = Field(default=5, ge=1, le=100, description="Количество результатов")
123
+ min_similarity: float = Field(default=0.0, ge=-1.0, le=1.0, description="Минимальный порог схожести")
124
+
125
+
126
+ class MatchTextRequest(BaseModel):
127
+ """Запрос на поиск похожих объектов по тексту."""
128
+ text: str = Field(..., min_length=1, description="Текст для поиска")
129
+ entity_type: str = Field(default="properties", description="Тип сущности для поиска (leads, properties)")
130
+ top_k: int = Field(default=5, ge=1, le=100, description="Количество результатов")
131
+ min_similarity: float = Field(default=0.0, ge=-1.0, le=1.0, description="Минимальный порог схожести")
132
+
133
+
134
+ class MatchResult(BaseModel):
135
+ """Результат матчинга."""
136
+ entity_id: str = Field(..., description="ID найденного объекта")
137
+ similarity: float = Field(..., description="Косинусная близость (0-1)")
138
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
139
+
140
+
141
+ class MatchResponse(BaseModel):
142
+ """Ответ с результатами матчинга."""
143
+ matches: List[MatchResult] = Field(..., description="Найденные объекты")
144
+ total_searched: int = Field(..., description="Количество проверенных объектов")
145
+
146
+
147
+ class RegisterEmbeddingRequest(BaseModel):
148
+ """Запрос на регистрацию эмбеддинга объекта."""
149
+ entity_id: str = Field(..., description="ID объекта")
150
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
151
+ text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
152
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
153
+
154
+
155
+ class RegisterEmbeddingFromVectorRequest(BaseModel):
156
+ """Запрос на регистрацию готового эмбеддинга."""
157
+ entity_id: str = Field(..., description="ID объекта")
158
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
159
+ embedding: List[float] = Field(..., description="Готовый эмбеддинг")
160
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
161
+
162
+
163
+ class RegisterResponse(BaseModel):
164
+ """Ответ на регистрацию эмбеддинга."""
165
+ success: bool
166
+ entity_id: str
167
+ entity_type: str
168
+
169
+
170
+ class DeleteEmbeddingRequest(BaseModel):
171
+ """Запрос на удаление эмбеддинга."""
172
+ entity_id: str = Field(..., description="ID объекта")
173
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
174
+
175
+
176
+ class StoreStatsResponse(BaseModel):
177
+ """Статистика хранилища эмбеддингов."""
178
+ leads_count: int
179
+ properties_count: int
180
+ total_count: int
181
+
182
+
183
+ # --- Bulk Index Models ---
184
+
185
+ class BulkIndexItem(BaseModel):
186
+ """Один элемент для массовой индексации."""
187
+ entity_id: str = Field(..., description="ID объекта")
188
+ text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
189
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные")
190
+
191
+
192
+ class BulkIndexRequest(BaseModel):
193
+ """Запрос на массовую индексацию."""
194
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
195
+ items: List[BulkIndexItem] = Field(..., description="Список объектов для индексации")
196
+ clear_existing: bool = Field(default=False, description="Очистить существующие данные перед индексацией")
197
+
198
+
199
+ class BulkIndexResult(BaseModel):
200
+ """Результат индексации одного элемента."""
201
+ entity_id: str
202
+ success: bool
203
+ error: Optional[str] = None
204
+
205
+
206
+ class BulkIndexResponse(BaseModel):
207
+ """Ответ на массовую индексацию."""
208
+ total: int = Field(..., description="Всего элементов в запросе")
209
+ indexed: int = Field(..., description="Успешно проиндексировано")
210
+ failed: int = Field(..., description="Ошибок")
211
+ results: List[BulkIndexResult] = Field(..., description="Детали по каждому элементу")
212
+
213
+
214
+ class ReindexFromDBRequest(BaseModel):
215
+ """Запрос на переиндексацию из внешнего источника (вызывается Go Backend)."""
216
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
217
+ db_url: Optional[str] = Field(default=None, description="URL базы данных (опционально)")
218
+
219
+
220
+ # --- Weighted Matching Models ---
221
+
222
+ class ParameterWeights(BaseModel):
223
+ """Веса для различных параметров матчинга."""
224
+ price: float = Field(default=0.30, ge=0.0, le=1.0, description="Вес цены (по умолчанию 0.30)")
225
+ district: float = Field(default=0.25, ge=0.0, le=1.0, description="Вес района (по умолчанию 0.25)")
226
+ rooms: float = Field(default=0.20, ge=0.0, le=1.0, description="Вес количества комнат (по умолчанию 0.20)")
227
+ area: float = Field(default=0.10, ge=0.0, le=1.0, description="Вес площади (по умолчанию 0.10)")
228
+ semantic: float = Field(default=0.15, ge=0.0, le=1.0, description="Вес семантической близости (по умолчанию 0.15)")
229
+
230
+
231
+ class PriceFilter(BaseModel):
232
+ """Фильтр по цене."""
233
+ min_price: Optional[float] = Field(default=None, description="Минимальная цена")
234
+ max_price: Optional[float] = Field(default=None, description="Максимальная цена")
235
+ tolerance_percent: float = Field(default=10.0, description="Допустимое отклонение в % (для мягкого фильтра)")
236
+
237
+
238
+ class HardFilters(BaseModel):
239
+ """Жёсткие фильтры (объекты не прошедшие фильтр исключаются)."""
240
+ price: Optional[PriceFilter] = Field(default=None, description="Фильтр по цене")
241
+ districts: Optional[List[str]] = Field(default=None, description="Список допустимых районов")
242
+ rooms: Optional[List[int]] = Field(default=None, description="Список допустимого кол-ва комнат")
243
+ min_area: Optional[float] = Field(default=None, description="Минимальная площадь")
244
+ max_area: Optional[float] = Field(default=None, description="Максимальная площадь")
245
+
246
+
247
+ class SoftCriteria(BaseModel):
248
+ """Мягкие критерии для ранжирования (влияют на score, но не исключают)."""
249
+ target_price: Optional[float] = Field(default=None, description="Желаемая цена")
250
+ target_district: Optional[str] = Field(default=None, description="Предпочтительный район")
251
+ target_rooms: Optional[int] = Field(default=None, description="Желаемое кол-во комнат")
252
+ target_area: Optional[float] = Field(default=None, description="Желаемая площадь")
253
+ metro_distance_km: Optional[float] = Field(default=None, description="Желаемое расстояние до метро (км)")
254
+ preferred_districts: Optional[List[str]] = Field(default=None, description="Список предпочтительных районов")
255
+
256
+
257
+ class WeightedMatchRequest(BaseModel):
258
+ """Запрос на взвешенный матчинга с приоритетами."""
259
+ text: str = Field(..., min_length=1, description="Текст запроса (описание требований)")
260
+ entity_type: str = Field(default="properties", description="Тип сущности для поиска")
261
+ top_k: int = Field(default=10, ge=1, le=100, description="Количество результатов")
262
+
263
+ # Настройка весов
264
+ weights: Optional[ParameterWeights] = Field(default=None, description="Веса параметров")
265
+
266
+ # Фильтры
267
+ hard_filters: Optional[HardFilters] = Field(default=None, description="Жёсткие фильтры")
268
+ soft_criteria: Optional[SoftCriteria] = Field(default=None, description="Мягкие критерии")
269
+
270
+ # Минимальный порог
271
+ min_total_score: float = Field(default=0.0, ge=0.0, le=1.0, description="Минимальный общий score")
272
+
273
+
274
+ class WeightedMatchResult(BaseModel):
275
+ """Результат взвешенного матчинга с детализацией."""
276
+ entity_id: str
277
+ total_score: float = Field(..., description="Общий взвешенный score (0-1)")
278
+
279
+ # Детализация по компонентам
280
+ price_score: float = Field(default=0.0, description="Score по цене (0-1)")
281
+ district_score: float = Field(default=0.0, description="Score по району (0-1)")
282
+ rooms_score: float = Field(default=0.0, description="Score по комнатам (0-1)")
283
+ area_score: float = Field(default=0.0, description="Score по площади (0-1)")
284
+ semantic_score: float = Field(default=0.0, description="Семантический score (0-1)")
285
+
286
+ metadata: Optional[Dict[str, Any]] = None
287
+ match_explanation: Optional[str] = Field(default=None, description="Объяснение почему объект подходит")
288
+
289
+
290
+ class WeightedMatchResponse(BaseModel):
291
+ """Ответ взвешенного матчинга."""
292
+ matches: List[WeightedMatchResult]
293
+ total_searched: int
294
+ filtered_out: int = Field(..., description="Отфильтровано жёсткими фильтрами")
295
+ weights_used: ParameterWeights
296
+
297
+
298
+ # --- Endpoints ---
299
+
300
+ @app.get("/health", response_model=HealthResponse)
301
+ async def health_check():
302
+ """Проверка здоровья сервиса."""
303
+ if model is None:
304
+ raise HTTPException(status_code=503, detail="Model not loaded")
305
+ return HealthResponse(
306
+ status="healthy",
307
+ model=MODEL_NAME,
308
+ dimensions=model.get_sentence_embedding_dimension()
309
+ )
310
+
311
+
312
+ @app.post("/embed", response_model=EmbedResponse)
313
+ async def embed_text(request: EmbedRequest):
314
+ """
315
+ Генерация эмбеддинга для одного текста.
316
+
317
+ Используется для получения векторного представления лида или объекта недвижимости.
318
+ """
319
+ if model is None:
320
+ raise HTTPException(status_code=503, detail="Model not loaded")
321
+
322
+ try:
323
+ embedding = model.encode(request.text, convert_to_numpy=True)
324
+ return EmbedResponse(
325
+ embedding=embedding.tolist(),
326
+ model=MODEL_NAME,
327
+ dimensions=len(embedding)
328
+ )
329
+ except Exception as e:
330
+ raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}")
331
+
332
+
333
+ @app.post("/embed-batch", response_model=EmbedBatchResponse)
334
+ async def embed_batch(request: EmbedBatchRequest):
335
+ """
336
+ Пакетная генерация эмбеддингов.
337
+
338
+ Эффективнее для обработки нескольких текстов за раз.
339
+ """
340
+ if model is None:
341
+ raise HTTPException(status_code=503, detail="Model not loaded")
342
+
343
+ try:
344
+ embeddings = model.encode(request.texts, convert_to_numpy=True)
345
+ return EmbedBatchResponse(
346
+ embeddings=[emb.tolist() for emb in embeddings],
347
+ model=MODEL_NAME,
348
+ dimensions=embeddings.shape[1] if len(embeddings.shape) > 1 else len(embeddings)
349
+ )
350
+ except Exception as e:
351
+ raise HTTPException(status_code=500, detail=f"Batch embedding generation failed: {str(e)}")
352
+
353
+
354
+ @app.post("/similarity", response_model=SimilarityResponse)
355
+ async def compute_similarity(request: SimilarityRequest):
356
+ """
357
+ Вычисление косинусной близости между двумя эмбеддингами.
358
+
359
+ Возвращает значение от -1 (противоположные) до 1 (идентичные).
360
+ """
361
+ if len(request.embedding1) != len(request.embedding2):
362
+ raise HTTPException(
363
+ status_code=400,
364
+ detail="Embeddings must have the same dimensions"
365
+ )
366
+
367
+ try:
368
+ vec1 = np.array(request.embedding1)
369
+ vec2 = np.array(request.embedding2)
370
+
371
+ # Косинусная близость
372
+ similarity = np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
373
+
374
+ return SimilarityResponse(similarity=float(similarity))
375
+ except Exception as e:
376
+ raise HTTPException(status_code=500, detail=f"Similarity computation failed: {str(e)}")
377
+
378
+
379
+ @app.post("/prepare-text")
380
+ async def prepare_text_for_embedding(
381
+ title: str = "",
382
+ description: str = "",
383
+ requirement: dict = None
384
+ ):
385
+ """
386
+ Подготовка текста для генерации эмбеддинга.
387
+
388
+ Объединяет title, description и requirement в один текст для эмбеддинга.
389
+ """
390
+ parts = []
391
+
392
+ if title:
393
+ parts.append(f"Название: {title}")
394
+
395
+ if description:
396
+ parts.append(f"Описание: {description}")
397
+
398
+ if requirement:
399
+ req_parts = []
400
+ for key, value in requirement.items():
401
+ req_parts.append(f"{key}: {value}")
402
+ if req_parts:
403
+ parts.append(f"Требования: {', '.join(req_parts)}")
404
+
405
+ combined_text = ". ".join(parts)
406
+
407
+ return {"prepared_text": combined_text}
408
+
409
+
410
+ # --- Matching Endpoints ---
411
+
412
+ def _cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
413
+ """Вычисление косинусной близости между двумя векторами."""
414
+ norm1 = np.linalg.norm(vec1)
415
+ norm2 = np.linalg.norm(vec2)
416
+ if norm1 == 0 or norm2 == 0:
417
+ return 0.0
418
+ return float(np.dot(vec1, vec2) / (norm1 * norm2))
419
+
420
+
421
+ def _calculate_price_score(obj_price: Optional[float], target_price: Optional[float], tolerance_percent: float = 20.0) -> float:
422
+ """
423
+ Вычисление score по цене.
424
+
425
+ Если цена объекта в пределах допуска от целевой - высокий score.
426
+ Чем дальше - тем ниже score.
427
+ """
428
+ if obj_price is None or target_price is None:
429
+ return 0.5 # Нейтральный score если данных нет
430
+
431
+ if target_price == 0:
432
+ return 0.5
433
+
434
+ # Процентное отклонение
435
+ deviation_percent = abs(obj_price - target_price) / target_price * 100
436
+
437
+ if deviation_percent <= tolerance_percent:
438
+ # В пределах допуска - линейно о�� 1.0 до 0.7
439
+ return 1.0 - (deviation_percent / tolerance_percent) * 0.3
440
+ else:
441
+ # За пределами допуска - быстро падает
442
+ extra_deviation = deviation_percent - tolerance_percent
443
+ score = 0.7 - (extra_deviation / 100) * 0.7
444
+ return max(0.0, score)
445
+
446
+
447
+ def _calculate_district_score(
448
+ obj_district: Optional[str],
449
+ target_district: Optional[str],
450
+ preferred_districts: Optional[List[str]] = None
451
+ ) -> float:
452
+ """
453
+ Вычисление score по району.
454
+
455
+ Точное совпадение = 1.0
456
+ В списке предпочтительных = 0.7
457
+ Иначе = 0.3
458
+ """
459
+ if obj_district is None:
460
+ return 0.3
461
+
462
+ obj_district_lower = obj_district.lower().strip()
463
+
464
+ # Точное совпадение с целевым
465
+ if target_district and obj_district_lower == target_district.lower().strip():
466
+ return 1.0
467
+
468
+ # Проверяем в списке предпочтительных
469
+ if preferred_districts:
470
+ for pref in preferred_districts:
471
+ if obj_district_lower == pref.lower().strip():
472
+ return 0.7
473
+ # Частичное совпадение (например "Центральный" в "Центральный район")
474
+ if pref.lower() in obj_district_lower or obj_district_lower in pref.lower():
475
+ return 0.6
476
+
477
+ return 0.3
478
+
479
+
480
+ def _calculate_rooms_score(obj_rooms: Optional[int], target_rooms: Optional[int]) -> float:
481
+ """
482
+ Вычисление score по количеству комнат.
483
+
484
+ Точное совпадение = 1.0
485
+ ±1 комната = 0.6
486
+ ±2 комнаты = 0.3
487
+ Больше разницы = 0.1
488
+ """
489
+ if obj_rooms is None or target_rooms is None:
490
+ return 0.5
491
+
492
+ diff = abs(obj_rooms - target_rooms)
493
+
494
+ if diff == 0:
495
+ return 1.0
496
+ elif diff == 1:
497
+ return 0.6
498
+ elif diff == 2:
499
+ return 0.3
500
+ else:
501
+ return 0.1
502
+
503
+
504
+ def _calculate_area_score(obj_area: Optional[float], target_area: Optional[float], tolerance_percent: float = 15.0) -> float:
505
+ """
506
+ Вычисление score по площади.
507
+
508
+ Аналогично цене, но с меньшим допуском.
509
+ """
510
+ if obj_area is None or target_area is None:
511
+ return 0.5
512
+
513
+ if target_area == 0:
514
+ return 0.5
515
+
516
+ deviation_percent = abs(obj_area - target_area) / target_area * 100
517
+
518
+ if deviation_percent <= tolerance_percent:
519
+ return 1.0 - (deviation_percent / tolerance_percent) * 0.3
520
+ else:
521
+ extra_deviation = deviation_percent - tolerance_percent
522
+ score = 0.7 - (extra_deviation / 50) * 0.7
523
+ return max(0.0, score)
524
+
525
+
526
+ def _passes_hard_filters(metadata: Dict[str, Any], filters: Optional[HardFilters]) -> bool:
527
+ """Проверка прохождения жёстких фильтров."""
528
+ if filters is None:
529
+ return True
530
+
531
+ # Фильтр по цене
532
+ if filters.price:
533
+ obj_price = metadata.get("price")
534
+ if obj_price is not None:
535
+ if filters.price.min_price and obj_price < filters.price.min_price:
536
+ return False
537
+ if filters.price.max_price and obj_price > filters.price.max_price:
538
+ return False
539
+
540
+ # Фильтр по районам
541
+ if filters.districts:
542
+ obj_district = metadata.get("district", "").lower().strip()
543
+ allowed = [d.lower().strip() for d in filters.districts]
544
+ if obj_district and obj_district not in allowed:
545
+ # Проверяем частичное совпадение
546
+ if not any(a in obj_district or obj_district in a for a in allowed):
547
+ return False
548
+
549
+ # Фильтр по комнатам
550
+ if filters.rooms:
551
+ obj_rooms = metadata.get("rooms")
552
+ if obj_rooms is not None and obj_rooms not in filters.rooms:
553
+ return False
554
+
555
+ # Фильтр по площади
556
+ obj_area = metadata.get("area")
557
+ if obj_area is not None:
558
+ if filters.min_area and obj_area < filters.min_area:
559
+ return False
560
+ if filters.max_area and obj_area > filters.max_area:
561
+ return False
562
+
563
+ return True
564
+
565
+
566
+ def _generate_match_explanation(
567
+ price_score: float,
568
+ district_score: float,
569
+ rooms_score: float,
570
+ area_score: float,
571
+ semantic_score: float,
572
+ metadata: Dict[str, Any]
573
+ ) -> str:
574
+ """Генерация человеко-читаемого объяснения матча."""
575
+ reasons = []
576
+
577
+ if price_score >= 0.7:
578
+ price = metadata.get("price")
579
+ if price:
580
+ reasons.append(f"цена {price:,.0f}₽ в бюджете")
581
+
582
+ if district_score >= 0.7:
583
+ district = metadata.get("district")
584
+ if district:
585
+ reasons.append(f"район '{district}' подходит")
586
+
587
+ if rooms_score >= 0.7:
588
+ rooms = metadata.get("rooms")
589
+ if rooms:
590
+ reasons.append(f"{rooms}-комн. как нужно")
591
+
592
+ if area_score >= 0.7:
593
+ area = metadata.get("area")
594
+ if area:
595
+ reasons.append(f"площадь {area}м² подходит")
596
+
597
+ if semantic_score >= 0.6:
598
+ reasons.append("описание похоже на запрос")
599
+
600
+ if not reasons:
601
+ return "Частичное совпадение по параметрам"
602
+
603
+ return "; ".join(reasons)
604
+
605
+
606
+ @app.post("/match", response_model=MatchResponse)
607
+ async def match_by_embedding(request: MatchRequest):
608
+ """
609
+ Поиск похожих объектов по эмбеддингу.
610
+
611
+ Возвращает top_k наиболее похожих объектов указанного типа.
612
+ """
613
+ if request.entity_type not in embedding_store:
614
+ raise HTTPException(
615
+ status_code=400,
616
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
617
+ )
618
+
619
+ store = embedding_store[request.entity_type]
620
+ if not store:
621
+ return MatchResponse(matches=[], total_searched=0)
622
+
623
+ query_vec = np.array(request.embedding)
624
+
625
+ # Вычисляем схожесть со всеми объектами
626
+ similarities = []
627
+ for entity_id, data in store.items():
628
+ stored_vec = np.array(data["embedding"])
629
+ similarity = _cosine_similarity(query_vec, stored_vec)
630
+ if similarity >= request.min_similarity:
631
+ similarities.append((entity_id, similarity, data.get("metadata")))
632
+
633
+ # Сортируем по убыванию схожести и берем top_k
634
+ similarities.sort(key=lambda x: x[1], reverse=True)
635
+ top_matches = similarities[:request.top_k]
636
+
637
+ matches = [
638
+ MatchResult(entity_id=eid, similarity=sim, metadata=meta)
639
+ for eid, sim, meta in top_matches
640
+ ]
641
+
642
+ return MatchResponse(matches=matches, total_searched=len(store))
643
+
644
+
645
+ @app.post("/match-text", response_model=MatchResponse)
646
+ async def match_by_text(request: MatchTextRequest):
647
+ """
648
+ Поиск похожих объектов по тексту.
649
+
650
+ Генерирует эмбеддинг для текста и ищет похожие объекты.
651
+ """
652
+ if model is None:
653
+ raise HTTPException(status_code=503, detail="Model not loaded")
654
+
655
+ if request.entity_type not in embedding_store:
656
+ raise HTTPException(
657
+ status_code=400,
658
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
659
+ )
660
+
661
+ store = embedding_store[request.entity_type]
662
+ if not store:
663
+ return MatchResponse(matches=[], total_searched=0)
664
+
665
+ try:
666
+ # Генерируем эмбеддинг для текста запроса
667
+ query_embedding = model.encode(request.text, convert_to_numpy=True)
668
+ query_vec = np.array(query_embedding)
669
+
670
+ # Вычисляем схожесть со всеми объектами
671
+ similarities = []
672
+ for entity_id, data in store.items():
673
+ stored_vec = np.array(data["embedding"])
674
+ similarity = _cosine_similarity(query_vec, stored_vec)
675
+ if similarity >= request.min_similarity:
676
+ similarities.append((entity_id, similarity, data.get("metadata")))
677
+
678
+ # Сортируем по убыванию схожести и берем top_k
679
+ similarities.sort(key=lambda x: x[1], reverse=True)
680
+ top_matches = similarities[:request.top_k]
681
+
682
+ matches = [
683
+ MatchResult(entity_id=eid, similarity=sim, metadata=meta)
684
+ for eid, sim, meta in top_matches
685
+ ]
686
+
687
+ return MatchResponse(matches=matches, total_searched=len(store))
688
+ except Exception as e:
689
+ raise HTTPException(status_code=500, detail=f"Match by text failed: {str(e)}")
690
+
691
+
692
+ @app.post("/register", response_model=RegisterResponse)
693
+ async def register_embedding(request: RegisterEmbeddingRequest):
694
+ """
695
+ Регистрация объекта с автоматической генерацией эмбеддинга.
696
+
697
+ Используется для добавления лидов или объектов недвижимости в хранилище.
698
+ """
699
+ if model is None:
700
+ raise HTTPException(status_code=503, detail="Model not loaded")
701
+
702
+ if request.entity_type not in embedding_store:
703
+ raise HTTPException(
704
+ status_code=400,
705
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
706
+ )
707
+
708
+ try:
709
+ # Генерируем эмбеддинг
710
+ embedding = model.encode(request.text, convert_to_numpy=True)
711
+
712
+ # Сохраняем в хранилище
713
+ embedding_store[request.entity_type][request.entity_id] = {
714
+ "embedding": embedding.tolist(),
715
+ "metadata": request.metadata or {}
716
+ }
717
+
718
+ return RegisterResponse(
719
+ success=True,
720
+ entity_id=request.entity_id,
721
+ entity_type=request.entity_type
722
+ )
723
+ except Exception as e:
724
+ raise HTTPException(status_code=500, detail=f"Register embedding failed: {str(e)}")
725
+
726
+
727
+ @app.post("/register-vector", response_model=RegisterResponse)
728
+ async def register_embedding_from_vector(request: RegisterEmbeddingFromVectorRequest):
729
+ """
730
+ Регистрация объекта с готовым эмбеддингом.
731
+
732
+ Используется когда эмбеддинг уже был сгенерирован ранее.
733
+ """
734
+ if request.entity_type not in embedding_store:
735
+ raise HTTPException(
736
+ status_code=400,
737
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
738
+ )
739
+
740
+ # Сохраняем в хранилище
741
+ embedding_store[request.entity_type][request.entity_id] = {
742
+ "embedding": request.embedding,
743
+ "metadata": request.metadata or {}
744
+ }
745
+
746
+ return RegisterResponse(
747
+ success=True,
748
+ entity_id=request.entity_id,
749
+ entity_type=request.entity_type
750
+ )
751
+
752
+
753
+ @app.delete("/register", response_model=RegisterResponse)
754
+ async def delete_embedding(request: DeleteEmbeddingRequest):
755
+ """
756
+ Удаление эмбеддинга объекта из хранилища.
757
+ """
758
+ if request.entity_type not in embedding_store:
759
+ raise HTTPException(
760
+ status_code=400,
761
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
762
+ )
763
+
764
+ store = embedding_store[request.entity_type]
765
+ if request.entity_id not in store:
766
+ raise HTTPException(
767
+ status_code=404,
768
+ detail=f"Entity {request.entity_id} not found in {request.entity_type}"
769
+ )
770
+
771
+ del store[request.entity_id]
772
+
773
+ return RegisterResponse(
774
+ success=True,
775
+ entity_id=request.entity_id,
776
+ entity_type=request.entity_type
777
+ )
778
+
779
+
780
+ @app.get("/store/stats", response_model=StoreStatsResponse)
781
+ async def get_store_stats():
782
+ """
783
+ Получение статистики хранилища эмбеддингов.
784
+ """
785
+ leads_count = len(embedding_store.get("leads", {}))
786
+ properties_count = len(embedding_store.get("properties", {}))
787
+
788
+ return StoreStatsResponse(
789
+ leads_count=leads_count,
790
+ properties_count=properties_count,
791
+ total_count=leads_count + properties_count
792
+ )
793
+
794
+
795
+ @app.get("/store/{entity_type}")
796
+ async def list_registered_entities(entity_type: str):
797
+ """
798
+ Список зарегистрированных объектов указанного типа.
799
+ """
800
+ if entity_type not in embedding_store:
801
+ raise HTTPException(
802
+ status_code=400,
803
+ detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
804
+ )
805
+
806
+ store = embedding_store[entity_type]
807
+ entities = [
808
+ {
809
+ "entity_id": eid,
810
+ "metadata": data.get("metadata", {}),
811
+ "embedding_dimensions": len(data.get("embedding", []))
812
+ }
813
+ for eid, data in store.items()
814
+ ]
815
+
816
+ return {"entity_type": entity_type, "count": len(entities), "entities": entities}
817
+
818
+
819
+ # --- Bulk Indexing Endpoints ---
820
+
821
+ @app.post("/index/bulk", response_model=BulkIndexResponse)
822
+ async def bulk_index(request: BulkIndexRequest):
823
+ """
824
+ Массовая индексация объектов.
825
+
826
+ Позволяет за один запрос проиндексировать множество лидов или объектов.
827
+ Используется для первоначальной загрузки данных или переиндексации.
828
+
829
+ Пример:
830
+ ```
831
+ POST /index/bulk
832
+ {
833
+ "entity_type": "properties",
834
+ "items": [
835
+ {"entity_id": "prop-1", "text": "3-комнатная квартира в центре", "metadata": {"price": 10000000}},
836
+ {"entity_id": "prop-2", "text": "Студия у метро", "metadata": {"price": 5000000}}
837
+ ],
838
+ "clear_existing": false
839
+ }
840
+ ```
841
+ """
842
+ if model is None:
843
+ raise HTTPException(status_code=503, detail="Model not loaded")
844
+
845
+ if request.entity_type not in embedding_store:
846
+ raise HTTPException(
847
+ status_code=400,
848
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
849
+ )
850
+
851
+ # Очистка если нужно
852
+ if request.clear_existing:
853
+ embedding_store[request.entity_type] = {}
854
+
855
+ results: List[BulkIndexResult] = []
856
+ indexed = 0
857
+ failed = 0
858
+
859
+ # Собираем все тексты для батчевой генерации эмбеддингов (быстрее)
860
+ texts = [item.text for item in request.items]
861
+
862
+ try:
863
+ # Генерируем все эмбеддинги за один вызов модели
864
+ embeddings = model.encode(texts, convert_to_numpy=True, show_progress_bar=True)
865
+
866
+ # Сохраняем каждый
867
+ for i, item in enumerate(request.items):
868
+ try:
869
+ embedding_store[request.entity_type][item.entity_id] = {
870
+ "embedding": embeddings[i].tolist(),
871
+ "metadata": item.metadata or {}
872
+ }
873
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
874
+ indexed += 1
875
+ except Exception as e:
876
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(e)))
877
+ failed += 1
878
+ except Exception as e:
879
+ # Если батч не удался, пробуем по одному
880
+ for item in request.items:
881
+ try:
882
+ embedding = model.encode(item.text, convert_to_numpy=True)
883
+ embedding_store[request.entity_type][item.entity_id] = {
884
+ "embedding": embedding.tolist(),
885
+ "metadata": item.metadata or {}
886
+ }
887
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
888
+ indexed += 1
889
+ except Exception as item_error:
890
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(item_error)))
891
+ failed += 1
892
+
893
+ return BulkIndexResponse(
894
+ total=len(request.items),
895
+ indexed=indexed,
896
+ failed=failed,
897
+ results=results
898
+ )
899
+
900
+
901
+ @app.delete("/index/{entity_type}")
902
+ async def clear_index(entity_type: str):
903
+ """
904
+ Очистка индекса для указанного типа сущностей.
905
+
906
+ Удаляет все эмбеддинги указанного типа.
907
+ """
908
+ if entity_type not in embedding_store:
909
+ raise HTTPException(
910
+ status_code=400,
911
+ detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
912
+ )
913
+
914
+ count = len(embedding_store[entity_type])
915
+ embedding_store[entity_type] = {}
916
+
917
+ return {"message": f"Cleared {count} {entity_type} from index", "deleted_count": count}
918
+
919
+
920
+ @app.post("/index/sync")
921
+ async def sync_index_info():
922
+ """
923
+ Получение информации для синхронизации.
924
+
925
+ Возвращает список всех entity_id в индексе, чтобы Go Backend мог
926
+ определить какие объекты нужно добавить/удалить.
927
+ """
928
+ return {
929
+ "leads": list(embedding_store["leads"].keys()),
930
+ "properties": list(embedding_store["properties"].keys())
931
+ }
932
+
933
+
934
+ # --- Weighted Matching Endpoint ---
935
+
936
+ @app.post("/match-weighted", response_model=WeightedMatchResponse)
937
+ async def match_weighted(request: WeightedMatchRequest):
938
+ """
939
+ Взвешенный матчинг с настраиваемыми приоритетами параметров.
940
+
941
+ Позволяет задать:
942
+ - Веса для каждого параметра (цена, район, комнаты, площадь, семантика)
943
+ - Жёсткие фильтры (объекты не прошедшие - исключаются)
944
+ - Мягкие критерии (влияют на ранжирование)
945
+
946
+ Пример использования:
947
+ ```json
948
+ {
949
+ "text": "Ищу 2-комнатную квартиру в центре до 10 млн",
950
+ "entity_type": "properties",
951
+ "top_k": 10,
952
+ "weights": {
953
+ "price": 0.35, // Цена - главный приоритет
954
+ "district": 0.30, // Район - второй по важности
955
+ "rooms": 0.20, // Комнаты
956
+ "area": 0.05, // Площадь менее важна
957
+ "semantic": 0.10 // Семантика для "мягких" критериев
958
+ },
959
+ "hard_filters": {
960
+ "price": {"max_price": 12000000},
961
+ "districts": ["Центральный", "Арбат", "Тверской"]
962
+ },
963
+ "soft_criteria": {
964
+ "target_price": 10000000,
965
+ "target_rooms": 2,
966
+ "target_district": "Центральный"
967
+ }
968
+ }
969
+ ```
970
+ """
971
+ if model is None:
972
+ raise HTTPException(status_code=503, detail="Model not loaded")
973
+
974
+ if request.entity_type not in embedding_store:
975
+ raise HTTPException(
976
+ status_code=400,
977
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
978
+ )
979
+
980
+ store = embedding_store[request.entity_type]
981
+ if not store:
982
+ return WeightedMatchResponse(
983
+ matches=[],
984
+ total_searched=0,
985
+ filtered_out=0,
986
+ weights_used=request.weights or ParameterWeights()
987
+ )
988
+
989
+ # Используем переданные веса или значения по умолчанию
990
+ weights = request.weights or ParameterWeights()
991
+
992
+ # Нормализуем веса чтобы сумма = 1
993
+ total_weight = weights.price + weights.district + weights.rooms + weights.area + weights.semantic
994
+ if total_weight > 0:
995
+ w_price = weights.price / total_weight
996
+ w_district = weights.district / total_weight
997
+ w_rooms = weights.rooms / total_weight
998
+ w_area = weights.area / total_weight
999
+ w_semantic = weights.semantic / total_weight
1000
+ else:
1001
+ w_price = w_district = w_rooms = w_area = w_semantic = 0.2
1002
+
1003
+ # Генерируем эмбеддинг для текста запроса
1004
+ try:
1005
+ query_embedding = model.encode(request.text, convert_to_numpy=True)
1006
+ query_vec = np.array(query_embedding)
1007
+ except Exception as e:
1008
+ raise HTTPException(status_code=500, detail=f"Failed to generate embedding: {str(e)}")
1009
+
1010
+ # Извлекаем soft criteria
1011
+ soft = request.soft_criteria or SoftCriteria()
1012
+
1013
+ results = []
1014
+ filtered_out = 0
1015
+
1016
+ for entity_id, data in store.items():
1017
+ metadata = data.get("metadata", {})
1018
+
1019
+ # 1. Проверяем жёсткие фильтры
1020
+ if not _passes_hard_filters(metadata, request.hard_filters):
1021
+ filtered_out += 1
1022
+ continue
1023
+
1024
+ # 2. Вычисляем score по каждому параметру
1025
+
1026
+ # Цена
1027
+ price_score = _calculate_price_score(
1028
+ metadata.get("price"),
1029
+ soft.target_price,
1030
+ tolerance_percent=20.0
1031
+ )
1032
+
1033
+ # Район
1034
+ district_score = _calculate_district_score(
1035
+ metadata.get("district"),
1036
+ soft.target_district,
1037
+ soft.preferred_districts
1038
+ )
1039
+
1040
+ # Комнаты
1041
+ rooms_score = _calculate_rooms_score(
1042
+ metadata.get("rooms"),
1043
+ soft.target_rooms
1044
+ )
1045
+
1046
+ # Площадь
1047
+ area_score = _calculate_area_score(
1048
+ metadata.get("area"),
1049
+ soft.target_area
1050
+ )
1051
+
1052
+ # Семантика
1053
+ stored_vec = np.array(data["embedding"])
1054
+ semantic_score = _cosine_similarity(query_vec, stored_vec)
1055
+ # Нормализуем в 0-1 (косинусная близость может быть отрицательной)
1056
+ semantic_score = (semantic_score + 1) / 2
1057
+
1058
+ # 3. Вычисляем взвешенный total score
1059
+ total_score = (
1060
+ w_price * price_score +
1061
+ w_district * district_score +
1062
+ w_rooms * rooms_score +
1063
+ w_area * area_score +
1064
+ w_semantic * semantic_score
1065
+ )
1066
+
1067
+ # Пропускаем если ниже минимального порога
1068
+ if total_score < request.min_total_score:
1069
+ continue
1070
+
1071
+ # Генерируем объяснение
1072
+ explanation = _generate_match_explanation(
1073
+ price_score, district_score, rooms_score, area_score, semantic_score, metadata
1074
+ )
1075
+
1076
+ results.append(WeightedMatchResult(
1077
+ entity_id=entity_id,
1078
+ total_score=round(total_score, 4),
1079
+ price_score=round(price_score, 4),
1080
+ district_score=round(district_score, 4),
1081
+ rooms_score=round(rooms_score, 4),
1082
+ area_score=round(area_score, 4),
1083
+ semantic_score=round(semantic_score, 4),
1084
+ metadata=metadata,
1085
+ match_explanation=explanation
1086
+ ))
1087
+
1088
+ # Сортируем по total_score и берём top_k
1089
+ results.sort(key=lambda x: x.total_score, reverse=True)
1090
+ top_results = results[:request.top_k]
1091
+
1092
+ return WeightedMatchResponse(
1093
+ matches=top_results,
1094
+ total_searched=len(store),
1095
+ filtered_out=filtered_out,
1096
+ weights_used=weights
1097
+ )
1098
+
1099
+
1100
+ @app.get("/weights/presets")
1101
+ async def get_weight_presets():
1102
+ """
1103
+ Получить предустановленные наборы весов для разных сценариев.
1104
+
1105
+ Помогает фронтенду предложить пользователю готовые настройки.
1106
+ """
1107
+ return {
1108
+ "balanced": {
1109
+ "name": "Сбалансированный",
1110
+ "description": "Равномерное распределение приоритетов",
1111
+ "weights": {"price": 0.25, "district": 0.25, "rooms": 0.20, "area": 0.15, "semantic": 0.15}
1112
+ },
1113
+ "budget_first": {
1114
+ "name": "Бюджет важнее всего",
1115
+ "description": "Максимальный приоритет на соответствие бюджету",
1116
+ "weights": {"price": 0.45, "district": 0.20, "rooms": 0.15, "area": 0.10, "semantic": 0.10}
1117
+ },
1118
+ "location_first": {
1119
+ "name": "Локация важнее всего",
1120
+ "description": "Район и расположение - главный приоритет",
1121
+ "weights": {"price": 0.20, "district": 0.40, "rooms": 0.15, "area": 0.10, "semantic": 0.15}
1122
+ },
1123
+ "family": {
1124
+ "name": "Для семьи",
1125
+ "description": "Важны комнаты и площадь",
1126
+ "weights": {"price": 0.20, "district": 0.20, "rooms": 0.30, "area": 0.20, "semantic": 0.10}
1127
+ },
1128
+ "semantic_heavy": {
1129
+ "name": "Умный поиск",
1130
+ "description": "Максимальный приоритет на семантическое понимание запроса",
1131
+ "weights": {"price": 0.15, "district": 0.15, "rooms": 0.15, "area": 0.10, "semantic": 0.45}
1132
+ }
1133
+ }
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Requirements for HuggingFace Space
2
+ # Оптимизировано для стабильной работы
3
+
4
+ fastapi==0.104.1
5
+ uvicorn[standard]==0.24.0
6
+ sentence-transformers==2.2.2
7
+ numpy>=1.24.0,<2.0.0
8
+ pydantic==2.5.3
9
+ python-dotenv==1.0.0
10
+
11
+ # PyTorch - используем стандартную версию (HuggingFace имеет достаточно памяти)
12
+ torch>=2.1.0,<2.2.0
13
+ transformers>=4.36.0,<4.37.0
14
+