Calcifer0323 commited on
Commit
d99e8d3
·
2 Parent(s): 2b3c222 345a8d5

Merge GitHub repo and resolve conflicts

Browse files

- Resolved merge conflicts by keeping local Hugging Face versions
- Local changes include test files and updated requirements

.python-version ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ 3.11.0
2
+
BACKEND_INTEGRATION.md ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Интеграция Embedding Service с Go Backend
2
+
3
+ ## Адрес сервиса
4
+
5
+ ```
6
+ https://calcifer0323-matching.hf.space
7
+ ```
8
+
9
+ ## Endpoints
10
+
11
+ | Метод | Путь | Описание |
12
+ |-------|------|----------|
13
+ | GET | `/` | Информация о сервисе |
14
+ | GET | `/health` | Проверка здоровья |
15
+ | GET | `/model-info` | Информация о модели (размерность для pgvector) |
16
+ | POST | `/embed` | Эмбеддинг из готового текста |
17
+ | POST | `/prepare-and-embed` | ⭐ **ОСНОВНОЙ** - подготовка полей + эмбеддинг |
18
+ | POST | `/batch` | Пакетная обработка |
19
+
20
+ ## Архитектура
21
+
22
+ ```
23
+ Frontend → Go Backend → PostgreSQL + pgvector
24
+
25
+ Embedding Service (STATELESS)
26
+ (только генерирует эмбеддинги, не хранит)
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Шаг 1: Настройка PostgreSQL + pgvector
32
+
33
+ ```sql
34
+ -- Установить расширение
35
+ CREATE EXTENSION IF NOT EXISTS vector;
36
+
37
+ -- Добавить колонку в leads (384 измерения)
38
+ ALTER TABLE leads ADD COLUMN IF NOT EXISTS embedding vector(384);
39
+
40
+ -- Добавить колонку в properties
41
+ ALTER TABLE properties ADD COLUMN IF NOT EXISTS embedding vector(384);
42
+
43
+ -- Создать индексы для быстрого поиска
44
+ CREATE INDEX IF NOT EXISTS leads_embedding_idx
45
+ ON leads USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
46
+
47
+ CREATE INDEX IF NOT EXISTS properties_embedding_idx
48
+ ON properties USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Шаг 2: Интеграция в Go Backend
54
+
55
+ ### 2.1 HTTP клиент
56
+
57
+ ```go
58
+ package embedding
59
+
60
+ import (
61
+ "bytes"
62
+ "encoding/json"
63
+ "fmt"
64
+ "net/http"
65
+ "time"
66
+ )
67
+
68
+ const ServiceURL = "https://calcifer0323-matching.hf.space"
69
+
70
+ type Client struct {
71
+ http *http.Client
72
+ }
73
+
74
+ func NewClient() *Client {
75
+ return &Client{
76
+ http: &http.Client{Timeout: 30 * time.Second},
77
+ }
78
+ }
79
+
80
+ // Request для /prepare-and-embed
81
+ type PrepareAndEmbedRequest struct {
82
+ Title string `json:"title,omitempty"`
83
+ Description string `json:"description,omitempty"`
84
+ Requirement map[string]interface{} `json:"requirement,omitempty"`
85
+ Price *float64 `json:"price,omitempty"`
86
+ District *string `json:"district,omitempty"`
87
+ Rooms *int `json:"rooms,omitempty"`
88
+ Area *float64 `json:"area,omitempty"`
89
+ Address *string `json:"address,omitempty"`
90
+ }
91
+
92
+ // Response от /prepare-and-embed
93
+ type PrepareAndEmbedResponse struct {
94
+ Embedding []float32 `json:"embedding"`
95
+ Dimensions int `json:"dimensions"`
96
+ PreparedText string `json:"prepared_text"`
97
+ }
98
+
99
+ // GetEmbedding - получить эмбеддинг для лида или объекта
100
+ func (c *Client) GetEmbedding(req PrepareAndEmbedRequest) ([]float32, error) {
101
+ body, _ := json.Marshal(req)
102
+
103
+ resp, err := c.http.Post(
104
+ ServiceURL+"/prepare-and-embed",
105
+ "application/json",
106
+ bytes.NewBuffer(body),
107
+ )
108
+ if err != nil {
109
+ return nil, fmt.Errorf("request failed: %w", err)
110
+ }
111
+ defer resp.Body.Close()
112
+
113
+ if resp.StatusCode != 200 {
114
+ return nil, fmt.Errorf("service returned %d", resp.StatusCode)
115
+ }
116
+
117
+ var result PrepareAndEmbedResponse
118
+ json.NewDecoder(resp.Body).Decode(&result)
119
+
120
+ return result.Embedding, nil
121
+ }
122
+ ```
123
+
124
+ ### 2.2 Работа с pgvector
125
+
126
+ ```go
127
+ import "github.com/pgvector/pgvector-go"
128
+
129
+ // Сохранение эмбеддинга
130
+ func (r *LeadRepo) SaveEmbedding(ctx context.Context, leadID string, embedding []float32) error {
131
+ vec := pgvector.NewVector(embedding)
132
+ _, err := r.db.Exec(ctx,
133
+ `UPDATE leads SET embedding = $1 WHERE lead_id = $2`,
134
+ vec, leadID,
135
+ )
136
+ return err
137
+ }
138
+
139
+ // Поиск похожих объектов
140
+ func (r *PropertyRepo) FindSimilar(ctx context.Context, leadEmbedding []float32, limit int) ([]Match, error) {
141
+ vec := pgvector.NewVector(leadEmbedding)
142
+
143
+ rows, err := r.db.Query(ctx, `
144
+ SELECT property_id, title, price, district, rooms, area,
145
+ 1 - (embedding <=> $1) as similarity
146
+ FROM properties
147
+ WHERE embedding IS NOT NULL
148
+ ORDER BY embedding <=> $1
149
+ LIMIT $2
150
+ `, vec, limit)
151
+ // ... обработка результатов
152
+ }
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Шаг 3: Флоу создания лида
158
+
159
+ ```go
160
+ func (s *LeadService) CreateLead(ctx context.Context, req CreateLeadRequest) (*Lead, error) {
161
+ // 1. Сохранить лид в БД
162
+ lead, err := s.repo.Create(ctx, req)
163
+ if err != nil {
164
+ return nil, err
165
+ }
166
+
167
+ // 2. Получить эмбеддинг (можно асинхронно)
168
+ go func() {
169
+ embedding, err := s.embeddingClient.GetEmbedding(PrepareAndEmbedRequest{
170
+ Title: lead.Title,
171
+ Description: lead.Description,
172
+ Price: extractPrice(lead.Requirement),
173
+ District: extractDistrict(lead.Requirement),
174
+ Rooms: extractRooms(lead.Requirement),
175
+ })
176
+ if err != nil {
177
+ log.Printf("embedding failed for %s: %v", lead.ID, err)
178
+ return
179
+ }
180
+ s.repo.SaveEmbedding(context.Background(), lead.ID, embedding)
181
+ }()
182
+
183
+ return lead, nil
184
+ }
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Шаг 4: Эндпоинт матчинга
190
+
191
+ ```go
192
+ // GET /leads/{id}/matches?limit=10
193
+ func (h *Handler) GetMatches(w http.ResponseWriter, r *http.Request) {
194
+ leadID := chi.URLParam(r, "id")
195
+ limit := parseIntParam(r, "limit", 10)
196
+
197
+ // Получить эмбеддинг лида
198
+ leadEmbedding, err := h.leadRepo.GetEmbedding(r.Context(), leadID)
199
+ if err != nil {
200
+ respondError(w, "Lead has no embedding", 400)
201
+ return
202
+ }
203
+
204
+ // Найти похожие объекты
205
+ matches, err := h.propertyRepo.FindSimilar(r.Context(), leadEmbedding, limit)
206
+ if err != nil {
207
+ respondError(w, err.Error(), 500)
208
+ return
209
+ }
210
+
211
+ respondJSON(w, MatchesResponse{
212
+ LeadID: leadID,
213
+ Matches: matches,
214
+ })
215
+ }
216
+ ```
217
+
218
+ ---
219
+
220
+ ## API Response для Frontend
221
+
222
+ ```json
223
+ GET /api/leads/{leadId}/matches
224
+
225
+ {
226
+ "leadId": "550e8400-e29b-41d4-a716-446655440000",
227
+ "matches": [
228
+ {
229
+ "propertyId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
230
+ "title": "3-комнатная квартира в центре",
231
+ "price": 9500000,
232
+ "district": "Центральный",
233
+ "rooms": 3,
234
+ "area": 78.5,
235
+ "similarity": 0.92
236
+ }
237
+ ]
238
+ }
239
+ ```
240
+
241
+ ---
242
+
243
+ ## Зависимости Go
244
+
245
+ ```bash
246
+ go get github.com/pgvector/pgvector-go
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Проверка работоспособности
252
+
253
+ ```bash
254
+ # Health check
255
+ curl https://calcifer0323-matching.hf.space/health
256
+
257
+ # Тест эмбеддинга
258
+ curl -X POST https://calcifer0323-matching.hf.space/prepare-and-embed \
259
+ -H "Content-Type: application/json" \
260
+ -d '{"title": "Ищу квартиру", "price": 10000000, "rooms": 3}'
261
+
262
+ # Информация о модели
263
+ curl https://calcifer0323-matching.hf.space/model-info
264
+ ```
265
+
266
+ ---
267
+
268
+ ## FAQ
269
+
270
+ **Q: Что если Embedding Service недоступен?**
271
+ A: Лид сохранится без эмбеддинга. Добавьте retry-логику или фоновую задачу.
272
+
273
+ **Q: Как переиндексировать все записи?**
274
+ A: Используйте `/batch` endpoint для массовой обработки.
275
+
276
+ **Q: Нужно ли хранить prepared_text?**
277
+ A: Нет, только для отладки. Храните только `embedding`.
278
+
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,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Устанавливаем переменные окружения для оптимизации
6
+ ENV PYTHONUNBUFFERED=1
7
+ ENV TRANSFORMERS_CACHE=/tmp/transformers_cache
8
+ ENV SENTENCE_TRANSFORMERS_HOME=/tmp/sentence_transformers
9
+ ENV PIP_NO_CACHE_DIR=1
10
+
11
+ # Install dependencies (используем production requirements)
12
+ COPY requirements-prod.txt .
13
+ RUN pip install --no-cache-dir -r requirements-prod.txt
14
+
15
+ # Copy application
16
+ COPY main.py .
17
+
18
+ # Expose port
19
+ EXPOSE 8082
20
+
21
+ # Run with uvicorn (ограничиваем workers для экономии памяти)
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082", "--workers", "1"]
23
+
embedding-service/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
+ }
embedding-service/requirements-prod.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Production requirements - оптимизировано для минимального потребления памяти
2
+ fastapi==0.104.1
3
+ uvicorn[standard]==0.24.0
4
+ numpy>=1.24.0,<2.0.0
5
+ pydantic==2.5.3
6
+ python-dotenv==1.0.0
7
+
8
+ # PyTorch CPU-only версия (значительно меньше памяти)
9
+ --extra-index-url https://download.pytorch.org/whl/cpu
10
+ torch==2.1.2+cpu
11
+
12
+ # Transformers совместимый с torch 2.1.2
13
+ transformers==4.36.2
14
+ sentence-transformers==2.2.2
15
+
embedding-service/requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn[standard]>=0.24.0
3
+ numpy>=1.24.0,<2.0.0
4
+ pydantic>=2.5.0
5
+ python-dotenv>=1.0.0
6
+ --extra-index-url https://download.pytorch.org/whl/cpu
7
+ torch>=2.1.0,<2.2.0
8
+ transformers>=4.36.0,<4.37.0
9
+ sentence-transformers>=2.2.2
10
+
huggingface/.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
+
huggingface/.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
huggingface/.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
+
huggingface/Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ENV EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
22
+ ENV EMBEDDING_DIMENSIONS=384
23
+
24
+ # Copy requirements and install dependencies
25
+ COPY --chown=user requirements.txt .
26
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
27
+
28
+ # Copy application files
29
+ COPY --chown=user main.py .
30
+
31
+ # Expose port 7860 (HuggingFace Spaces standard)
32
+ EXPOSE 7860
33
+
34
+ # Start the application
35
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
36
+
huggingface/README.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Matching Embedding Service
3
+ emoji: 🏠
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ app_port: 7860
10
+ ---
11
+
12
+ # Matching Embedding Service
13
+
14
+ Сервис для генерации эмбеддингов текста и семантического поиска объектов недвижимости.
15
+
16
+ ## Возможности
17
+
18
+ - 🔢 Генерация эмбеддингов для русского и английского текста
19
+ - 🔍 Семантический поиск и матчинг
20
+ - 📊 In-memory хранилище векторов
21
+ - 🚀 FastAPI с автоматической документацией
22
+ - 🌐 CORS-ready для интеграции
23
+
24
+ ## API Документация
25
+
26
+ После запуска доступна по адресам:
27
+ - Swagger UI: `/docs`
28
+ - ReDoc: `/redoc`
29
+
30
+ ## Модель
31
+
32
+ Используется модель: `paraphrase-multilingual-MiniLM-L12-v2`
33
+ - Поддержка 50+ языков (включая русский)
34
+ - Размерность векторов: 384
35
+ - 12 слоёв (лучшее качество чем L6-v2)
36
+ - Оптимизирована для семантического поиска
37
+
38
+ ## Endpoints
39
+
40
+ ### Основные
41
+ - `GET /health` - проверка работоспособности
42
+ - `POST /embed` - генерация эмбеддинга для текста
43
+ - `POST /embed-batch` - пакетная генерация эмбеддингов
44
+
45
+ ### Матчинг
46
+ - `POST /match-text` - поиск похожих объектов по тексту
47
+ - `POST /register` - регистрация объекта с эмбеддингом
48
+
49
+ ### Статистика
50
+ - `GET /store/stats` - статистика хранилища
51
+
52
+ ## Использование
53
+
54
+ ```python
55
+ import requests
56
+
57
+ # Health check
58
+ response = requests.get("https://calcifer0323-matching.hf.space/health")
59
+ print(response.json())
60
+
61
+ # Генерация эмбеддинга
62
+ response = requests.post(
63
+ "https://calcifer0323-matching.hf.space/embed",
64
+ json={"text": "Уютная квартира в центре"}
65
+ )
66
+ embedding = response.json()["embedding"]
67
+ ```
68
+
69
+ ## Разработка
70
+
71
+ Локальный запуск:
72
+ ```bash
73
+ pip install -r requirements.txt
74
+ uvicorn main:app --host 0.0.0.0 --port 7860
75
+ ```
76
+
77
+ Docker:
78
+ ```bash
79
+ docker build -t matching-service .
80
+ docker run -p 7860:7860 matching-service
81
+ ```
82
+
huggingface/main.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding Service - FastAPI сервис для генерации эмбеддингов текста.
3
+
4
+ STATELESS сервис - не хранит данные, только генерирует эмбеддинги.
5
+ Хранение эмбеддингов происходит на стороне бэкенда в PostgreSQL + pgvector.
6
+
7
+ Используется для матчинга лидов с объектами недвижимости.
8
+
9
+ Endpoints:
10
+ - POST /embed - генерация эмбеддинга из текста
11
+ - POST /prepare-and-embed - подготовка полей + эмбеддинг (ОСНОВНОЙ)
12
+ - POST /batch - пакетная обработка
13
+ - GET /health - проверка здоровья
14
+ - GET /model-info - информация о модели
15
+ """
16
+
17
+ import os
18
+ from typing import List, Optional, Dict, Any
19
+ from contextlib import asynccontextmanager
20
+
21
+ from fastapi import FastAPI, HTTPException
22
+ from fastapi.middleware.cors import CORSMiddleware
23
+ from pydantic import BaseModel, Field
24
+ from sentence_transformers import SentenceTransformer
25
+ import numpy as np
26
+ from dotenv import load_dotenv
27
+
28
+ load_dotenv()
29
+
30
+ # Конфигурация
31
+ MODEL_NAME = os.getenv("EMBEDDING_MODEL", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
32
+ EMBEDDING_DIMENSIONS = 384
33
+
34
+ # Глобальная модель
35
+ model: Optional[SentenceTransformer] = None
36
+
37
+
38
+ @asynccontextmanager
39
+ async def lifespan(app: FastAPI):
40
+ """Загрузка модели при старте."""
41
+ global model
42
+ print(f"Loading embedding model: {MODEL_NAME}")
43
+ model = SentenceTransformer(MODEL_NAME, device='cpu')
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. Dimensions: {model.get_sentence_embedding_dimension()}")
50
+ yield
51
+ model = None
52
+
53
+
54
+ app = FastAPI(
55
+ title="Embedding Service",
56
+ description="Stateless сервис генерации эмбеддингов для матчинга недвижимости",
57
+ version="2.0.0",
58
+ lifespan=lifespan
59
+ )
60
+
61
+ app.add_middleware(
62
+ CORSMiddleware,
63
+ allow_origins=["*"],
64
+ allow_credentials=True,
65
+ allow_methods=["*"],
66
+ allow_headers=["*"],
67
+ )
68
+
69
+
70
+ # ============== Pydantic Models ==============
71
+
72
+ class EmbedRequest(BaseModel):
73
+ """Запрос на генерацию эмбеддинга из готового текста."""
74
+ text: str = Field(..., min_length=1, description="Текст для эмбеддинга")
75
+
76
+
77
+ class EmbedResponse(BaseModel):
78
+ """Ответ с эмбеддингом."""
79
+ embedding: List[float]
80
+ dimensions: int
81
+
82
+
83
+ class PrepareAndEmbedRequest(BaseModel):
84
+ """
85
+ Запрос на подготовку текста из полей и генерацию эмбеддинга.
86
+
87
+ Это ОСНОВНОЙ endpoint для интеграции с Go Backend.
88
+ """
89
+ title: str = Field(default="", description="Название")
90
+ description: str = Field(default="", description="Описание")
91
+ requirement: Optional[Dict[str, Any]] = Field(default=None, description="Требования (JSON)")
92
+ price: Optional[float] = Field(default=None, description="Цена")
93
+ district: Optional[str] = Field(default=None, description="Район")
94
+ rooms: Optional[int] = Field(default=None, description="Количество комнат")
95
+ area: Optional[float] = Field(default=None, description="Площадь")
96
+ address: Optional[str] = Field(default=None, description="Адрес")
97
+
98
+
99
+ class PrepareAndEmbedResponse(BaseModel):
100
+ """Ответ с эмбеддингом."""
101
+ embedding: List[float]
102
+ dimensions: int
103
+ prepared_text: str = Field(description="Подготовленный текст (для отладки)")
104
+
105
+
106
+ class BatchItem(BaseModel):
107
+ """Один элемент для пакетной обработки."""
108
+ entity_id: str = Field(..., description="ID объекта")
109
+ title: str = Field(default="")
110
+ description: str = Field(default="")
111
+ requirement: Optional[Dict[str, Any]] = None
112
+ price: Optional[float] = None
113
+ district: Optional[str] = None
114
+ rooms: Optional[int] = None
115
+ area: Optional[float] = None
116
+ address: Optional[str] = None
117
+
118
+
119
+ class BatchRequest(BaseModel):
120
+ """Запрос на пакетную обработку."""
121
+ items: List[BatchItem]
122
+
123
+
124
+ class BatchResultItem(BaseModel):
125
+ """Результат для одного элемента."""
126
+ entity_id: str
127
+ embedding: List[float]
128
+ success: bool = True
129
+ error: Optional[str] = None
130
+
131
+
132
+ class BatchResponse(BaseModel):
133
+ """Ответ на пакетную обработку."""
134
+ results: List[BatchResultItem]
135
+ dimensions: int
136
+ total: int
137
+ successful: int
138
+
139
+
140
+ class HealthResponse(BaseModel):
141
+ """Ответ health check."""
142
+ status: str
143
+ model: str
144
+ dimensions: int
145
+
146
+
147
+ # ============== Helper Functions ==============
148
+
149
+ def prepare_text(
150
+ title: str = "",
151
+ description: str = "",
152
+ requirement: Optional[Dict[str, Any]] = None,
153
+ price: Optional[float] = None,
154
+ district: Optional[str] = None,
155
+ rooms: Optional[int] = None,
156
+ area: Optional[float] = None,
157
+ address: Optional[str] = None
158
+ ) -> str:
159
+ """Объединяет поля в текст для эмбеддинга."""
160
+ parts = []
161
+
162
+ if title:
163
+ parts.append(f"Название: {title}")
164
+ if description:
165
+ parts.append(f"Описание: {description}")
166
+
167
+ if requirement:
168
+ req_parts = [f"{k}: {v}" for k, v in requirement.items() if v is not None]
169
+ if req_parts:
170
+ parts.append(f"Требования: {', '.join(req_parts)}")
171
+
172
+ params = []
173
+ if price is not None:
174
+ params.append(f"цена {price:,.0f}₽")
175
+ if district:
176
+ params.append(f"район {district}")
177
+ if rooms is not None:
178
+ params.append(f"{rooms}-комнатная")
179
+ if area is not None:
180
+ params.append(f"площадь {area}м²")
181
+ if address:
182
+ params.append(f"адрес: {address}")
183
+
184
+ if params:
185
+ parts.append(f"Параметры: {', '.join(params)}")
186
+
187
+ return ". ".join(parts)
188
+
189
+
190
+ # ============== Endpoints ==============
191
+
192
+ @app.get("/")
193
+ async def root():
194
+ """Информация о сервисе."""
195
+ return {
196
+ "service": "Embedding Service",
197
+ "version": "2.0.0",
198
+ "type": "STATELESS",
199
+ "description": "Генерирует эмбеддинги. Хранение на стороне Go Backend + pgvector.",
200
+ "endpoints": {
201
+ "POST /embed": "Эмбеддинг из готового текста",
202
+ "POST /prepare-and-embed": "Подготовка полей + эмбеддинг (ОСНОВНОЙ)",
203
+ "POST /batch": "Пакетная обработка",
204
+ "GET /health": "Проверка здоровья",
205
+ "GET /model-info": "Информация о модели для pgvector"
206
+ },
207
+ "docs": "/docs"
208
+ }
209
+
210
+
211
+ @app.get("/health", response_model=HealthResponse)
212
+ async def health_check():
213
+ """Проверка здоровья сервиса."""
214
+ if model is None:
215
+ raise HTTPException(status_code=503, detail="Model not loaded")
216
+ return HealthResponse(
217
+ status="healthy",
218
+ model=MODEL_NAME,
219
+ dimensions=model.get_sentence_embedding_dimension()
220
+ )
221
+
222
+
223
+ @app.post("/embed", response_model=EmbedResponse)
224
+ async def embed_text(request: EmbedRequest):
225
+ """
226
+ Генерация эмбеддинга из готового текста.
227
+
228
+ Используйте если текст уже подготовлен на стороне бэкенда.
229
+ """
230
+ if model is None:
231
+ raise HTTPException(status_code=503, detail="Model not loaded")
232
+
233
+ embedding = model.encode(request.text, convert_to_numpy=True)
234
+ return EmbedResponse(
235
+ embedding=embedding.tolist(),
236
+ dimensions=len(embedding)
237
+ )
238
+
239
+
240
+ @app.post("/prepare-and-embed", response_model=PrepareAndEmbedResponse)
241
+ async def prepare_and_embed(request: PrepareAndEmbedRequest):
242
+ """
243
+ Подготовка текста из полей и генерация эмбеддинга.
244
+
245
+ ⭐ ОСНОВНОЙ ENDPOINT для интеграции с Go Backend.
246
+
247
+ Пример запроса:
248
+ ```json
249
+ {
250
+ "title": "Ищу квартиру в центре",
251
+ "description": "Для семьи с детьми",
252
+ "price": 10000000,
253
+ "district": "Центральный",
254
+ "rooms": 3
255
+ }
256
+ ```
257
+
258
+ Go Backend сохраняет embedding в PostgreSQL:
259
+ ```sql
260
+ UPDATE leads SET embedding = $1 WHERE lead_id = $2
261
+ ```
262
+ """
263
+ if model is None:
264
+ raise HTTPException(status_code=503, detail="Model not loaded")
265
+
266
+ prepared = prepare_text(
267
+ title=request.title,
268
+ description=request.description,
269
+ requirement=request.requirement,
270
+ price=request.price,
271
+ district=request.district,
272
+ rooms=request.rooms,
273
+ area=request.area,
274
+ address=request.address
275
+ )
276
+
277
+ if not prepared:
278
+ raise HTTPException(status_code=400, detail="All fields are empty")
279
+
280
+ embedding = model.encode(prepared, convert_to_numpy=True)
281
+
282
+ return PrepareAndEmbedResponse(
283
+ embedding=embedding.tolist(),
284
+ dimensions=len(embedding),
285
+ prepared_text=prepared
286
+ )
287
+
288
+
289
+ @app.post("/batch", response_model=BatchResponse)
290
+ async def batch_process(request: BatchRequest):
291
+ """
292
+ Пакетная обработка нескольких объектов.
293
+
294
+ Используйте для массовой индексации при первоначальной загрузке.
295
+
296
+ Пример:
297
+ ```json
298
+ {
299
+ "items": [
300
+ {"entity_id": "lead-1", "title": "Ищу квартиру", "rooms": 3},
301
+ {"entity_id": "lead-2", "title": "Нужен офис", "area": 100}
302
+ ]
303
+ }
304
+ ```
305
+ """
306
+ if model is None:
307
+ raise HTTPException(status_code=503, detail="Model not loaded")
308
+
309
+ results = []
310
+ texts = []
311
+ valid_items = []
312
+
313
+ # Подготовка текстов
314
+ for item in request.items:
315
+ prepared = prepare_text(
316
+ title=item.title,
317
+ description=item.description,
318
+ requirement=item.requirement,
319
+ price=item.price,
320
+ district=item.district,
321
+ rooms=item.rooms,
322
+ area=item.area,
323
+ address=item.address
324
+ )
325
+ if prepared:
326
+ texts.append(prepared)
327
+ valid_items.append(item)
328
+ else:
329
+ results.append(BatchResultItem(
330
+ entity_id=item.entity_id,
331
+ embedding=[],
332
+ success=False,
333
+ error="All fields are empty"
334
+ ))
335
+
336
+ # Генерация эмбеддингов батчем
337
+ if texts:
338
+ embeddings = model.encode(texts, convert_to_numpy=True)
339
+ for i, item in enumerate(valid_items):
340
+ results.append(BatchResultItem(
341
+ entity_id=item.entity_id,
342
+ embedding=embeddings[i].tolist(),
343
+ success=True
344
+ ))
345
+
346
+ # Сортировка по порядку входных items
347
+ results_map = {r.entity_id: r for r in results}
348
+ sorted_results = [results_map[item.entity_id] for item in request.items]
349
+ successful = sum(1 for r in sorted_results if r.success)
350
+
351
+ return BatchResponse(
352
+ results=sorted_results,
353
+ dimensions=EMBEDDING_DIMENSIONS,
354
+ total=len(request.items),
355
+ successful=successful
356
+ )
357
+
358
+
359
+ @app.get("/model-info")
360
+ async def get_model_info():
361
+ """
362
+ Информация о модели для настройки pgvector.
363
+
364
+ Используйте для создания колонки правильной размерности.
365
+ """
366
+ if model is None:
367
+ raise HTTPException(status_code=503, detail="Model not loaded")
368
+
369
+ dims = model.get_sentence_embedding_dimension()
370
+
371
+ return {
372
+ "model_name": MODEL_NAME,
373
+ "dimensions": dims,
374
+ "sql_examples": {
375
+ "extension": "CREATE EXTENSION IF NOT EXISTS vector;",
376
+ "column": f"ALTER TABLE leads ADD COLUMN embedding vector({dims});",
377
+ "index": f"CREATE INDEX ON leads USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);",
378
+ "search": """
379
+ SELECT property_id, title, 1 - (embedding <=> $1) as similarity
380
+ FROM properties
381
+ WHERE embedding IS NOT NULL
382
+ ORDER BY embedding <=> $1
383
+ LIMIT 10;
384
+ """.strip()
385
+ }
386
+ }
huggingface/requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Requirements for HuggingFace Space
2
+ # Оптимизировано для стабильной работы
3
+
4
+ fastapi==0.104.1
5
+ uvicorn[standard]==0.24.0
6
+ numpy>=1.24.0,<2.0.0
7
+ pydantic==2.5.3
8
+ python-dotenv==1.0.0
9
+
10
+ # PyTorch - используем стандартную версию (HuggingFace имеет достаточно памяти)
11
+ torch>=2.1.0,<2.2.0
12
+ transformers==4.36.2
13
+
14
+ # Sentence Transformers с совместимой версией huggingface_hub
15
+ sentence-transformers==2.3.1
16
+ huggingface_hub>=0.19.0,<0.20.0
17
+
render.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: matching-embedding-service
4
+ runtime: python
5
+ region: frankfurt # или oregon для США
6
+ plan: free # бесплатный план, можно изменить на starter
7
+ rootDir: embedding-service
8
+ buildCommand: pip install --no-cache-dir -r requirements-prod.txt
9
+ startCommand: uvicorn main:app --host 0.0.0.0 --port $PORT --workers 1
10
+ healthCheckPath: /health
11
+ envVars:
12
+ - key: EMBEDDING_MODEL
13
+ value: sentence-transformers/paraphrase-multilingual-MiniLM-L6-v2
14
+ - key: EMBEDDING_DIMENSIONS
15
+ value: 384
16
+ - key: PYTHON_VERSION
17
+ value: 3.11.0
18
+ - key: TRANSFORMERS_CACHE
19
+ value: /tmp/transformers_cache
20
+
test-huggingface.ps1 ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Тест HuggingFace Space
2
+ # Запустите после успешного деплоя
3
+
4
+ $baseUrl = "https://calcifer0323-matching.hf.space"
5
+
6
+ Write-Host "🧪 Тестирование HuggingFace Space: $baseUrl" -ForegroundColor Cyan
7
+ Write-Host ""
8
+
9
+ # Test 1: Health Check
10
+ Write-Host "1️⃣ Health Check..." -ForegroundColor Yellow
11
+ try {
12
+ $health = Invoke-RestMethod -Uri "$baseUrl/health" -Method Get
13
+ Write-Host " ✅ Status: $($health.status)" -ForegroundColor Green
14
+ Write-Host " ✅ Model: $($health.model)" -ForegroundColor Green
15
+ Write-Host " ✅ Dimensions: $($health.embedding_dimensions)" -ForegroundColor Green
16
+ } catch {
17
+ Write-Host " ❌ Error: $($_.Exception.Message)" -ForegroundColor Red
18
+ Write-Host " 💡 Space может еще собираться. Подождите 2-3 минуты." -ForegroundColor Yellow
19
+ exit
20
+ }
21
+
22
+ Write-Host ""
23
+
24
+ # Test 2: Single Embedding
25
+ Write-Host "2️⃣ Генерация одного эмбеддинга..." -ForegroundColor Yellow
26
+ try {
27
+ $body = @{
28
+ text = "Современная трёхкомнатная квартира в центре Москвы"
29
+ } | ConvertTo-Json
30
+
31
+ $embedding = Invoke-RestMethod -Uri "$baseUrl/embed" -Method Post -Body $body -ContentType "application/json"
32
+ Write-Host " ✅ Embedding dimensions: $($embedding.dimensions)" -ForegroundColor Green
33
+ Write-Host " ✅ Vector length: $($embedding.embedding.Count)" -ForegroundColor Green
34
+ Write-Host " ✅ First 5 values: $($embedding.embedding[0..4] -join ', ')" -ForegroundColor Green
35
+ } catch {
36
+ Write-Host " ❌ Error: $($_.Exception.Message)" -ForegroundColor Red
37
+ }
38
+
39
+ Write-Host ""
40
+
41
+ # Test 3: Batch Embeddings
42
+ Write-Host "3️⃣ Пакетная генерация эмбеддингов..." -ForegroundColor Yellow
43
+ try {
44
+ $body = @{
45
+ texts = @(
46
+ "Студия 30 кв.м, ремонт, метро рядом",
47
+ "2-комнатная квартира, 65 кв.м, Арбат",
48
+ "Пентхаус с панорамным видом"
49
+ )
50
+ } | ConvertTo-Json
51
+
52
+ $batch = Invoke-RestMethod -Uri "$baseUrl/embed-batch" -Method Post -Body $body -ContentType "application/json"
53
+ Write-Host " ✅ Embeddings count: $($batch.embeddings.Count)" -ForegroundColor Green
54
+ Write-Host " ✅ Dimensions: $($batch.dimensions)" -ForegroundColor Green
55
+ } catch {
56
+ Write-Host " ❌ Error: $($_.Exception.Message)" -ForegroundColor Red
57
+ }
58
+
59
+ Write-Host ""
60
+
61
+ # Test 4: Register Property
62
+ Write-Host "4️⃣ Регистрация объекта недвижимости..." -ForegroundColor Yellow
63
+ try {
64
+ $body = @{
65
+ entity_type = "properties"
66
+ entity_id = "test-prop-001"
67
+ text = "Просторная 3-комнатная квартира 85 кв.м, современный ремонт, район Арбат"
68
+ metadata = @{
69
+ price = 25000000
70
+ rooms = 3
71
+ area = 85
72
+ location = "Арбат"
73
+ }
74
+ } | ConvertTo-Json -Depth 3
75
+
76
+ $register = Invoke-RestMethod -Uri "$baseUrl/register" -Method Post -Body $body -ContentType "application/json"
77
+ Write-Host " ✅ Registered: $($register.entity_id)" -ForegroundColor Green
78
+ Write-Host " ✅ Type: $($register.entity_type)" -ForegroundColor Green
79
+ } catch {
80
+ Write-Host " ❌ Error: $($_.Exception.Message)" -ForegroundColor Red
81
+ }
82
+
83
+ Write-Host ""
84
+
85
+ # Test 5: Search Similar
86
+ Write-Host "5️⃣ Поиск похожих объектов..." -ForegroundColor Yellow
87
+ try {
88
+ $body = @{
89
+ entity_type = "properties"
90
+ query_text = "Хочу купить просторную квартиру в центре Москвы"
91
+ top_k = 5
92
+ min_similarity = 0.0
93
+ } | ConvertTo-Json
94
+
95
+ $matches = Invoke-RestMethod -Uri "$baseUrl/match-text" -Method Post -Body $body -ContentType "application/json"
96
+ Write-Host " ✅ Matches found: $($matches.matches.Count)" -ForegroundColor Green
97
+ if ($matches.matches.Count -gt 0) {
98
+ Write-Host " ✅ Top match ID: $($matches.matches[0].entity_id)" -ForegroundColor Green
99
+ Write-Host " ✅ Similarity: $([math]::Round($matches.matches[0].similarity, 4))" -ForegroundColor Green
100
+ }
101
+ } catch {
102
+ Write-Host " ❌ Error: $($_.Exception.Message)" -ForegroundColor Red
103
+ }
104
+
105
+ Write-Host ""
106
+
107
+ # Test 6: Stats
108
+ Write-Host "6️⃣ Статистика хранилища..." -ForegroundColor Yellow
109
+ try {
110
+ $stats = Invoke-RestMethod -Uri "$baseUrl/store/stats" -Method Get
111
+ Write-Host " ✅ Total entities: $($stats.total_entities)" -ForegroundColor Green
112
+ Write-Host " ✅ Properties: $($stats.by_type.properties)" -ForegroundColor Green
113
+ Write-Host " ✅ Model: $($stats.model)" -ForegroundColor Green
114
+ } catch {
115
+ Write-Host " ❌ Error: $($_.Exception.Message)" -ForegroundColor Red
116
+ }
117
+
118
+ Write-Host ""
119
+ Write-Host "=" * 60 -ForegroundColor Cyan
120
+ Write-Host "🎉 Все тесты заверше��ы!" -ForegroundColor Green
121
+ Write-Host ""
122
+ Write-Host "📚 Swagger UI: $baseUrl/docs" -ForegroundColor Cyan
123
+ Write-Host "📖 ReDoc: $baseUrl/redoc" -ForegroundColor Cyan
124
+ Write-Host "🏠 Space: https://huggingface.co/spaces/Calcifer0323/matching" -ForegroundColor Cyan
125
+ Write-Host ""
126
+