Calcifer0323 commited on
Commit
9cf5488
·
0 Parent(s):

Initial commit: Embedding service ready for Render deployment

Browse files
.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Embedding Service Configuration
2
+ EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
3
+ EMBEDDING_DIMENSIONS=384
4
+
5
+ # Optional: OpenAI API (if using OpenAI embeddings)
6
+ # OPENAI_API_KEY=your-api-key-here
7
+
.gitignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ .cache/
3
+ models/
4
+ # Model cache
5
+
6
+ *.log
7
+ # Logs
8
+
9
+ Thumbs.db
10
+ .DS_Store
11
+ # OS
12
+
13
+ .env.local
14
+ .env
15
+ # Environment variables
16
+
17
+ *~
18
+ *.swo
19
+ *.swp
20
+ .vscode/
21
+ .idea/
22
+ # IDEs
23
+
24
+ .venv
25
+ env/
26
+ ENV/
27
+ venv/
28
+ # Virtual Environment
29
+
30
+ MANIFEST
31
+ *.egg
32
+ .installed.cfg
33
+ *.egg-info/
34
+ share/python-wheels/
35
+ pip-wheel-metadata/
36
+ wheels/
37
+ var/
38
+ sdist/
39
+ parts/
40
+ lib64/
41
+ lib/
42
+ .eggs/
43
+ eggs/
44
+ downloads/
45
+ dist/
46
+ develop-eggs/
47
+ build/
48
+ .Python
49
+ *.so
50
+ *$py.class
51
+ *.py[cod]
52
+ __pycache__/
53
+
.python-version ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ 3.11.0
2
+
INTEGRATION.md ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Интеграция Matching Service с Go Backend
2
+
3
+ ## Обзор
4
+
5
+ Matching Service — это Python/FastAPI сервис для семантического поиска похожих объектов на основе эмбеддингов текста.
6
+
7
+ ## Архитектура
8
+
9
+ ```
10
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐
11
+ │ Frontend │────▶│ Go Backend │────▶│ PostgreSQL │
12
+ └─────────────┘ └──────┬──────┘ └─────────────────┘
13
+
14
+ │ HTTP calls
15
+
16
+ ┌─────────────────┐
17
+ │ Embedding Service│
18
+ │ (Python) │
19
+ └─────────────────┘
20
+ ```
21
+
22
+ ## Установка Go-клиента
23
+
24
+ Клиент уже добавлен в `internal/lib/matching/client.go`.
25
+
26
+ ## Конфигурация
27
+
28
+ Добавьте переменные окружения:
29
+
30
+ ```bash
31
+ MATCHING_SERVICE_URL=http://localhost:8082 # URL сервиса матчинга
32
+ MATCHING_ENABLED=true # Включить интеграцию
33
+ MATCHING_TOP_K=10 # Кол-во результатов по умолчанию
34
+ MATCHING_MIN_SIMILARITY=0.1 # Мин. порог схожести (0-1)
35
+ ```
36
+
37
+ ## Использование в коде
38
+
39
+ ### 1. Инициализация клиента
40
+
41
+ ```go
42
+ import "lead_exchange/internal/lib/matching"
43
+
44
+ // В app.go или при инициализации сервисов
45
+ matchingClient := matching.NewClient(cfg.Matching.URL)
46
+
47
+ // Проверка доступности
48
+ health, err := matchingClient.Health(ctx)
49
+ if err != nil {
50
+ log.Warn("Matching service unavailable", "error", err)
51
+ }
52
+ ```
53
+
54
+ ### 2. Регистрация объекта при создании
55
+
56
+ ```go
57
+ // В lead service при создании лида
58
+ func (s *LeadService) CreateLead(ctx context.Context, lead *domain.Lead) error {
59
+ // Сохраняем в БД
60
+ err := s.repo.Create(ctx, lead)
61
+ if err != nil {
62
+ return err
63
+ }
64
+
65
+ // Индексируем в matching service (асинхронно, не блокируем)
66
+ if s.matchingEnabled {
67
+ go func() {
68
+ text := s.prepareLeadText(lead)
69
+ metadata := map[string]interface{}{
70
+ "budget_min": lead.BudgetMin,
71
+ "budget_max": lead.BudgetMax,
72
+ "city": lead.City,
73
+ }
74
+ if err := s.matchingClient.RegisterLead(context.Background(), lead.ID, text, metadata); err != nil {
75
+ log.Error("Failed to register lead in matching", "lead_id", lead.ID, "error", err)
76
+ }
77
+ }()
78
+ }
79
+
80
+ return nil
81
+ }
82
+
83
+ func (s *LeadService) prepareLeadText(lead *domain.Lead) string {
84
+ // Объединяем все текстовые поля для эмбеддинга
85
+ return fmt.Sprintf("%s. %s. Бюджет: %d-%d",
86
+ lead.Title,
87
+ lead.Description,
88
+ lead.BudgetMin,
89
+ lead.BudgetMax,
90
+ )
91
+ }
92
+ ```
93
+
94
+ ### 3. Аналогично для объектов недвижимости
95
+
96
+ ```go
97
+ // В property service при создании объекта
98
+ func (s *PropertyService) CreateProperty(ctx context.Context, prop *domain.Property) error {
99
+ err := s.repo.Create(ctx, prop)
100
+ if err != nil {
101
+ return err
102
+ }
103
+
104
+ if s.matchingEnabled {
105
+ go func() {
106
+ text := s.preparePropertyText(prop)
107
+ metadata := map[string]interface{}{
108
+ "price": prop.Price,
109
+ "rooms": prop.Rooms,
110
+ "area": prop.Area,
111
+ "city": prop.City,
112
+ }
113
+ if err := s.matchingClient.RegisterProperty(context.Background(), prop.ID, text, metadata); err != nil {
114
+ log.Error("Failed to register property in matching", "property_id", prop.ID, "error", err)
115
+ }
116
+ }()
117
+ }
118
+
119
+ return nil
120
+ }
121
+ ```
122
+
123
+ ### 4. Поиск матчей для лида
124
+
125
+ ```go
126
+ // Новый endpoint: GET /v1/leads/{id}/matches
127
+ func (s *LeadService) FindMatches(ctx context.Context, leadID string) ([]MatchResult, error) {
128
+ // Получаем лид из БД
129
+ lead, err := s.repo.GetByID(ctx, leadID)
130
+ if err != nil {
131
+ return nil, err
132
+ }
133
+
134
+ // Ищем похожие объекты
135
+ text := s.prepareLeadText(lead)
136
+ matches, err := s.matchingClient.FindPropertiesForLead(ctx, text, 10, 0.1)
137
+ if err != nil {
138
+ return nil, fmt.Errorf("matching failed: %w", err)
139
+ }
140
+
141
+ return matches, nil
142
+ }
143
+ ```
144
+
145
+ ### 5. Взвешенный по��ск с приоритетами (НОВОЕ)
146
+
147
+ ```go
148
+ // Новый endpoint: POST /v1/leads/{id}/weighted-matches
149
+ func (s *LeadService) FindWeightedMatches(ctx context.Context, leadID string, opts WeightedMatchOptions) ([]WeightedMatchResult, error) {
150
+ lead, err := s.repo.GetByID(ctx, leadID)
151
+ if err != nil {
152
+ return nil, err
153
+ }
154
+
155
+ text := s.prepareLeadText(lead)
156
+
157
+ // Формируем структурированные метаданные для фильтрации
158
+ request := matching.WeightedMatchRequest{
159
+ Text: text,
160
+ EntityType: "properties",
161
+ TopK: opts.TopK,
162
+ Weights: matching.ParameterWeights{
163
+ Price: opts.PriceWeight, // по умолчанию 0.30
164
+ District: opts.DistrictWeight, // по умолчанию 0.25
165
+ Rooms: opts.RoomsWeight, // по умолчанию 0.20
166
+ Area: opts.AreaWeight, // по умолчанию 0.10
167
+ Semantic: opts.SemanticWeight, // по умолчанию 0.15
168
+ },
169
+ HardFilters: matching.HardFilters{
170
+ Price: &matching.PriceFilter{
171
+ MaxPrice: float64(lead.BudgetMax) * 1.2,
172
+ },
173
+ Rooms: opts.AllowedRooms,
174
+ },
175
+ SoftCriteria: matching.SoftCriteria{
176
+ TargetPrice: float64(lead.BudgetMax),
177
+ TargetRooms: lead.Rooms,
178
+ TargetDistrict: lead.District,
179
+ },
180
+ }
181
+
182
+ return s.matchingClient.FindWeightedMatches(ctx, request)
183
+ }
184
+ ```
185
+
186
+ ### 6. Получение пресетов весов (НОВОЕ)
187
+
188
+ ```go
189
+ // GET /v1/matching/presets
190
+ func (s *MatchingService) GetWeightPresets(ctx context.Context) (map[string]WeightPreset, error) {
191
+ return s.matchingClient.GetWeightPresets(ctx)
192
+ }
193
+ ```
194
+
195
+ **Пресеты:**
196
+ - `balanced` — равномерное распределение
197
+ - `budget_first` — бюджет важнее всего
198
+ - `location_first` — локация важнее всего
199
+ - `family` — важны комнаты и площадь
200
+ - `semantic_heavy` — максимум семантики
201
+
202
+ ### 7. Удаление при удалении сущности
203
+
204
+ ```go
205
+ func (s *LeadService) DeleteLead(ctx context.Context, leadID string) error {
206
+ err := s.repo.Delete(ctx, leadID)
207
+ if err != nil {
208
+ return err
209
+ }
210
+
211
+ // Удаляем из индекса
212
+ if s.matchingEnabled {
213
+ go func() {
214
+ s.matchingClient.DeleteLead(context.Background(), leadID)
215
+ }()
216
+ }
217
+
218
+ return nil
219
+ }
220
+ ```
221
+
222
+ ## Добавление gRPC endpoint для матчинга
223
+
224
+ ### 1. Добавить в lead.proto
225
+
226
+ ```protobuf
227
+ // Базовый поиск
228
+ message FindMatchesRequest {
229
+ string lead_id = 1;
230
+ int32 top_k = 2;
231
+ float min_similarity = 3;
232
+ }
233
+
234
+ message MatchResult {
235
+ string property_id = 1;
236
+ float similarity = 2;
237
+ map<string, string> metadata = 3;
238
+ }
239
+
240
+ message FindMatchesResponse {
241
+ repeated MatchResult matches = 1;
242
+ }
243
+
244
+ // Взвешенный поиск (НОВОЕ)
245
+ message ParameterWeights {
246
+ float price = 1; // default 0.30
247
+ float district = 2; // default 0.25
248
+ float rooms = 3; // default 0.20
249
+ float area = 4; // default 0.10
250
+ float semantic = 5; // default 0.15
251
+ }
252
+
253
+ message PriceFilter {
254
+ optional double min_price = 1;
255
+ optional double max_price = 2;
256
+ }
257
+
258
+ message HardFilters {
259
+ optional PriceFilter price = 1;
260
+ repeated string districts = 2;
261
+ repeated int32 rooms = 3;
262
+ optional double min_area = 4;
263
+ optional double max_area = 5;
264
+ }
265
+
266
+ message SoftCriteria {
267
+ optional double target_price = 1;
268
+ optional string target_district = 2;
269
+ optional int32 target_rooms = 3;
270
+ optional double target_area = 4;
271
+ repeated string preferred_districts = 5;
272
+ }
273
+
274
+ message FindWeightedMatchesRequest {
275
+ string lead_id = 1;
276
+ int32 top_k = 2;
277
+ optional ParameterWeights weights = 3;
278
+ optional HardFilters hard_filters = 4;
279
+ optional SoftCriteria soft_criteria = 5;
280
+ float min_total_score = 6;
281
+ }
282
+
283
+ message WeightedMatchResult {
284
+ string property_id = 1;
285
+ float total_score = 2;
286
+ float price_score = 3;
287
+ float district_score = 4;
288
+ float rooms_score = 5;
289
+ float area_score = 6;
290
+ float semantic_score = 7;
291
+ map<string, string> metadata = 8;
292
+ string match_explanation = 9;
293
+ }
294
+
295
+ message FindWeightedMatchesResponse {
296
+ repeated WeightedMatchResult matches = 1;
297
+ int32 total_searched = 2;
298
+ int32 filtered_out = 3;
299
+ ParameterWeights weights_used = 4;
300
+ }
301
+
302
+ service LeadService {
303
+ // ... existing methods ...
304
+ rpc FindMatches(FindMatchesRequest) returns (FindMatchesResponse);
305
+ rpc FindWeightedMatches(FindWeightedMatchesRequest) returns (FindWeightedMatchesResponse);
306
+ }
307
+ ```
308
+
309
+ ### 2. Реализовать handler
310
+
311
+ ```go
312
+ func (s *serverAPI) FindMatches(ctx context.Context, req *pb.FindMatchesRequest) (*pb.FindMatchesResponse, error) {
313
+ matches, err := s.leadService.FindMatches(ctx, req.LeadId)
314
+ if err != nil {
315
+ return nil, status.Error(codes.Internal, err.Error())
316
+ }
317
+
318
+ pbMatches := make([]*pb.MatchResult, len(matches))
319
+ for i, m := range matches {
320
+ pbMatches[i] = &pb.MatchResult{
321
+ PropertyId: m.EntityID,
322
+ Similarity: float32(m.Similarity),
323
+ // ... metadata
324
+ }
325
+ }
326
+
327
+ return &pb.FindMatchesResponse{Matches: pbMatches}, nil
328
+ }
329
+ ```
330
+
331
+ ## Деплой Embedding Service на Render
332
+
333
+ 1. Создайте новый Web Service на Render
334
+ 2. Подключите репозиторий
335
+ 3. Настройки:
336
+ - **Root Directory**: `matching/embedding-service`
337
+ - **Runtime**: Docker
338
+ - **Instance Type**: Standard (нужно минимум 1GB RAM для модели)
339
+
340
+ 4. После деплоя обновите `MATCHING_SERVICE_URL` в основном бэкенде
341
+
342
+ ## Миграция существующих данных
343
+
344
+ Для индексации существующих объектов создайте скрипт:
345
+
346
+ ```go
347
+ func MigrateToMatching(ctx context.Context, repo LeadRepository, client *matching.Client) error {
348
+ leads, err := repo.GetAll(ctx)
349
+ if err != nil {
350
+ return err
351
+ }
352
+
353
+ for _, lead := range leads {
354
+ text := prepareLeadText(lead)
355
+ if err := client.RegisterLead(ctx, lead.ID, text, nil); err != nil {
356
+ log.Error("Failed to migrate lead", "id", lead.ID, "error", err)
357
+ }
358
+ }
359
+
360
+ return nil
361
+ }
362
+ ```
363
+
README.md ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Matching Service
2
+
3
+ Сервис для матчинга лидов с объектами недвижимости на основе семантического поиска с использованием эмбеддингов.
4
+
5
+
6
+ ### 1. Embedding Service (Python)
7
+ FastAPI сервис для генерации эмбеддингов текста:
8
+
9
+ **Базовые эндпоинты:**
10
+ - `/embed` - генерация эмбеддинга для одного текста
11
+ - `/embed-batch` - пакетная генерация эмбеддингов
12
+ - `/similarity` - вычисление косинусной близости
13
+
14
+ **Матчинг:**
15
+ - `/match` - поиск похожих объектов по эмбеддингу
16
+ - `/match-text` - поиск похожих объектов по тексту
17
+ - `/match-weighted` - **НОВОЕ** взвешенный матчинг с настраиваемыми приоритетами
18
+
19
+ **Регистрация:**
20
+ - `/register` - регистрация объекта с автоматической генерацией эмбеддинга
21
+ - `/register-vector` - регистрация объекта с готовым эмбеддингом
22
+
23
+ **Индексация:**
24
+ - `/index/bulk` - **НОВОЕ** массовая индексация объектов
25
+ - `/index/sync` - получение списка проиндексированных ID
26
+ - `DELETE /index/{entity_type}` - очистка индекса
27
+
28
+ **Настройки:**
29
+ - `/weights/presets` - **НОВОЕ** предустановленные наборы весов
30
+
31
+ **Статистика:**
32
+ - `/store/stats` - статистика хранилища эмбеддингов
33
+ - `/store/{entity_type}` - список объектов в индексе
34
+
35
+ Поддерживаемые модели:
36
+ - `sentence-transformers/all-MiniLM-L6-v2` (локальная, бесплатная)
37
+ - `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` (для русского языка)
38
+ - OpenAI `text-embedding-3-small` (платная, высокое качество)
39
+
40
+ ### 2. PostgreSQL с pgvector
41
+ Расширение pgvector позволяет хранить и искать векторы в PostgreSQL:
42
+ - Косинусное расстояние (`<=>`)
43
+ - L2 расстояние (`<->`)
44
+ - Внутреннее произведение (`<#>`)
45
+
46
+ ### 3. Go Client
47
+ HTTP-клиент для вызова Embedding API из Go backend.
48
+
49
+ ## Запуск
50
+
51
+ ```bash
52
+ # Запуск всех сервисов
53
+ docker-compose up -d
54
+
55
+ # Только embedding service
56
+ cd matching/embedding-service
57
+ pip install -r requirements.txt
58
+ uvicorn main:app --host 0.0.0.0 --port 8082
59
+ ```
60
+
61
+ ## API
62
+
63
+ ### GET /health
64
+ Проверка здоровья сервиса.
65
+
66
+ Response:
67
+ ```json
68
+ {
69
+ "status": "healthy",
70
+ "model": "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
71
+ "dimensions": 384
72
+ }
73
+ ```
74
+
75
+ ### POST /embed
76
+ Генерация эмбеддинга для текста.
77
+
78
+ Request:
79
+ ```json
80
+ {
81
+ "text": "Ищу 3-комнатную квартиру в центре города"
82
+ }
83
+ ```
84
+
85
+ Response:
86
+ ```json
87
+ {
88
+ "embedding": [0.123, -0.456, ...],
89
+ "model": "paraphrase-multilingual-MiniLM-L12-v2",
90
+ "dimensions": 384
91
+ }
92
+ ```
93
+
94
+ ### POST /embed-batch
95
+ Пакетная генерация эмбеддингов.
96
+
97
+ Request:
98
+ ```json
99
+ {
100
+ "texts": ["текст 1", "текст 2"]
101
+ }
102
+ ```
103
+
104
+ Response:
105
+ ```json
106
+ {
107
+ "embeddings": [[0.123, ...], [0.456, ...]],
108
+ "model": "paraphrase-multilingual-MiniLM-L12-v2",
109
+ "dimensions": 384
110
+ }
111
+ ```
112
+
113
+ ### POST /similarity
114
+ Вычисление косинусной близости между двумя эмбеддингами.
115
+
116
+ Request:
117
+ ```json
118
+ {
119
+ "embedding1": [0.123, -0.456, ...],
120
+ "embedding2": [0.789, 0.012, ...]
121
+ }
122
+ ```
123
+
124
+ Response:
125
+ ```json
126
+ {
127
+ "similarity": 0.85
128
+ }
129
+ ```
130
+
131
+ ### POST /register
132
+ Регистрация объекта с автоматической генерацией эмбеддинга.
133
+
134
+ Request:
135
+ ```json
136
+ {
137
+ "entity_id": "lead-123",
138
+ "entity_type": "leads",
139
+ "text": "Ищу 3-комнатную квартиру в центре города",
140
+ "metadata": {
141
+ "budget_min": 5000000,
142
+ "budget_max": 8000000,
143
+ "city": "Москва"
144
+ }
145
+ }
146
+ ```
147
+
148
+ Response:
149
+ ```json
150
+ {
151
+ "success": true,
152
+ "entity_id": "lead-123",
153
+ "entity_type": "leads"
154
+ }
155
+ ```
156
+
157
+ ### POST /register-vector
158
+ Регистрация объекта с готовым эмбеддингом.
159
+
160
+ Request:
161
+ ```json
162
+ {
163
+ "entity_id": "property-456",
164
+ "entity_type": "properties",
165
+ "embedding": [0.123, -0.456, ...],
166
+ "metadata": {
167
+ "price": 6500000,
168
+ "rooms": 3,
169
+ "city": "Москва"
170
+ }
171
+ }
172
+ ```
173
+
174
+ ### DELETE /register
175
+ Удаление эмбеддинга объекта из хранилища.
176
+
177
+ Request:
178
+ ```json
179
+ {
180
+ "entity_id": "lead-123",
181
+ "entity_type": "leads"
182
+ }
183
+ ```
184
+
185
+ ### POST /match
186
+ Поиск похожих объектов по эмбеддингу.
187
+
188
+ Request:
189
+ ```json
190
+ {
191
+ "embedding": [0.123, -0.456, ...],
192
+ "entity_type": "properties",
193
+ "top_k": 5,
194
+ "min_similarity": 0.5
195
+ }
196
+ ```
197
+
198
+ Response:
199
+ ```json
200
+ {
201
+ "matches": [
202
+ {
203
+ "entity_id": "property-456",
204
+ "similarity": 0.92,
205
+ "metadata": {
206
+ "price": 6500000,
207
+ "rooms": 3,
208
+ "city": "Москва"
209
+ }
210
+ },
211
+ {
212
+ "entity_id": "property-789",
213
+ "similarity": 0.78,
214
+ "metadata": {...}
215
+ }
216
+ ],
217
+ "total_searched": 150
218
+ }
219
+ ```
220
+
221
+ ### POST /match-text
222
+ Поиск похожих объектов по тексту (генерирует эмбеддинг автоматически).
223
+
224
+ Request:
225
+ ```json
226
+ {
227
+ "text": "Ищу 3-комнатную квартиру в центре города",
228
+ "entity_type": "properties",
229
+ "top_k": 5,
230
+ "min_similarity": 0.5
231
+ }
232
+ ```
233
+
234
+ Response: аналогично `/match`
235
+
236
+ ### GET /store/stats
237
+ Статистика хранилища эмбеддингов.
238
+
239
+ Response:
240
+ ```json
241
+ {
242
+ "leads_count": 42,
243
+ "properties_count": 150,
244
+ "total_count": 192
245
+ }
246
+ ```
247
+
248
+ ### GET /store/{entity_type}
249
+ Список зарегистрированных объектов указанного типа.
250
+
251
+ Response:
252
+ ```json
253
+ {
254
+ "entity_type": "leads",
255
+ "count": 42,
256
+ "entities": [
257
+ {
258
+ "entity_id": "lead-123",
259
+ "metadata": {...},
260
+ "embedding_dimensions": 384
261
+ }
262
+ ]
263
+ }
264
+ ```
265
+
266
+ ## Примеры использования
267
+
268
+ ### Сценарий матчинга лида с объектами недвижимости
269
+
270
+ 1. Бэкенд регистрирует объекты недвижимости:
271
+ ```bash
272
+ curl -X POST http://localhost:8082/register \
273
+ -H "Content-Type: application/json" \
274
+ -d '{
275
+ "entity_id": "property-1",
276
+ "entity_type": "properties",
277
+ "text": "3-комнатная квартира в центре Москвы, 85 кв.м, евроремонт",
278
+ "metadata": {"price": 12000000, "rooms": 3}
279
+ }'
280
+ ```
281
+
282
+ 2. При создании лида делаем матчинг:
283
+ ```bash
284
+ curl -X POST http://localhost:8082/match-text \
285
+ -H "Content-Type: application/json" \
286
+ -d '{
287
+ "text": "Ищу просторную квартиру в центре Москвы",
288
+ "entity_type": "properties",
289
+ "top_k": 10,
290
+ "min_similarity": 0.6
291
+ }'
292
+ ```
293
+
294
+ ---
295
+
296
+ ## Новые функции: Взвешенный матчинг
297
+
298
+ ### POST /match-weighted
299
+ **Взвешенный матчинг с настраиваемыми приоритетами параметров.**
300
+
301
+ Позволяет задать:
302
+ - Веса для каждого параметра (цена, район, комнаты, площадь, семантика)
303
+ - Жёсткие фильтры (объекты не прошедшие — исключаются полностью)
304
+ - Мягкие критерии (влияют на ранжирование, но не исключают)
305
+
306
+ Request:
307
+ ```json
308
+ {
309
+ "text": "Ищу 2-комнатную квартиру в центре до 10 млн",
310
+ "entity_type": "properties",
311
+ "top_k": 10,
312
+ "weights": {
313
+ "price": 0.35,
314
+ "district": 0.30,
315
+ "rooms": 0.20,
316
+ "area": 0.05,
317
+ "semantic": 0.10
318
+ },
319
+ "hard_filters": {
320
+ "price": {
321
+ "min_price": null,
322
+ "max_price": 12000000
323
+ },
324
+ "districts": ["Центр", "Арбат", "Тверской"],
325
+ "rooms": [1, 2, 3],
326
+ "min_area": 40,
327
+ "max_area": 100
328
+ },
329
+ "soft_criteria": {
330
+ "target_price": 10000000,
331
+ "target_rooms": 2,
332
+ "target_district": "Центр",
333
+ "target_area": 55,
334
+ "preferred_districts": ["Центр", "Арбат"]
335
+ },
336
+ "min_total_score": 0.5
337
+ }
338
+ ```
339
+
340
+ Response:
341
+ ```json
342
+ {
343
+ "matches": [
344
+ {
345
+ "entity_id": "prop-2",
346
+ "total_score": 0.9197,
347
+ "price_score": 0.925,
348
+ "district_score": 1.0,
349
+ "rooms_score": 1.0,
350
+ "area_score": 0.5,
351
+ "semantic_score": 0.7867,
352
+ "metadata": {
353
+ "price": 9500000,
354
+ "district": "Центр",
355
+ "rooms": 2,
356
+ "area": 55
357
+ },
358
+ "match_explanation": "цена 9,500,000₽ в бюджете; район 'Центр' подходит; 2-комн. как нужно"
359
+ }
360
+ ],
361
+ "total_searched": 7,
362
+ "filtered_out": 2,
363
+ "weights_used": {
364
+ "price": 0.35,
365
+ "district": 0.30,
366
+ "rooms": 0.20,
367
+ "area": 0.05,
368
+ "semantic": 0.10
369
+ }
370
+ }
371
+ ```
372
+
373
+ ### GET /weights/presets
374
+ Получить предустановленные наборы весов для разных сценариев.
375
+
376
+ Response:
377
+ ```json
378
+ {
379
+ "balanced": {
380
+ "name": "Сбалансированный",
381
+ "description": "Равномерное распределение приоритетов",
382
+ "weights": {"price": 0.25, "district": 0.25, "rooms": 0.20, "area": 0.15, "semantic": 0.15}
383
+ },
384
+ "budget_first": {
385
+ "name": "Бюджет важнее всего",
386
+ "description": "Максимальный приоритет на соответствие бюджету",
387
+ "weights": {"price": 0.45, "district": 0.20, "rooms": 0.15, "area": 0.10, "semantic": 0.10}
388
+ },
389
+ "location_first": {
390
+ "name": "Локация важнее всего",
391
+ "description": "Район и расположение - главный приоритет",
392
+ "weights": {"price": 0.20, "district": 0.40, "rooms": 0.15, "area": 0.10, "semantic": 0.15}
393
+ },
394
+ "family": {
395
+ "name": "Для семьи",
396
+ "description": "Важны комнаты и площадь",
397
+ "weights": {"price": 0.20, "district": 0.20, "rooms": 0.30, "area": 0.20, "semantic": 0.10}
398
+ },
399
+ "semantic_heavy": {
400
+ "name": "Умный поиск",
401
+ "description": "Максимальный приоритет на семантическое понимание запроса",
402
+ "weights": {"price": 0.15, "district": 0.15, "rooms": 0.15, "area": 0.10, "semantic": 0.45}
403
+ }
404
+ }
405
+ ```
406
+
407
+ ---
408
+
409
+ ## Массовая индексация
410
+
411
+ ### POST /index/bulk
412
+ Массовая индексация объектов (эффективнее чем по одному).
413
+
414
+ Request:
415
+ ```json
416
+ {
417
+ "entity_type": "properties",
418
+ "items": [
419
+ {
420
+ "entity_id": "prop-1",
421
+ "text": "3-комнатная квартира в центре, 80м²",
422
+ "metadata": {"price": 15000000, "district": "Центр", "rooms": 3, "area": 80}
423
+ },
424
+ {
425
+ "entity_id": "prop-2",
426
+ "text": "2-комнатная квартира у метро, 55м²",
427
+ "metadata": {"price": 9500000, "district": "Центр", "rooms": 2, "area": 55}
428
+ }
429
+ ],
430
+ "clear_existing": false
431
+ }
432
+ ```
433
+
434
+ Response:
435
+ ```json
436
+ {
437
+ "total": 2,
438
+ "indexed": 2,
439
+ "failed": 0,
440
+ "results": [
441
+ {"entity_id": "prop-1", "success": true, "error": null},
442
+ {"entity_id": "prop-2", "success": true, "error": null}
443
+ ]
444
+ }
445
+ ```
446
+
447
+ ### DELETE /index/{entity_type}
448
+ Очистка индекса для указанного типа.
449
+
450
+ ```bash
451
+ curl -X DELETE http://localhost:8082/index/properties
452
+ ```
453
+
454
+ Response:
455
+ ```json
456
+ {
457
+ "message": "Cleared 150 properties from index",
458
+ "deleted_count": 150
459
+ }
460
+ ```
461
+
462
+ ### POST /index/sync
463
+ Получение списка ID в индексе (для синхронизации с БД).
464
+
465
+ Response:
466
+ ```json
467
+ {
468
+ "leads": ["lead-1", "lead-2", "lead-3"],
469
+ "properties": ["prop-1", "prop-2"]
470
+ }
471
+ ```
472
+
473
+ ---
474
+
475
+ ## Как работает приоритизация параметров
476
+
477
+ ### Иерархия важности (отраслевой стандарт недвижимости):
478
+
479
+ | Приоритет | Параметр | Тип | Описание |
480
+ |-----------|----------|-----|----------|
481
+ | 🔴 1 | Цена/Бюджет | Жёсткий фильтр | Если не в бюджете — исключается |
482
+ | 🔴 2 | Район/Локация | Жёсткий фильтр | Если не в нужном районе — исключается |
483
+ | 🟡 3 | Количество комнат | Мягкий | ±1 комната = снижение score |
484
+ | 🟡 4 | Площадь | Мягкий | ±15% = небольшое снижение |
485
+ | 🟢 5 | Семантика | Мягкий | Для доп. критериев (парк, школа, метро) |
486
+
487
+ ### Формула расчёта score:
488
+
489
+ ```
490
+ total_score = w_price × price_score +
491
+ w_district × district_score +
492
+ w_rooms × rooms_score +
493
+ w_area × area_score +
494
+ w_semantic × semantic_score
495
+ ```
496
+
497
+ Где все веса нормализуются так, чтобы их сумма = 1.
498
+
499
+ ### Расчёт score по параметрам:
500
+
501
+ **Price score:**
502
+ - В пределах ±20% от целевой цены: 1.0 → 0.7 (линейно)
503
+ - За пределами допуска: быстро падает до 0
504
+
505
+ **District score:**
506
+ - Точное совпадение: 1.0
507
+ - В списке preferred: 0.7
508
+ - Частичное совпадение: 0.6
509
+ - Не совпадает: 0.3
510
+
511
+ **Rooms score:**
512
+ - Точное совпадение: 1.0
513
+ - ±1 комната: 0.6
514
+ - ±2 комнаты: 0.3
515
+ - Больше: 0.1
516
+
517
+ **Area score:**
518
+ - В пределах ±15%: 1.0 → 0.7
519
+ - За пределами: падает
520
+
521
+ **Semantic score:**
522
+ - Косинусная близость эмбеддингов (0-1)
523
+
524
+
WORKFLOW.md ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Система матчинга лидов и объектов недвижимости
2
+
3
+ ## Общая схема работы
4
+
5
+ ```
6
+ ┌──────────────────────────────────────────────────────────────────────────────┐
7
+ │ FRONTEND │
8
+ │ ┌─────────────────┐ ┌─────────────────┐ │
9
+ │ │ Форма создания │ │ Форма создания │ │
10
+ │ │ ЛИДА │ │ ОБЪЕКТА │ │
11
+ │ └────────┬────────┘ └────────┬────────┘ │
12
+ └───────────┼────────────────────────────────┼─────────────────────────────────┘
13
+ │ │
14
+ ▼ ▼
15
+ ┌──────────────────────────────────────────────────────────────────────────────┐
16
+ │ GO BACKEND (Render) │
17
+ │ │
18
+ │ ┌─────────────────────────────────────────────────────────────────────────┐ │
19
+ │ │ LeadService / PropertyService │ │
20
+ │ │ │ │
21
+ │ │ 1. Валидация данных │ │
22
+ │ │ 2. Сохранение в PostgreSQL │ │
23
+ │ │ 3. Вызов Matching Service для индексации ◄──── НОВЫЙ ШАГ │ │
24
+ │ │ 4. Возврат результата │ │
25
+ │ └─────────────────────────────────────────────────────────────────────────┘ │
26
+ │ │ │
27
+ │ │ HTTP POST /register │
28
+ │ ▼ │
29
+ │ ┌─────────────────────────────────────────────────────────────────────────┐ │
30
+ │ │ Matching Client (internal/lib/matching) │ │
31
+ │ └─────────────────────────────────────────────────────────────────────────┘ │
32
+ └────────────────────────────────────┼─────────────────────────────────────────┘
33
+
34
+
35
+ ┌──────────────────────────────────────────────────────────────────────────────┐
36
+ │ EMBEDDING SERVICE (Python/FastAPI) │
37
+ │ │
38
+ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
39
+ │ │ ML Model │ │ In-Memory │ │ API Endpoints │ │
40
+ │ │ (Transformers) │───▶│ Store │◄───│ /register │ │
41
+ │ │ │ │ /match-text │ │ /index/bulk │ │
42
+ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
43
+ └──────────────────────────────────────────────────────────────────────────────┘
44
+ ```
45
+
46
+ ## Что индексируем?
47
+
48
+ **Индексируем ОБА типа сущностей:**
49
+
50
+ | Сущность | Зачем индексировать |
51
+ |----------|---------------------|
52
+ | **Лиды** | Чтобы находить подходящие объекты ДЛЯ лида |
53
+ | **Объекты** | Чтобы находить заинтересованных покупателей (лидов) ДЛЯ объекта |
54
+
55
+ ### Сценарии использования:
56
+
57
+ 1. **Риелтор создал лид** → система показывает "Рекомендуемые объекты" (топ-10 похожих)
58
+ 2. **Риелтор добавил объект** → система показывает "Потенциальные покупатели" (топ-10 лидов)
59
+ 3. **Покупатель ищет квартиру** → видит релевантные предложения
60
+
61
+ ---
62
+
63
+ ## Детальный Flow: Создание ЛИДА
64
+
65
+ ### Шаг 1: Frontend — заполнение формы
66
+
67
+ ```
68
+ Пользователь заполняет форму:
69
+ - Название: "Ищу 3-комнатную квартиру"
70
+ - Описание: "В центре города, рядом с метро, бюджет до 15 млн"
71
+ - Бюджет: 10 000 000 - 15 000 000 ₽
72
+ - Город: Москва
73
+ - Район: Центральный
74
+ ```
75
+
76
+ ### Шаг 2: Frontend → Go Backend
77
+
78
+ ```http
79
+ POST /v1/leads
80
+ Authorization: Bearer <token>
81
+ Content-Type: application/json
82
+
83
+ {
84
+ "title": "Ищу 3-комнатную квартиру",
85
+ "description": "В центре города, рядом с метро, бюджет до 15 млн",
86
+ "budget_min": 10000000,
87
+ "budget_max": 15000000,
88
+ "city": "Москва",
89
+ "district": "Центральный"
90
+ }
91
+ ```
92
+
93
+ ### Шаг 3: Go Backend — обработка
94
+
95
+ ```go
96
+ // internal/services/lead/service.go
97
+
98
+ func (s *LeadService) CreateLead(ctx context.Context, lead *domain.Lead) (*domain.Lead, error) {
99
+ // 1. Валидация
100
+ if err := s.validate(lead); err != nil {
101
+ return nil, err
102
+ }
103
+
104
+ // 2. Сохранение в PostgreSQL
105
+ created, err := s.repo.Create(ctx, lead)
106
+ if err != nil {
107
+ return nil, err
108
+ }
109
+
110
+ // 3. ИНДЕКСАЦИЯ в Matching Service (асинхронно, чтобы не блокировать ответ)
111
+ if s.matchingClient != nil && s.cfg.Matching.Enabled {
112
+ go s.indexLead(created)
113
+ }
114
+
115
+ // 4. Возврат результата
116
+ return created, nil
117
+ }
118
+
119
+ func (s *LeadService) indexLead(lead *domain.Lead) {
120
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
121
+ defer cancel()
122
+
123
+ // Формируем текст для эмбеддинга
124
+ text := fmt.Sprintf("%s. %s. Бюджет: %d-%d руб. Город: %s",
125
+ lead.Title,
126
+ lead.Description,
127
+ lead.BudgetMin,
128
+ lead.BudgetMax,
129
+ lead.City,
130
+ )
131
+
132
+ // Метаданные для фильтрации
133
+ metadata := map[string]interface{}{
134
+ "budget_min": lead.BudgetMin,
135
+ "budget_max": lead.BudgetMax,
136
+ "city": lead.City,
137
+ "user_id": lead.UserID,
138
+ }
139
+
140
+ err := s.matchingClient.RegisterLead(ctx, lead.ID.String(), text, metadata)
141
+ if err != nil {
142
+ s.log.Error("Failed to index lead", "lead_id", lead.ID, "error", err)
143
+ }
144
+ }
145
+ ```
146
+
147
+ ### Шаг 4: Matching Service — индексация
148
+
149
+ ```
150
+ POST /register
151
+ {
152
+ "entity_id": "550e8400-e29b-41d4-a716-446655440000",
153
+ "entity_type": "leads",
154
+ "text": "Ищу 3-комнатную квартиру. В центре города, рядом с метро. Бюджет: 10000000-15000000 руб. Город: Москва",
155
+ "metadata": {
156
+ "budget_min": 10000000,
157
+ "budget_max": 15000000,
158
+ "city": "Москва",
159
+ "user_id": "user-123"
160
+ }
161
+ }
162
+ ```
163
+
164
+ **Что происходит внутри:**
165
+ 1. ML-модель генерирует эмбеддинг (вектор 384 измерений)
166
+ 2. Вектор сохраняется в in-memory хранилище
167
+ 3. Возвращается `{"success": true}`
168
+
169
+ ---
170
+
171
+ ## Детальный Flow: Создание ОБЪЕКТА
172
+
173
+ ### Шаг 1: Frontend — заполнение формы
174
+
175
+ ```
176
+ Пользователь заполняет форму:
177
+ - Название: "3-комнатная квартира в ЖК Пресня"
178
+ - Описание: "85 кв.м, евроремонт, вид на парк, 5 минут до метро"
179
+ - Цена: 14 500 000 ₽
180
+ - Площадь: 85 кв.м
181
+ - Комнат: 3
182
+ - Город: Москва
183
+ ```
184
+
185
+ ### Шаг 2: Frontend → Go Backend
186
+
187
+ ```http
188
+ POST /v1/properties
189
+ Authorization: Bearer <token>
190
+ Content-Type: application/json
191
+
192
+ {
193
+ "title": "3-комнатная квартира в ЖК Пресня",
194
+ "description": "85 кв.м, евроремонт, вид на парк, 5 минут до метро",
195
+ "price": 14500000,
196
+ "area": 85,
197
+ "rooms": 3,
198
+ "city": "Москва"
199
+ }
200
+ ```
201
+
202
+ ### Шаг 3: Go Backend — обработка
203
+
204
+ ```go
205
+ // internal/services/property/service.go
206
+
207
+ func (s *PropertyService) CreateProperty(ctx context.Context, prop *domain.Property) (*domain.Property, error) {
208
+ // 1. Валидация
209
+ if err := s.validate(prop); err != nil {
210
+ return nil, err
211
+ }
212
+
213
+ // 2. Сохранение в PostgreSQL
214
+ created, err := s.repo.Create(ctx, prop)
215
+ if err != nil {
216
+ return nil, err
217
+ }
218
+
219
+ // 3. ИНДЕКСАЦИЯ в Matching Service
220
+ if s.matchingClient != nil && s.cfg.Matching.Enabled {
221
+ go s.indexProperty(created)
222
+ }
223
+
224
+ return created, nil
225
+ }
226
+
227
+ func (s *PropertyService) indexProperty(prop *domain.Property) {
228
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
229
+ defer cancel()
230
+
231
+ text := fmt.Sprintf("%s. %s. Цена: %d руб. Площадь: %d кв.м. Комнат: %d. Город: %s",
232
+ prop.Title,
233
+ prop.Description,
234
+ prop.Price,
235
+ prop.Area,
236
+ prop.Rooms,
237
+ prop.City,
238
+ )
239
+
240
+ metadata := map[string]interface{}{
241
+ "price": prop.Price,
242
+ "area": prop.Area,
243
+ "rooms": prop.Rooms,
244
+ "city": prop.City,
245
+ "user_id": prop.UserID,
246
+ }
247
+
248
+ err := s.matchingClient.RegisterProperty(ctx, prop.ID.String(), text, metadata)
249
+ if err != nil {
250
+ s.log.Error("Failed to index property", "property_id", prop.ID, "error", err)
251
+ }
252
+ }
253
+ ```
254
+
255
+ ---
256
+
257
+ ## Детальный Flow: ПОИСК МАТЧЕЙ
258
+
259
+ ### Сценарий: Найти объекты для лида (базовый поиск)
260
+
261
+ ```
262
+ Frontend: GET /v1/leads/{lead_id}/matches?top_k=10
263
+ ```
264
+
265
+ ```go
266
+ // internal/services/lead/service.go
267
+
268
+ func (s *LeadService) FindMatches(ctx context.Context, leadID uuid.UUID, topK int) ([]MatchedProperty, error) {
269
+ // 1. Получаем лид из БД
270
+ lead, err := s.repo.GetByID(ctx, leadID)
271
+ if err != nil {
272
+ return nil, err
273
+ }
274
+
275
+ // 2. Формируем текст для поиска
276
+ text := fmt.Sprintf("%s. %s. Бюджет: %d-%d руб. Город: %s",
277
+ lead.Title, lead.Description, lead.BudgetMin, lead.BudgetMax, lead.City)
278
+
279
+ // 3. Вызываем Matching Service
280
+ matches, err := s.matchingClient.FindPropertiesForLead(ctx, text, topK, 0.1)
281
+ if err != nil {
282
+ return nil, err
283
+ }
284
+
285
+ // 4. Загружаем полные данные объектов из PostgreSQL
286
+ propertyIDs := make([]uuid.UUID, len(matches))
287
+ for i, m := range matches {
288
+ propertyIDs[i] = uuid.MustParse(m.EntityID)
289
+ }
290
+
291
+ properties, err := s.propertyRepo.GetByIDs(ctx, propertyIDs)
292
+ if err != nil {
293
+ return nil, err
294
+ }
295
+
296
+ // 5. Объединяем с similarity score
297
+ result := make([]MatchedProperty, len(matches))
298
+ for i, m := range matches {
299
+ result[i] = MatchedProperty{
300
+ Property: properties[m.EntityID],
301
+ Similarity: m.Similarity,
302
+ }
303
+ }
304
+
305
+ return result, nil
306
+ }
307
+ ```
308
+
309
+ ### Сценарий: Взвешенный поиск с приоритетами (НОВОЕ)
310
+
311
+ Для более точного матчинга используйте `/match-weighted`:
312
+
313
+ ```go
314
+ // internal/services/lead/service.go
315
+
316
+ func (s *LeadService) FindWeightedMatches(ctx context.Context, leadID uuid.UUID, opts MatchOptions) ([]WeightedMatchedProperty, error) {
317
+ lead, err := s.repo.GetByID(ctx, leadID)
318
+ if err != nil {
319
+ return nil, err
320
+ }
321
+
322
+ // Формируем запрос с весами и фильтрами
323
+ request := matching.WeightedMatchRequest{
324
+ Text: fmt.Sprintf("%s. %s", lead.Title, lead.Description),
325
+ EntityType: "properties",
326
+ TopK: opts.TopK,
327
+ Weights: matching.ParameterWeights{
328
+ Price: opts.PriceWeight, // 0.35 - цена важнее
329
+ District: opts.DistrictWeight, // 0.30 - район важен
330
+ Rooms: opts.RoomsWeight, // 0.20 - комнаты
331
+ Area: opts.AreaWeight, // 0.05 - площадь менее важна
332
+ Semantic: opts.SemanticWeight, // 0.10 - семантика
333
+ },
334
+ HardFilters: matching.HardFilters{
335
+ Price: &matching.PriceFilter{
336
+ MaxPrice: float64(lead.BudgetMax) * 1.2, // +20% допуск
337
+ },
338
+ Districts: opts.AllowedDistricts,
339
+ Rooms: opts.AllowedRooms,
340
+ },
341
+ SoftCriteria: matching.SoftCriteria{
342
+ TargetPrice: float64(lead.BudgetMax),
343
+ TargetRooms: lead.Rooms,
344
+ TargetDistrict: lead.District,
345
+ },
346
+ }
347
+
348
+ matches, err := s.matchingClient.FindWeightedMatches(ctx, request)
349
+ if err != nil {
350
+ return nil, err
351
+ }
352
+
353
+ // ... загрузка полных данных из БД
354
+ return result, nil
355
+ }
356
+ ```
357
+
358
+ ### Пример вызова из Frontend:
359
+
360
+ ```http
361
+ POST /v1/leads/{lead_id}/weighted-matches
362
+ Content-Type: application/json
363
+
364
+ {
365
+ "top_k": 10,
366
+ "preset": "budget_first", // или свои веса
367
+ "weights": {
368
+ "price": 0.40,
369
+ "district": 0.25,
370
+ "rooms": 0.20,
371
+ "area": 0.05,
372
+ "semantic": 0.10
373
+ },
374
+ "hard_filters": {
375
+ "max_price": 12000000,
376
+ "districts": ["Центр", "Арбат"]
377
+ }
378
+ }
379
+ ```
380
+ ```
381
+
382
+ ### Ответ клиенту
383
+
384
+ ```json
385
+ {
386
+ "matches": [
387
+ {
388
+ "property": {
389
+ "id": "prop-123",
390
+ "title": "3-комнатная квартира в ЖК Пресня",
391
+ "price": 14500000,
392
+ "rooms": 3,
393
+ "area": 85
394
+ },
395
+ "similarity": 0.89
396
+ },
397
+ {
398
+ "property": {
399
+ "id": "prop-456",
400
+ "title": "3-комнатная квартира у метро Маяковская",
401
+ "price": 13000000,
402
+ "rooms": 3,
403
+ "area": 78
404
+ },
405
+ "similarity": 0.82
406
+ }
407
+ ]
408
+ }
409
+ ```
410
+
411
+ ---
412
+
413
+ ## Полный список операций с индексом
414
+
415
+ | Операция | Когда вызывать | Endpoint |
416
+ |----------|----------------|----------|
417
+ | Индексация лида | При создании/обновлении лида | `POST /register` |
418
+ | Индексация объекта | При создании/обновлении объекта | `POST /register` |
419
+ | Удаление из индекса | При удалении лида/объекта | `DELETE /register` |
420
+ | Массовая индексация | При миграции/переиндексации | `POST /index/bulk` |
421
+ | Очистка индекса | При сбросе данных | `DELETE /index/{type}` |
422
+ | Базовый поиск матчей | По запросу пользователя | `POST /match-text` |
423
+ | **Взвешенный поиск** | С настройкой приоритетов | `POST /match-weighted` |
424
+ | **Получить пресеты** | Для UI выбора режима поиска | `GET /weights/presets` |
425
+
426
+ ---
427
+
428
+ ## Обновление и удаление
429
+
430
+ ### При обновлении лида/объекта
431
+
432
+ ```go
433
+ func (s *LeadService) UpdateLead(ctx context.Context, lead *domain.Lead) error {
434
+ // 1. Обновляем в БД
435
+ err := s.repo.Update(ctx, lead)
436
+ if err != nil {
437
+ return err
438
+ }
439
+
440
+ // 2. Переиндексируем (RegisterLead перезапишет старый эмбеддинг)
441
+ go s.indexLead(lead)
442
+
443
+ return nil
444
+ }
445
+ ```
446
+
447
+ ### При удалении
448
+
449
+ ```go
450
+ func (s *LeadService) DeleteLead(ctx context.Context, leadID uuid.UUID) error {
451
+ // 1. Удаляем из БД
452
+ err := s.repo.Delete(ctx, leadID)
453
+ if err != nil {
454
+ return err
455
+ }
456
+
457
+ // 2. Удаляем из индекса
458
+ go func() {
459
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
460
+ defer cancel()
461
+ s.matchingClient.DeleteLead(ctx, leadID.String())
462
+ }()
463
+
464
+ return nil
465
+ }
466
+ ```
467
+
468
+ ---
469
+
470
+ ## Первоначальная миграция данных
471
+
472
+ Если в БД уже есть данные, нужно их проиндексировать:
473
+
474
+ ```go
475
+ // cmd/migrate_to_matching/main.go
476
+
477
+ func main() {
478
+ // Инициализация
479
+ cfg := config.MustLoad()
480
+ db := setupDB(cfg)
481
+ matchingClient := matching.NewClient(cfg.Matching.URL)
482
+
483
+ // Проверяем доступность сервиса
484
+ _, err := matchingClient.Health(context.Background())
485
+ if err != nil {
486
+ log.Fatal("Matching service unavailable:", err)
487
+ }
488
+
489
+ // Индексируем лиды
490
+ indexLeads(db, matchingClient)
491
+
492
+ // Индексируем объекты
493
+ indexProperties(db, matchingClient)
494
+ }
495
+
496
+ func indexLeads(db *sql.DB, client *matching.Client) {
497
+ rows, _ := db.Query("SELECT id, title, description, budget_min, budget_max, city FROM leads")
498
+ defer rows.Close()
499
+
500
+ var items []matching.BulkIndexItem
501
+ for rows.Next() {
502
+ var id, title, description, city string
503
+ var budgetMin, budgetMax int64
504
+ rows.Scan(&id, &title, &description, &budgetMin, &budgetMax, &city)
505
+
506
+ items = append(items, matching.BulkIndexItem{
507
+ EntityID: id,
508
+ Text: fmt.Sprintf("%s. %s. Бюджет: %d-%d. Город: %s", title, description, budgetMin, budgetMax, city),
509
+ Metadata: map[string]interface{}{
510
+ "budget_min": budgetMin,
511
+ "budget_max": budgetMax,
512
+ "city": city,
513
+ },
514
+ })
515
+ }
516
+
517
+ // Массовая индексация
518
+ resp, err := client.BulkIndexLeads(context.Background(), items, true)
519
+ if err != nil {
520
+ log.Fatal(err)
521
+ }
522
+
523
+ log.Printf("Indexed %d leads, failed: %d", resp.Indexed, resp.Failed)
524
+ }
525
+ ```
526
+
527
+ ---
528
+
529
+ ## Переменные окружения
530
+
531
+ ```bash
532
+ # Go Backend
533
+ MATCHING_SERVICE_URL=https://matching-service.onrender.com
534
+ MATCHING_ENABLED=true
535
+ MATCHING_TOP_K=10
536
+ MATCHING_MIN_SIMILARITY=0.1
537
+
538
+ # Embedding Service
539
+ EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
540
+ ```
541
+
542
+ ---
543
+
544
+ ## Чек-лист для бэкенд-разработчика
545
+
546
+ - [ ] Добавить `matchingClient` в сервисы Lead и Property
547
+ - [ ] Добавить вызов `RegisterLead` в `CreateLead`
548
+ - [ ] Добавить вызов `RegisterProperty` в `CreateProperty`
549
+ - [ ] Добавить вызов `RegisterLead` в `UpdateLead`
550
+ - [ ] Добавить вызов `RegisterProperty` в `UpdateProperty`
551
+ - [ ] Добавить вызов `DeleteLead` в `DeleteLead`
552
+ - [ ] Добавить вызов `DeleteProperty` в `DeleteProperty`
553
+ - [ ] Создать endpoint `GET /v1/leads/{id}/matches`
554
+ - [ ] Создать endpoint `GET /v1/properties/{id}/matches`
555
+ - [ ] Написать скрипт миграции существующих данных
556
+ - [ ] Задеплоить Embedding Service на Render
557
+ - [ ] Добавить переменные окружения на Render
558
+
build.sh ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ echo "Build completed successfully!"
3
+
4
+ python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')"
5
+ echo "Pre-downloading embedding model to reduce cold start time..."
6
+
7
+ pip install --no-cache-dir -r requirements.txt
8
+ cd embedding-service
9
+ echo "Installing Python dependencies..."
10
+
11
+ # Render build script
12
+
embedding-service/Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy application
10
+ COPY main.py .
11
+
12
+ # Pre-download model during build (faster cold starts)
13
+ RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')"
14
+
15
+ # Expose port
16
+ EXPOSE 8082
17
+
18
+ # Run with uvicorn
19
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"]
20
+
embedding-service/main.py ADDED
@@ -0,0 +1,1126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-L12-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
+ model = SentenceTransformer(MODEL_NAME)
42
+ print(f"Model loaded successfully. Embedding dimensions: {model.get_sentence_embedding_dimension()}")
43
+ yield
44
+ # Cleanup
45
+ model = None
46
+
47
+
48
+ app = FastAPI(
49
+ title="Embedding Service",
50
+ description="Сервис для генерации эмбеддингов текста",
51
+ version="1.0.0",
52
+ lifespan=lifespan
53
+ )
54
+
55
+ # CORS для локальной разработки
56
+ app.add_middleware(
57
+ CORSMiddleware,
58
+ allow_origins=["*"],
59
+ allow_credentials=True,
60
+ allow_methods=["*"],
61
+ allow_headers=["*"],
62
+ )
63
+
64
+
65
+ # --- Pydantic Models ---
66
+
67
+ class EmbedRequest(BaseModel):
68
+ """Запрос на генерацию эмбеддинга для одного текста."""
69
+ text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
70
+
71
+
72
+ class EmbedResponse(BaseModel):
73
+ """Ответ с эмбеддингом."""
74
+ embedding: List[float] = Field(..., description="Векторное представление текста")
75
+ model: str = Field(..., description="Название используемой модели")
76
+ dimensions: int = Field(..., description="Размерность вектора")
77
+
78
+
79
+ class EmbedBatchRequest(BaseModel):
80
+ """Запрос на пакетную генерацию эмбеддингов."""
81
+ texts: List[str] = Field(..., min_length=1, description="Список текстов")
82
+
83
+
84
+ class EmbedBatchResponse(BaseModel):
85
+ """Ответ с пакетными эмбеддингами."""
86
+ embeddings: List[List[float]] = Field(..., description="Список векторных представлений")
87
+ model: str = Field(..., description="Название используемой модели")
88
+ dimensions: int = Field(..., description="Размерность векторов")
89
+
90
+
91
+ class SimilarityRequest(BaseModel):
92
+ """Запрос на вычисление косинусной близости."""
93
+ embedding1: List[float] = Field(..., description="Первый эмбеддинг")
94
+ embedding2: List[float] = Field(..., description="Второй эмбеддинг")
95
+
96
+
97
+ class SimilarityResponse(BaseModel):
98
+ """Ответ с косинусной близостью."""
99
+ similarity: float = Field(..., description="Косинусная близость от -1 до 1")
100
+
101
+
102
+ class HealthResponse(BaseModel):
103
+ """Ответ на health check."""
104
+ status: str
105
+ model: str
106
+ dimensions: int
107
+
108
+
109
+ # --- Match Models ---
110
+
111
+ class MatchRequest(BaseModel):
112
+ """Запрос на поиск похожих объектов по эмбеддингу."""
113
+ embedding: List[float] = Field(..., description="Эмбеддинг для поиска")
114
+ entity_type: str = Field(default="properties", description="Тип сущности для поиска (leads, properties)")
115
+ top_k: int = Field(default=5, ge=1, le=100, description="Количество результатов")
116
+ min_similarity: float = Field(default=0.0, ge=-1.0, le=1.0, description="Минимальный порог схожести")
117
+
118
+
119
+ class MatchTextRequest(BaseModel):
120
+ """Запрос на поиск похожих объектов по тексту."""
121
+ text: str = Field(..., min_length=1, description="Текст для поиска")
122
+ entity_type: str = Field(default="properties", description="Тип сущности для поиска (leads, properties)")
123
+ top_k: int = Field(default=5, ge=1, le=100, description="Количество результатов")
124
+ min_similarity: float = Field(default=0.0, ge=-1.0, le=1.0, description="Минимальный порог схожести")
125
+
126
+
127
+ class MatchResult(BaseModel):
128
+ """Результат матчинга."""
129
+ entity_id: str = Field(..., description="ID найденного объекта")
130
+ similarity: float = Field(..., description="Косинусная близость (0-1)")
131
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
132
+
133
+
134
+ class MatchResponse(BaseModel):
135
+ """Ответ с результатами матчинга."""
136
+ matches: List[MatchResult] = Field(..., description="Найденные объекты")
137
+ total_searched: int = Field(..., description="Количество проверенных объектов")
138
+
139
+
140
+ class RegisterEmbeddingRequest(BaseModel):
141
+ """Запрос на регистрацию эмбеддинга объекта."""
142
+ entity_id: str = Field(..., description="ID объекта")
143
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
144
+ text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
145
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
146
+
147
+
148
+ class RegisterEmbeddingFromVectorRequest(BaseModel):
149
+ """Запрос на регистрацию готового эмбеддинга."""
150
+ entity_id: str = Field(..., description="ID объекта")
151
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
152
+ embedding: List[float] = Field(..., description="Готовый эмбеддинг")
153
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные объекта")
154
+
155
+
156
+ class RegisterResponse(BaseModel):
157
+ """Ответ на регистрацию эмбеддинга."""
158
+ success: bool
159
+ entity_id: str
160
+ entity_type: str
161
+
162
+
163
+ class DeleteEmbeddingRequest(BaseModel):
164
+ """Запрос на удаление эмбеддинга."""
165
+ entity_id: str = Field(..., description="ID объекта")
166
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
167
+
168
+
169
+ class StoreStatsResponse(BaseModel):
170
+ """Статистика хранилища эмбеддингов."""
171
+ leads_count: int
172
+ properties_count: int
173
+ total_count: int
174
+
175
+
176
+ # --- Bulk Index Models ---
177
+
178
+ class BulkIndexItem(BaseModel):
179
+ """Один элемент для массовой индексации."""
180
+ entity_id: str = Field(..., description="ID объекта")
181
+ text: str = Field(..., min_length=1, description="Текст для генерации эмбеддинга")
182
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Дополнительные данные")
183
+
184
+
185
+ class BulkIndexRequest(BaseModel):
186
+ """Запрос на массовую индексацию."""
187
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
188
+ items: List[BulkIndexItem] = Field(..., description="Список объектов для индексации")
189
+ clear_existing: bool = Field(default=False, description="Очистить существующие данные перед индексацией")
190
+
191
+
192
+ class BulkIndexResult(BaseModel):
193
+ """Результат индексации одного элемента."""
194
+ entity_id: str
195
+ success: bool
196
+ error: Optional[str] = None
197
+
198
+
199
+ class BulkIndexResponse(BaseModel):
200
+ """Ответ на массовую индексацию."""
201
+ total: int = Field(..., description="Всего элементов в запросе")
202
+ indexed: int = Field(..., description="Успешно проиндексировано")
203
+ failed: int = Field(..., description="Ошибок")
204
+ results: List[BulkIndexResult] = Field(..., description="Детали по каждому элементу")
205
+
206
+
207
+ class ReindexFromDBRequest(BaseModel):
208
+ """Запрос на переиндексацию из внешнего источника (вызывается Go Backend)."""
209
+ entity_type: str = Field(..., description="Тип сущности (leads, properties)")
210
+ db_url: Optional[str] = Field(default=None, description="URL базы данных (опционально)")
211
+
212
+
213
+ # --- Weighted Matching Models ---
214
+
215
+ class ParameterWeights(BaseModel):
216
+ """Веса для различных параметров матчинга."""
217
+ price: float = Field(default=0.30, ge=0.0, le=1.0, description="Вес цены (по умолчанию 0.30)")
218
+ district: float = Field(default=0.25, ge=0.0, le=1.0, description="Вес района (по умолчанию 0.25)")
219
+ rooms: float = Field(default=0.20, ge=0.0, le=1.0, description="Вес ��оличества комнат (по умолчанию 0.20)")
220
+ area: float = Field(default=0.10, ge=0.0, le=1.0, description="Вес площади (по умолчанию 0.10)")
221
+ semantic: float = Field(default=0.15, ge=0.0, le=1.0, description="Вес семантической близости (по умолчанию 0.15)")
222
+
223
+
224
+ class PriceFilter(BaseModel):
225
+ """Фильтр по цене."""
226
+ min_price: Optional[float] = Field(default=None, description="Минимальная цена")
227
+ max_price: Optional[float] = Field(default=None, description="Максимальная цена")
228
+ tolerance_percent: float = Field(default=10.0, description="Допустимое отклонение в % (для мягкого фильтра)")
229
+
230
+
231
+ class HardFilters(BaseModel):
232
+ """Жёсткие фильтры (объекты не прошедшие фильтр исключаются)."""
233
+ price: Optional[PriceFilter] = Field(default=None, description="Фильтр по цене")
234
+ districts: Optional[List[str]] = Field(default=None, description="Список допустимых районов")
235
+ rooms: Optional[List[int]] = Field(default=None, description="Список допустимого кол-ва комнат")
236
+ min_area: Optional[float] = Field(default=None, description="Минимальная площадь")
237
+ max_area: Optional[float] = Field(default=None, description="Максимальная площадь")
238
+
239
+
240
+ class SoftCriteria(BaseModel):
241
+ """Мягкие критерии для ранжирования (влияют на score, но не исключают)."""
242
+ target_price: Optional[float] = Field(default=None, description="Желаемая цена")
243
+ target_district: Optional[str] = Field(default=None, description="Предпочтительный район")
244
+ target_rooms: Optional[int] = Field(default=None, description="Желаемое кол-во комнат")
245
+ target_area: Optional[float] = Field(default=None, description="Желаемая площадь")
246
+ metro_distance_km: Optional[float] = Field(default=None, description="Желаемое расстояние до метро (км)")
247
+ preferred_districts: Optional[List[str]] = Field(default=None, description="Список предпочтительных районов")
248
+
249
+
250
+ class WeightedMatchRequest(BaseModel):
251
+ """Запрос на взвешенный матчинга с приоритетами."""
252
+ text: str = Field(..., min_length=1, description="Текст запроса (описание требований)")
253
+ entity_type: str = Field(default="properties", description="Тип сущности для поиска")
254
+ top_k: int = Field(default=10, ge=1, le=100, description="Количество результатов")
255
+
256
+ # Настройка весов
257
+ weights: Optional[ParameterWeights] = Field(default=None, description="Веса параметров")
258
+
259
+ # Фильтры
260
+ hard_filters: Optional[HardFilters] = Field(default=None, description="Жёсткие фильтры")
261
+ soft_criteria: Optional[SoftCriteria] = Field(default=None, description="Мягкие критерии")
262
+
263
+ # Минимальный порог
264
+ min_total_score: float = Field(default=0.0, ge=0.0, le=1.0, description="Минимальный общий score")
265
+
266
+
267
+ class WeightedMatchResult(BaseModel):
268
+ """Результат взвешенного матчинга с детализацией."""
269
+ entity_id: str
270
+ total_score: float = Field(..., description="Общий взвешенный score (0-1)")
271
+
272
+ # Детализация по компонентам
273
+ price_score: float = Field(default=0.0, description="Score по цене (0-1)")
274
+ district_score: float = Field(default=0.0, description="Score по району (0-1)")
275
+ rooms_score: float = Field(default=0.0, description="Score по комнатам (0-1)")
276
+ area_score: float = Field(default=0.0, description="Score по площади (0-1)")
277
+ semantic_score: float = Field(default=0.0, description="Семантический score (0-1)")
278
+
279
+ metadata: Optional[Dict[str, Any]] = None
280
+ match_explanation: Optional[str] = Field(default=None, description="Объяснение почему объект подходит")
281
+
282
+
283
+ class WeightedMatchResponse(BaseModel):
284
+ """Ответ взвешенного матчинга."""
285
+ matches: List[WeightedMatchResult]
286
+ total_searched: int
287
+ filtered_out: int = Field(..., description="Отфильтровано жёсткими фильтрами")
288
+ weights_used: ParameterWeights
289
+
290
+
291
+ # --- Endpoints ---
292
+
293
+ @app.get("/health", response_model=HealthResponse)
294
+ async def health_check():
295
+ """Проверка здоровья сервиса."""
296
+ if model is None:
297
+ raise HTTPException(status_code=503, detail="Model not loaded")
298
+ return HealthResponse(
299
+ status="healthy",
300
+ model=MODEL_NAME,
301
+ dimensions=model.get_sentence_embedding_dimension()
302
+ )
303
+
304
+
305
+ @app.post("/embed", response_model=EmbedResponse)
306
+ async def embed_text(request: EmbedRequest):
307
+ """
308
+ Генерация эмбеддинга для одного текста.
309
+
310
+ Используется для получения векторного представления лида или объекта недвижимости.
311
+ """
312
+ if model is None:
313
+ raise HTTPException(status_code=503, detail="Model not loaded")
314
+
315
+ try:
316
+ embedding = model.encode(request.text, convert_to_numpy=True)
317
+ return EmbedResponse(
318
+ embedding=embedding.tolist(),
319
+ model=MODEL_NAME,
320
+ dimensions=len(embedding)
321
+ )
322
+ except Exception as e:
323
+ raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}")
324
+
325
+
326
+ @app.post("/embed-batch", response_model=EmbedBatchResponse)
327
+ async def embed_batch(request: EmbedBatchRequest):
328
+ """
329
+ Пакетная генерация эмбеддингов.
330
+
331
+ Эффективнее для обработки нескольких текстов за раз.
332
+ """
333
+ if model is None:
334
+ raise HTTPException(status_code=503, detail="Model not loaded")
335
+
336
+ try:
337
+ embeddings = model.encode(request.texts, convert_to_numpy=True)
338
+ return EmbedBatchResponse(
339
+ embeddings=[emb.tolist() for emb in embeddings],
340
+ model=MODEL_NAME,
341
+ dimensions=embeddings.shape[1] if len(embeddings.shape) > 1 else len(embeddings)
342
+ )
343
+ except Exception as e:
344
+ raise HTTPException(status_code=500, detail=f"Batch embedding generation failed: {str(e)}")
345
+
346
+
347
+ @app.post("/similarity", response_model=SimilarityResponse)
348
+ async def compute_similarity(request: SimilarityRequest):
349
+ """
350
+ Вычисление косинусной близости между двумя эмбеддингами.
351
+
352
+ Возвращает значение от -1 (противоположные) до 1 (идентичные).
353
+ """
354
+ if len(request.embedding1) != len(request.embedding2):
355
+ raise HTTPException(
356
+ status_code=400,
357
+ detail="Embeddings must have the same dimensions"
358
+ )
359
+
360
+ try:
361
+ vec1 = np.array(request.embedding1)
362
+ vec2 = np.array(request.embedding2)
363
+
364
+ # Косинусная близость
365
+ similarity = np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
366
+
367
+ return SimilarityResponse(similarity=float(similarity))
368
+ except Exception as e:
369
+ raise HTTPException(status_code=500, detail=f"Similarity computation failed: {str(e)}")
370
+
371
+
372
+ @app.post("/prepare-text")
373
+ async def prepare_text_for_embedding(
374
+ title: str = "",
375
+ description: str = "",
376
+ requirement: dict = None
377
+ ):
378
+ """
379
+ Подготовка текста для генерации эмбеддинга.
380
+
381
+ Объединяет title, description и requirement в один текст для эмбеддинга.
382
+ """
383
+ parts = []
384
+
385
+ if title:
386
+ parts.append(f"Название: {title}")
387
+
388
+ if description:
389
+ parts.append(f"Описание: {description}")
390
+
391
+ if requirement:
392
+ req_parts = []
393
+ for key, value in requirement.items():
394
+ req_parts.append(f"{key}: {value}")
395
+ if req_parts:
396
+ parts.append(f"Требования: {', '.join(req_parts)}")
397
+
398
+ combined_text = ". ".join(parts)
399
+
400
+ return {"prepared_text": combined_text}
401
+
402
+
403
+ # --- Matching Endpoints ---
404
+
405
+ def _cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
406
+ """Вычисление косинусной близости между двумя векторами."""
407
+ norm1 = np.linalg.norm(vec1)
408
+ norm2 = np.linalg.norm(vec2)
409
+ if norm1 == 0 or norm2 == 0:
410
+ return 0.0
411
+ return float(np.dot(vec1, vec2) / (norm1 * norm2))
412
+
413
+
414
+ def _calculate_price_score(obj_price: Optional[float], target_price: Optional[float], tolerance_percent: float = 20.0) -> float:
415
+ """
416
+ Вычисление score по цене.
417
+
418
+ Если цена объекта в пределах допуска от целевой - высокий score.
419
+ Чем дальше - тем ниже score.
420
+ """
421
+ if obj_price is None or target_price is None:
422
+ return 0.5 # Нейтральный score если данных нет
423
+
424
+ if target_price == 0:
425
+ return 0.5
426
+
427
+ # Процентное отклонение
428
+ deviation_percent = abs(obj_price - target_price) / target_price * 100
429
+
430
+ if deviation_percent <= tolerance_percent:
431
+ # В пределах допуска - линейно от 1.0 до 0.7
432
+ return 1.0 - (deviation_percent / tolerance_percent) * 0.3
433
+ else:
434
+ # За пределами допуска - быстро падает
435
+ extra_deviation = deviation_percent - tolerance_percent
436
+ score = 0.7 - (extra_deviation / 100) * 0.7
437
+ return max(0.0, score)
438
+
439
+
440
+ def _calculate_district_score(
441
+ obj_district: Optional[str],
442
+ target_district: Optional[str],
443
+ preferred_districts: Optional[List[str]] = None
444
+ ) -> float:
445
+ """
446
+ Вычисление score по району.
447
+
448
+ Точное совпадение = 1.0
449
+ В списке предпочтительных = 0.7
450
+ Иначе = 0.3
451
+ """
452
+ if obj_district is None:
453
+ return 0.3
454
+
455
+ obj_district_lower = obj_district.lower().strip()
456
+
457
+ # Точное совпадение с целевым
458
+ if target_district and obj_district_lower == target_district.lower().strip():
459
+ return 1.0
460
+
461
+ # Проверяем в списке предпочтительных
462
+ if preferred_districts:
463
+ for pref in preferred_districts:
464
+ if obj_district_lower == pref.lower().strip():
465
+ return 0.7
466
+ # Частичное совпадение (например "Центральный" в "Центральный район")
467
+ if pref.lower() in obj_district_lower or obj_district_lower in pref.lower():
468
+ return 0.6
469
+
470
+ return 0.3
471
+
472
+
473
+ def _calculate_rooms_score(obj_rooms: Optional[int], target_rooms: Optional[int]) -> float:
474
+ """
475
+ Вычисление score по количеству комнат.
476
+
477
+ Точное совпадение = 1.0
478
+ ±1 комната = 0.6
479
+ ±2 комнаты = 0.3
480
+ Больше разницы = 0.1
481
+ """
482
+ if obj_rooms is None or target_rooms is None:
483
+ return 0.5
484
+
485
+ diff = abs(obj_rooms - target_rooms)
486
+
487
+ if diff == 0:
488
+ return 1.0
489
+ elif diff == 1:
490
+ return 0.6
491
+ elif diff == 2:
492
+ return 0.3
493
+ else:
494
+ return 0.1
495
+
496
+
497
+ def _calculate_area_score(obj_area: Optional[float], target_area: Optional[float], tolerance_percent: float = 15.0) -> float:
498
+ """
499
+ Вычисление score по площади.
500
+
501
+ Аналогично цене, но с меньшим допуском.
502
+ """
503
+ if obj_area is None or target_area is None:
504
+ return 0.5
505
+
506
+ if target_area == 0:
507
+ return 0.5
508
+
509
+ deviation_percent = abs(obj_area - target_area) / target_area * 100
510
+
511
+ if deviation_percent <= tolerance_percent:
512
+ return 1.0 - (deviation_percent / tolerance_percent) * 0.3
513
+ else:
514
+ extra_deviation = deviation_percent - tolerance_percent
515
+ score = 0.7 - (extra_deviation / 50) * 0.7
516
+ return max(0.0, score)
517
+
518
+
519
+ def _passes_hard_filters(metadata: Dict[str, Any], filters: Optional[HardFilters]) -> bool:
520
+ """Проверка прохождения жёстких фильтров."""
521
+ if filters is None:
522
+ return True
523
+
524
+ # Фильтр по цене
525
+ if filters.price:
526
+ obj_price = metadata.get("price")
527
+ if obj_price is not None:
528
+ if filters.price.min_price and obj_price < filters.price.min_price:
529
+ return False
530
+ if filters.price.max_price and obj_price > filters.price.max_price:
531
+ return False
532
+
533
+ # Фильтр по районам
534
+ if filters.districts:
535
+ obj_district = metadata.get("district", "").lower().strip()
536
+ allowed = [d.lower().strip() for d in filters.districts]
537
+ if obj_district and obj_district not in allowed:
538
+ # Проверяем частичное совпадение
539
+ if not any(a in obj_district or obj_district in a for a in allowed):
540
+ return False
541
+
542
+ # Фильтр по комнатам
543
+ if filters.rooms:
544
+ obj_rooms = metadata.get("rooms")
545
+ if obj_rooms is not None and obj_rooms not in filters.rooms:
546
+ return False
547
+
548
+ # Фильтр по площади
549
+ obj_area = metadata.get("area")
550
+ if obj_area is not None:
551
+ if filters.min_area and obj_area < filters.min_area:
552
+ return False
553
+ if filters.max_area and obj_area > filters.max_area:
554
+ return False
555
+
556
+ return True
557
+
558
+
559
+ def _generate_match_explanation(
560
+ price_score: float,
561
+ district_score: float,
562
+ rooms_score: float,
563
+ area_score: float,
564
+ semantic_score: float,
565
+ metadata: Dict[str, Any]
566
+ ) -> str:
567
+ """Генерация человеко-читаемого объяснения матча."""
568
+ reasons = []
569
+
570
+ if price_score >= 0.7:
571
+ price = metadata.get("price")
572
+ if price:
573
+ reasons.append(f"цена {price:,.0f}₽ в бюджете")
574
+
575
+ if district_score >= 0.7:
576
+ district = metadata.get("district")
577
+ if district:
578
+ reasons.append(f"район '{district}' подходит")
579
+
580
+ if rooms_score >= 0.7:
581
+ rooms = metadata.get("rooms")
582
+ if rooms:
583
+ reasons.append(f"{rooms}-комн. как нужно")
584
+
585
+ if area_score >= 0.7:
586
+ area = metadata.get("area")
587
+ if area:
588
+ reasons.append(f"площадь {area}м² подходит")
589
+
590
+ if semantic_score >= 0.6:
591
+ reasons.append("описание похоже на запрос")
592
+
593
+ if not reasons:
594
+ return "Частичное совпадение по параметрам"
595
+
596
+ return "; ".join(reasons)
597
+
598
+
599
+ @app.post("/match", response_model=MatchResponse)
600
+ async def match_by_embedding(request: MatchRequest):
601
+ """
602
+ Поиск похожих объектов по эмбеддингу.
603
+
604
+ Возвращает top_k наиболее похожих объектов указанного типа.
605
+ """
606
+ if request.entity_type not in embedding_store:
607
+ raise HTTPException(
608
+ status_code=400,
609
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
610
+ )
611
+
612
+ store = embedding_store[request.entity_type]
613
+ if not store:
614
+ return MatchResponse(matches=[], total_searched=0)
615
+
616
+ query_vec = np.array(request.embedding)
617
+
618
+ # Вычисляем схожесть со всеми объектами
619
+ similarities = []
620
+ for entity_id, data in store.items():
621
+ stored_vec = np.array(data["embedding"])
622
+ similarity = _cosine_similarity(query_vec, stored_vec)
623
+ if similarity >= request.min_similarity:
624
+ similarities.append((entity_id, similarity, data.get("metadata")))
625
+
626
+ # Сортируем по убыванию схожести и берем top_k
627
+ similarities.sort(key=lambda x: x[1], reverse=True)
628
+ top_matches = similarities[:request.top_k]
629
+
630
+ matches = [
631
+ MatchResult(entity_id=eid, similarity=sim, metadata=meta)
632
+ for eid, sim, meta in top_matches
633
+ ]
634
+
635
+ return MatchResponse(matches=matches, total_searched=len(store))
636
+
637
+
638
+ @app.post("/match-text", response_model=MatchResponse)
639
+ async def match_by_text(request: MatchTextRequest):
640
+ """
641
+ Поиск похожих объектов по тексту.
642
+
643
+ Генерирует эмбеддинг для текста и ищет похожие объекты.
644
+ """
645
+ if model is None:
646
+ raise HTTPException(status_code=503, detail="Model not loaded")
647
+
648
+ if request.entity_type not in embedding_store:
649
+ raise HTTPException(
650
+ status_code=400,
651
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
652
+ )
653
+
654
+ store = embedding_store[request.entity_type]
655
+ if not store:
656
+ return MatchResponse(matches=[], total_searched=0)
657
+
658
+ try:
659
+ # Генерируем эмбеддинг для текста запроса
660
+ query_embedding = model.encode(request.text, convert_to_numpy=True)
661
+ query_vec = np.array(query_embedding)
662
+
663
+ # Вычисляем схожесть со всеми объектами
664
+ similarities = []
665
+ for entity_id, data in store.items():
666
+ stored_vec = np.array(data["embedding"])
667
+ similarity = _cosine_similarity(query_vec, stored_vec)
668
+ if similarity >= request.min_similarity:
669
+ similarities.append((entity_id, similarity, data.get("metadata")))
670
+
671
+ # Сортируем по убыванию схожести и берем top_k
672
+ similarities.sort(key=lambda x: x[1], reverse=True)
673
+ top_matches = similarities[:request.top_k]
674
+
675
+ matches = [
676
+ MatchResult(entity_id=eid, similarity=sim, metadata=meta)
677
+ for eid, sim, meta in top_matches
678
+ ]
679
+
680
+ return MatchResponse(matches=matches, total_searched=len(store))
681
+ except Exception as e:
682
+ raise HTTPException(status_code=500, detail=f"Match by text failed: {str(e)}")
683
+
684
+
685
+ @app.post("/register", response_model=RegisterResponse)
686
+ async def register_embedding(request: RegisterEmbeddingRequest):
687
+ """
688
+ Регистрация объекта с автоматической генерацией эмбеддинга.
689
+
690
+ Используется для добавления лидов или объектов недвижимости в хранилище.
691
+ """
692
+ if model is None:
693
+ raise HTTPException(status_code=503, detail="Model not loaded")
694
+
695
+ if request.entity_type not in embedding_store:
696
+ raise HTTPException(
697
+ status_code=400,
698
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
699
+ )
700
+
701
+ try:
702
+ # Генерируем эмбеддинг
703
+ embedding = model.encode(request.text, convert_to_numpy=True)
704
+
705
+ # Сохраняем в хранилище
706
+ embedding_store[request.entity_type][request.entity_id] = {
707
+ "embedding": embedding.tolist(),
708
+ "metadata": request.metadata or {}
709
+ }
710
+
711
+ return RegisterResponse(
712
+ success=True,
713
+ entity_id=request.entity_id,
714
+ entity_type=request.entity_type
715
+ )
716
+ except Exception as e:
717
+ raise HTTPException(status_code=500, detail=f"Register embedding failed: {str(e)}")
718
+
719
+
720
+ @app.post("/register-vector", response_model=RegisterResponse)
721
+ async def register_embedding_from_vector(request: RegisterEmbeddingFromVectorRequest):
722
+ """
723
+ Регистрация объекта с готовым эмбед��ингом.
724
+
725
+ Используется когда эмбеддинг уже был сгенерирован ранее.
726
+ """
727
+ if request.entity_type not in embedding_store:
728
+ raise HTTPException(
729
+ status_code=400,
730
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
731
+ )
732
+
733
+ # Сохраняем в хранилище
734
+ embedding_store[request.entity_type][request.entity_id] = {
735
+ "embedding": request.embedding,
736
+ "metadata": request.metadata or {}
737
+ }
738
+
739
+ return RegisterResponse(
740
+ success=True,
741
+ entity_id=request.entity_id,
742
+ entity_type=request.entity_type
743
+ )
744
+
745
+
746
+ @app.delete("/register", response_model=RegisterResponse)
747
+ async def delete_embedding(request: DeleteEmbeddingRequest):
748
+ """
749
+ Удаление эмбеддинга объекта из хранилища.
750
+ """
751
+ if request.entity_type not in embedding_store:
752
+ raise HTTPException(
753
+ status_code=400,
754
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
755
+ )
756
+
757
+ store = embedding_store[request.entity_type]
758
+ if request.entity_id not in store:
759
+ raise HTTPException(
760
+ status_code=404,
761
+ detail=f"Entity {request.entity_id} not found in {request.entity_type}"
762
+ )
763
+
764
+ del store[request.entity_id]
765
+
766
+ return RegisterResponse(
767
+ success=True,
768
+ entity_id=request.entity_id,
769
+ entity_type=request.entity_type
770
+ )
771
+
772
+
773
+ @app.get("/store/stats", response_model=StoreStatsResponse)
774
+ async def get_store_stats():
775
+ """
776
+ Получение статистики хранилища эмбеддингов.
777
+ """
778
+ leads_count = len(embedding_store.get("leads", {}))
779
+ properties_count = len(embedding_store.get("properties", {}))
780
+
781
+ return StoreStatsResponse(
782
+ leads_count=leads_count,
783
+ properties_count=properties_count,
784
+ total_count=leads_count + properties_count
785
+ )
786
+
787
+
788
+ @app.get("/store/{entity_type}")
789
+ async def list_registered_entities(entity_type: str):
790
+ """
791
+ Список зарегистрированных объектов указанного типа.
792
+ """
793
+ if entity_type not in embedding_store:
794
+ raise HTTPException(
795
+ status_code=400,
796
+ detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
797
+ )
798
+
799
+ store = embedding_store[entity_type]
800
+ entities = [
801
+ {
802
+ "entity_id": eid,
803
+ "metadata": data.get("metadata", {}),
804
+ "embedding_dimensions": len(data.get("embedding", []))
805
+ }
806
+ for eid, data in store.items()
807
+ ]
808
+
809
+ return {"entity_type": entity_type, "count": len(entities), "entities": entities}
810
+
811
+
812
+ # --- Bulk Indexing Endpoints ---
813
+
814
+ @app.post("/index/bulk", response_model=BulkIndexResponse)
815
+ async def bulk_index(request: BulkIndexRequest):
816
+ """
817
+ Массовая индексация объектов.
818
+
819
+ Позволяет за один запрос проиндексировать множество лидов или объектов.
820
+ Используется для первоначальной загрузки данных или переиндексации.
821
+
822
+ Пример:
823
+ ```
824
+ POST /index/bulk
825
+ {
826
+ "entity_type": "properties",
827
+ "items": [
828
+ {"entity_id": "prop-1", "text": "3-комнатная квартира в центре", "metadata": {"price": 10000000}},
829
+ {"entity_id": "prop-2", "text": "Студия у метро", "metadata": {"price": 5000000}}
830
+ ],
831
+ "clear_existing": false
832
+ }
833
+ ```
834
+ """
835
+ if model is None:
836
+ raise HTTPException(status_code=503, detail="Model not loaded")
837
+
838
+ if request.entity_type not in embedding_store:
839
+ raise HTTPException(
840
+ status_code=400,
841
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
842
+ )
843
+
844
+ # Очистка если нужно
845
+ if request.clear_existing:
846
+ embedding_store[request.entity_type] = {}
847
+
848
+ results: List[BulkIndexResult] = []
849
+ indexed = 0
850
+ failed = 0
851
+
852
+ # Собираем все тексты для батчевой генерации эмбеддингов (быстрее)
853
+ texts = [item.text for item in request.items]
854
+
855
+ try:
856
+ # Генерируем все эмбеддинги за один вызов модели
857
+ embeddings = model.encode(texts, convert_to_numpy=True, show_progress_bar=True)
858
+
859
+ # Сохраняем каждый
860
+ for i, item in enumerate(request.items):
861
+ try:
862
+ embedding_store[request.entity_type][item.entity_id] = {
863
+ "embedding": embeddings[i].tolist(),
864
+ "metadata": item.metadata or {}
865
+ }
866
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
867
+ indexed += 1
868
+ except Exception as e:
869
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(e)))
870
+ failed += 1
871
+ except Exception as e:
872
+ # Если батч не удался, пробуем по одному
873
+ for item in request.items:
874
+ try:
875
+ embedding = model.encode(item.text, convert_to_numpy=True)
876
+ embedding_store[request.entity_type][item.entity_id] = {
877
+ "embedding": embedding.tolist(),
878
+ "metadata": item.metadata or {}
879
+ }
880
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
881
+ indexed += 1
882
+ except Exception as item_error:
883
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(item_error)))
884
+ failed += 1
885
+
886
+ return BulkIndexResponse(
887
+ total=len(request.items),
888
+ indexed=indexed,
889
+ failed=failed,
890
+ results=results
891
+ )
892
+
893
+
894
+ @app.delete("/index/{entity_type}")
895
+ async def clear_index(entity_type: str):
896
+ """
897
+ Очистка индекса для указанного типа сущностей.
898
+
899
+ Удаляет все эмбеддинги указанного типа.
900
+ """
901
+ if entity_type not in embedding_store:
902
+ raise HTTPException(
903
+ status_code=400,
904
+ detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
905
+ )
906
+
907
+ count = len(embedding_store[entity_type])
908
+ embedding_store[entity_type] = {}
909
+
910
+ return {"message": f"Cleared {count} {entity_type} from index", "deleted_count": count}
911
+
912
+
913
+ @app.post("/index/sync")
914
+ async def sync_index_info():
915
+ """
916
+ Получение информации для синхронизации.
917
+
918
+ Возвращает список всех entity_id в индексе, чтобы Go Backend мог
919
+ определить какие объекты нужно добавить/удалить.
920
+ """
921
+ return {
922
+ "leads": list(embedding_store["leads"].keys()),
923
+ "properties": list(embedding_store["properties"].keys())
924
+ }
925
+
926
+
927
+ # --- Weighted Matching Endpoint ---
928
+
929
+ @app.post("/match-weighted", response_model=WeightedMatchResponse)
930
+ async def match_weighted(request: WeightedMatchRequest):
931
+ """
932
+ Взвешенный матчинг с настраиваемыми приоритетами параметров.
933
+
934
+ Позволяет задать:
935
+ - Веса для каждого параметра (цена, район, комнаты, площадь, семантика)
936
+ - Жёсткие фильтры (объекты не прошедшие - исключаются)
937
+ - Мягкие критерии (влияют на ранжирование)
938
+
939
+ Пример использования:
940
+ ```json
941
+ {
942
+ "text": "Ищу 2-комнатную квартиру в центре до 10 млн",
943
+ "entity_type": "properties",
944
+ "top_k": 10,
945
+ "weights": {
946
+ "price": 0.35, // Цена - главный приоритет
947
+ "district": 0.30, // Район - второй по важности
948
+ "rooms": 0.20, // Комнаты
949
+ "area": 0.05, // Площадь менее важна
950
+ "semantic": 0.10 // Семантика для "мягких" критериев
951
+ },
952
+ "hard_filters": {
953
+ "price": {"max_price": 12000000},
954
+ "districts": ["Центральный", "Арбат", "Тверской"]
955
+ },
956
+ "soft_criteria": {
957
+ "target_price": 10000000,
958
+ "target_rooms": 2,
959
+ "target_district": "Центральный"
960
+ }
961
+ }
962
+ ```
963
+ """
964
+ if model is None:
965
+ raise HTTPException(status_code=503, detail="Model not loaded")
966
+
967
+ if request.entity_type not in embedding_store:
968
+ raise HTTPException(
969
+ status_code=400,
970
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
971
+ )
972
+
973
+ store = embedding_store[request.entity_type]
974
+ if not store:
975
+ return WeightedMatchResponse(
976
+ matches=[],
977
+ total_searched=0,
978
+ filtered_out=0,
979
+ weights_used=request.weights or ParameterWeights()
980
+ )
981
+
982
+ # Используем переданные веса или значения по умолчанию
983
+ weights = request.weights or ParameterWeights()
984
+
985
+ # Нормализуем веса чтобы сумма = 1
986
+ total_weight = weights.price + weights.district + weights.rooms + weights.area + weights.semantic
987
+ if total_weight > 0:
988
+ w_price = weights.price / total_weight
989
+ w_district = weights.district / total_weight
990
+ w_rooms = weights.rooms / total_weight
991
+ w_area = weights.area / total_weight
992
+ w_semantic = weights.semantic / total_weight
993
+ else:
994
+ w_price = w_district = w_rooms = w_area = w_semantic = 0.2
995
+
996
+ # Генерируем эмбеддинг для текста запроса
997
+ try:
998
+ query_embedding = model.encode(request.text, convert_to_numpy=True)
999
+ query_vec = np.array(query_embedding)
1000
+ except Exception as e:
1001
+ raise HTTPException(status_code=500, detail=f"Failed to generate embedding: {str(e)}")
1002
+
1003
+ # Извлекаем soft criteria
1004
+ soft = request.soft_criteria or SoftCriteria()
1005
+
1006
+ results = []
1007
+ filtered_out = 0
1008
+
1009
+ for entity_id, data in store.items():
1010
+ metadata = data.get("metadata", {})
1011
+
1012
+ # 1. Проверяем жёсткие фильтры
1013
+ if not _passes_hard_filters(metadata, request.hard_filters):
1014
+ filtered_out += 1
1015
+ continue
1016
+
1017
+ # 2. Вычисляем score по каждому параметру
1018
+
1019
+ # Цена
1020
+ price_score = _calculate_price_score(
1021
+ metadata.get("price"),
1022
+ soft.target_price,
1023
+ tolerance_percent=20.0
1024
+ )
1025
+
1026
+ # Район
1027
+ district_score = _calculate_district_score(
1028
+ metadata.get("district"),
1029
+ soft.target_district,
1030
+ soft.preferred_districts
1031
+ )
1032
+
1033
+ # Комнаты
1034
+ rooms_score = _calculate_rooms_score(
1035
+ metadata.get("rooms"),
1036
+ soft.target_rooms
1037
+ )
1038
+
1039
+ # Площадь
1040
+ area_score = _calculate_area_score(
1041
+ metadata.get("area"),
1042
+ soft.target_area
1043
+ )
1044
+
1045
+ # Семантика
1046
+ stored_vec = np.array(data["embedding"])
1047
+ semantic_score = _cosine_similarity(query_vec, stored_vec)
1048
+ # Нормализуем в 0-1 (косинусная близость может быть отрицательной)
1049
+ semantic_score = (semantic_score + 1) / 2
1050
+
1051
+ # 3. Вычисляем взвешенный total score
1052
+ total_score = (
1053
+ w_price * price_score +
1054
+ w_district * district_score +
1055
+ w_rooms * rooms_score +
1056
+ w_area * area_score +
1057
+ w_semantic * semantic_score
1058
+ )
1059
+
1060
+ # Пропускаем если ниже минимального порога
1061
+ if total_score < request.min_total_score:
1062
+ continue
1063
+
1064
+ # Генерируем объяснение
1065
+ explanation = _generate_match_explanation(
1066
+ price_score, district_score, rooms_score, area_score, semantic_score, metadata
1067
+ )
1068
+
1069
+ results.append(WeightedMatchResult(
1070
+ entity_id=entity_id,
1071
+ total_score=round(total_score, 4),
1072
+ price_score=round(price_score, 4),
1073
+ district_score=round(district_score, 4),
1074
+ rooms_score=round(rooms_score, 4),
1075
+ area_score=round(area_score, 4),
1076
+ semantic_score=round(semantic_score, 4),
1077
+ metadata=metadata,
1078
+ match_explanation=explanation
1079
+ ))
1080
+
1081
+ # Сортируем по total_score и берём top_k
1082
+ results.sort(key=lambda x: x.total_score, reverse=True)
1083
+ top_results = results[:request.top_k]
1084
+
1085
+ return WeightedMatchResponse(
1086
+ matches=top_results,
1087
+ total_searched=len(store),
1088
+ filtered_out=filtered_out,
1089
+ weights_used=weights
1090
+ )
1091
+
1092
+
1093
+ @app.get("/weights/presets")
1094
+ async def get_weight_presets():
1095
+ """
1096
+ Получить предустановленные наборы весов для разных сценариев.
1097
+
1098
+ Помогает фронтенду предложить пользователю готовые настройки.
1099
+ """
1100
+ return {
1101
+ "balanced": {
1102
+ "name": "Сбалансированный",
1103
+ "description": "Равномерное распределение приоритетов",
1104
+ "weights": {"price": 0.25, "district": 0.25, "rooms": 0.20, "area": 0.15, "semantic": 0.15}
1105
+ },
1106
+ "budget_first": {
1107
+ "name": "Бюджет важнее всего",
1108
+ "description": "Максимальный приоритет на соответствие бюджету",
1109
+ "weights": {"price": 0.45, "district": 0.20, "rooms": 0.15, "area": 0.10, "semantic": 0.10}
1110
+ },
1111
+ "location_first": {
1112
+ "name": "Локация важнее всего",
1113
+ "description": "Район и расположение - главный приоритет",
1114
+ "weights": {"price": 0.20, "district": 0.40, "rooms": 0.15, "area": 0.10, "semantic": 0.15}
1115
+ },
1116
+ "family": {
1117
+ "name": "Для семьи",
1118
+ "description": "Важны комнаты и площадь",
1119
+ "weights": {"price": 0.20, "district": 0.20, "rooms": 0.30, "area": 0.20, "semantic": 0.10}
1120
+ },
1121
+ "semantic_heavy": {
1122
+ "name": "Умный поиск",
1123
+ "description": "Максимальный приоритет на семантическое понимание запроса",
1124
+ "weights": {"price": 0.15, "district": 0.15, "rooms": 0.15, "area": 0.10, "semantic": 0.45}
1125
+ }
1126
+ }
embedding-service/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn[standard]>=0.24.0
3
+ sentence-transformers>=2.2.2
4
+ numpy>=1.24.0
5
+ pydantic>=2.5.0
6
+ python-dotenv>=1.0.0
7
+ torch>=2.0.0
8
+
render.yaml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: matching-embedding-service
4
+ env: python
5
+ region: frankfurt
6
+ plan: free
7
+ buildCommand: chmod +x build.sh && ./build.sh
8
+ startCommand: cd embedding-service && uvicorn main:app --host 0.0.0.0 --port $PORT
9
+ healthCheckPath: /health
10
+ envVars:
11
+ - key: EMBEDDING_MODEL
12
+ value: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
13
+ - key: EMBEDDING_DIMENSIONS
14
+ value: 384
15
+ - key: PYTHON_VERSION
16
+ value: 3.11.0
17
+