Calcifer0323 commited on
Commit
6ee1a1d
·
1 Parent(s): b51d0f9

feat: add HuggingFace deployment files and documentation

Browse files

- Add huggingface/ folder with Docker configuration for HF Spaces
- Add Dockerfile optimized for HuggingFace (16GB RAM, L12-v2 model)
- Add main.py FastAPI application with all endpoints
- Add requirements.txt with compatible dependencies
- Add test-huggingface.ps1 for automated testing
- Successfully deployed at: https://calcifer0323-matching.hf.space

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,1167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("/")
301
+ async def root():
302
+ """
303
+ Корневая страница API.
304
+
305
+ Показывает информацию о сервисе и ссылки на документацию.
306
+ """
307
+ return {
308
+ "service": "Matching Embedding Service",
309
+ "version": "1.0.0",
310
+ "status": "running",
311
+ "model": MODEL_NAME,
312
+ "docs": "/docs",
313
+ "redoc": "/redoc",
314
+ "health": "/health",
315
+ "endpoints": {
316
+ "embedding": {
317
+ "single": "POST /embed",
318
+ "batch": "POST /embed-batch"
319
+ },
320
+ "matching": {
321
+ "by_text": "POST /match-text",
322
+ "by_vector": "POST /match",
323
+ "weighted": "POST /match-weighted"
324
+ },
325
+ "management": {
326
+ "register": "POST /register",
327
+ "bulk_index": "POST /index/bulk",
328
+ "stats": "GET /store/stats"
329
+ }
330
+ }
331
+ }
332
+
333
+
334
+ @app.get("/health", response_model=HealthResponse)
335
+ async def health_check():
336
+ """Проверка здоровья сервиса."""
337
+ if model is None:
338
+ raise HTTPException(status_code=503, detail="Model not loaded")
339
+ return HealthResponse(
340
+ status="healthy",
341
+ model=MODEL_NAME,
342
+ dimensions=model.get_sentence_embedding_dimension()
343
+ )
344
+
345
+
346
+ @app.post("/embed", response_model=EmbedResponse)
347
+ async def embed_text(request: EmbedRequest):
348
+ """
349
+ Генерация эмбеддинга для одного текста.
350
+
351
+ Используется для получения векторного представления лида или объекта недвижимости.
352
+ """
353
+ if model is None:
354
+ raise HTTPException(status_code=503, detail="Model not loaded")
355
+
356
+ try:
357
+ embedding = model.encode(request.text, convert_to_numpy=True)
358
+ return EmbedResponse(
359
+ embedding=embedding.tolist(),
360
+ model=MODEL_NAME,
361
+ dimensions=len(embedding)
362
+ )
363
+ except Exception as e:
364
+ raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}")
365
+
366
+
367
+ @app.post("/embed-batch", response_model=EmbedBatchResponse)
368
+ async def embed_batch(request: EmbedBatchRequest):
369
+ """
370
+ Пакетная генерация эмбеддингов.
371
+
372
+ Эффективнее для обработки нескольких текстов за раз.
373
+ """
374
+ if model is None:
375
+ raise HTTPException(status_code=503, detail="Model not loaded")
376
+
377
+ try:
378
+ embeddings = model.encode(request.texts, convert_to_numpy=True)
379
+ return EmbedBatchResponse(
380
+ embeddings=[emb.tolist() for emb in embeddings],
381
+ model=MODEL_NAME,
382
+ dimensions=embeddings.shape[1] if len(embeddings.shape) > 1 else len(embeddings)
383
+ )
384
+ except Exception as e:
385
+ raise HTTPException(status_code=500, detail=f"Batch embedding generation failed: {str(e)}")
386
+
387
+
388
+ @app.post("/similarity", response_model=SimilarityResponse)
389
+ async def compute_similarity(request: SimilarityRequest):
390
+ """
391
+ Вычисление косинусной близости между двумя эмбеддингами.
392
+
393
+ Возвращает значение от -1 (противоположные) до 1 (идентичные).
394
+ """
395
+ if len(request.embedding1) != len(request.embedding2):
396
+ raise HTTPException(
397
+ status_code=400,
398
+ detail="Embeddings must have the same dimensions"
399
+ )
400
+
401
+ try:
402
+ vec1 = np.array(request.embedding1)
403
+ vec2 = np.array(request.embedding2)
404
+
405
+ # Косинусная близость
406
+ similarity = np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
407
+
408
+ return SimilarityResponse(similarity=float(similarity))
409
+ except Exception as e:
410
+ raise HTTPException(status_code=500, detail=f"Similarity computation failed: {str(e)}")
411
+
412
+
413
+ @app.post("/prepare-text")
414
+ async def prepare_text_for_embedding(
415
+ title: str = "",
416
+ description: str = "",
417
+ requirement: dict = None
418
+ ):
419
+ """
420
+ Подготовка текста для генерации эмбеддинга.
421
+
422
+ Объединяет title, description и requirement в один текст для эмбеддинга.
423
+ """
424
+ parts = []
425
+
426
+ if title:
427
+ parts.append(f"Название: {title}")
428
+
429
+ if description:
430
+ parts.append(f"Описание: {description}")
431
+
432
+ if requirement:
433
+ req_parts = []
434
+ for key, value in requirement.items():
435
+ req_parts.append(f"{key}: {value}")
436
+ if req_parts:
437
+ parts.append(f"Требования: {', '.join(req_parts)}")
438
+
439
+ combined_text = ". ".join(parts)
440
+
441
+ return {"prepared_text": combined_text}
442
+
443
+
444
+ # --- Matching Endpoints ---
445
+
446
+ def _cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
447
+ """Вычисление косинусной близости между двумя векторами."""
448
+ norm1 = np.linalg.norm(vec1)
449
+ norm2 = np.linalg.norm(vec2)
450
+ if norm1 == 0 or norm2 == 0:
451
+ return 0.0
452
+ return float(np.dot(vec1, vec2) / (norm1 * norm2))
453
+
454
+
455
+ def _calculate_price_score(obj_price: Optional[float], target_price: Optional[float], tolerance_percent: float = 20.0) -> float:
456
+ """
457
+ Вычисление score по цене.
458
+
459
+ Если цена объекта в пределах допуска от целевой - высокий score.
460
+ Чем дальше - тем ниже score.
461
+ """
462
+ if obj_price is None or target_price is None:
463
+ return 0.5 # Нейтральный score если данных нет
464
+
465
+ if target_price == 0:
466
+ return 0.5
467
+
468
+ # Процентное отклонение
469
+ deviation_percent = abs(obj_price - target_price) / target_price * 100
470
+
471
+ if deviation_percent <= tolerance_percent:
472
+ # В пределах допуска - линейно от 1.0 до 0.7
473
+ return 1.0 - (deviation_percent / tolerance_percent) * 0.3
474
+ else:
475
+ # За пределами допуска - быстро падает
476
+ extra_deviation = deviation_percent - tolerance_percent
477
+ score = 0.7 - (extra_deviation / 100) * 0.7
478
+ return max(0.0, score)
479
+
480
+
481
+ def _calculate_district_score(
482
+ obj_district: Optional[str],
483
+ target_district: Optional[str],
484
+ preferred_districts: Optional[List[str]] = None
485
+ ) -> float:
486
+ """
487
+ Вычисление score по району.
488
+
489
+ Точное совпадение = 1.0
490
+ В списке предпочтительных = 0.7
491
+ Иначе = 0.3
492
+ """
493
+ if obj_district is None:
494
+ return 0.3
495
+
496
+ obj_district_lower = obj_district.lower().strip()
497
+
498
+ # Точное совпадение с целевым
499
+ if target_district and obj_district_lower == target_district.lower().strip():
500
+ return 1.0
501
+
502
+ # Проверяем в списке предпочтительных
503
+ if preferred_districts:
504
+ for pref in preferred_districts:
505
+ if obj_district_lower == pref.lower().strip():
506
+ return 0.7
507
+ # Частичное совпадение (например "Центральный" в "Центральный район")
508
+ if pref.lower() in obj_district_lower or obj_district_lower in pref.lower():
509
+ return 0.6
510
+
511
+ return 0.3
512
+
513
+
514
+ def _calculate_rooms_score(obj_rooms: Optional[int], target_rooms: Optional[int]) -> float:
515
+ """
516
+ Вычисление score по количеству комнат.
517
+
518
+ Точное совпадение = 1.0
519
+ ±1 комната = 0.6
520
+ ±2 комнаты = 0.3
521
+ Больше разницы = 0.1
522
+ """
523
+ if obj_rooms is None or target_rooms is None:
524
+ return 0.5
525
+
526
+ diff = abs(obj_rooms - target_rooms)
527
+
528
+ if diff == 0:
529
+ return 1.0
530
+ elif diff == 1:
531
+ return 0.6
532
+ elif diff == 2:
533
+ return 0.3
534
+ else:
535
+ return 0.1
536
+
537
+
538
+ def _calculate_area_score(obj_area: Optional[float], target_area: Optional[float], tolerance_percent: float = 15.0) -> float:
539
+ """
540
+ Вычисление score по площади.
541
+
542
+ Аналогично цене, но с меньшим допуском.
543
+ """
544
+ if obj_area is None or target_area is None:
545
+ return 0.5
546
+
547
+ if target_area == 0:
548
+ return 0.5
549
+
550
+ deviation_percent = abs(obj_area - target_area) / target_area * 100
551
+
552
+ if deviation_percent <= tolerance_percent:
553
+ return 1.0 - (deviation_percent / tolerance_percent) * 0.3
554
+ else:
555
+ extra_deviation = deviation_percent - tolerance_percent
556
+ score = 0.7 - (extra_deviation / 50) * 0.7
557
+ return max(0.0, score)
558
+
559
+
560
+ def _passes_hard_filters(metadata: Dict[str, Any], filters: Optional[HardFilters]) -> bool:
561
+ """Проверка прохождения жёстких фильтров."""
562
+ if filters is None:
563
+ return True
564
+
565
+ # Фильтр по цене
566
+ if filters.price:
567
+ obj_price = metadata.get("price")
568
+ if obj_price is not None:
569
+ if filters.price.min_price and obj_price < filters.price.min_price:
570
+ return False
571
+ if filters.price.max_price and obj_price > filters.price.max_price:
572
+ return False
573
+
574
+ # Фильтр по районам
575
+ if filters.districts:
576
+ obj_district = metadata.get("district", "").lower().strip()
577
+ allowed = [d.lower().strip() for d in filters.districts]
578
+ if obj_district and obj_district not in allowed:
579
+ # Проверяем частичное совпадение
580
+ if not any(a in obj_district or obj_district in a for a in allowed):
581
+ return False
582
+
583
+ # Фильтр по комнатам
584
+ if filters.rooms:
585
+ obj_rooms = metadata.get("rooms")
586
+ if obj_rooms is not None and obj_rooms not in filters.rooms:
587
+ return False
588
+
589
+ # Фильтр по площади
590
+ obj_area = metadata.get("area")
591
+ if obj_area is not None:
592
+ if filters.min_area and obj_area < filters.min_area:
593
+ return False
594
+ if filters.max_area and obj_area > filters.max_area:
595
+ return False
596
+
597
+ return True
598
+
599
+
600
+ def _generate_match_explanation(
601
+ price_score: float,
602
+ district_score: float,
603
+ rooms_score: float,
604
+ area_score: float,
605
+ semantic_score: float,
606
+ metadata: Dict[str, Any]
607
+ ) -> str:
608
+ """Генерация человеко-читаемого объяснения матча."""
609
+ reasons = []
610
+
611
+ if price_score >= 0.7:
612
+ price = metadata.get("price")
613
+ if price:
614
+ reasons.append(f"цена {price:,.0f}₽ в бюджете")
615
+
616
+ if district_score >= 0.7:
617
+ district = metadata.get("district")
618
+ if district:
619
+ reasons.append(f"район '{district}' подходит")
620
+
621
+ if rooms_score >= 0.7:
622
+ rooms = metadata.get("rooms")
623
+ if rooms:
624
+ reasons.append(f"{rooms}-комн. как нужно")
625
+
626
+ if area_score >= 0.7:
627
+ area = metadata.get("area")
628
+ if area:
629
+ reasons.append(f"площадь {area}м² подходит")
630
+
631
+ if semantic_score >= 0.6:
632
+ reasons.append("описание похоже на запрос")
633
+
634
+ if not reasons:
635
+ return "Частичное совпадение по параметрам"
636
+
637
+ return "; ".join(reasons)
638
+
639
+
640
+ @app.post("/match", response_model=MatchResponse)
641
+ async def match_by_embedding(request: MatchRequest):
642
+ """
643
+ Поиск похожих объектов по эмбеддингу.
644
+
645
+ Возвращает top_k наиболее похожих объектов указанного типа.
646
+ """
647
+ if request.entity_type not in embedding_store:
648
+ raise HTTPException(
649
+ status_code=400,
650
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
651
+ )
652
+
653
+ store = embedding_store[request.entity_type]
654
+ if not store:
655
+ return MatchResponse(matches=[], total_searched=0)
656
+
657
+ query_vec = np.array(request.embedding)
658
+
659
+ # Вычисляем схожесть со всеми объектами
660
+ similarities = []
661
+ for entity_id, data in store.items():
662
+ stored_vec = np.array(data["embedding"])
663
+ similarity = _cosine_similarity(query_vec, stored_vec)
664
+ if similarity >= request.min_similarity:
665
+ similarities.append((entity_id, similarity, data.get("metadata")))
666
+
667
+ # Сортируем по убыванию схожести и берем top_k
668
+ similarities.sort(key=lambda x: x[1], reverse=True)
669
+ top_matches = similarities[:request.top_k]
670
+
671
+ matches = [
672
+ MatchResult(entity_id=eid, similarity=sim, metadata=meta)
673
+ for eid, sim, meta in top_matches
674
+ ]
675
+
676
+ return MatchResponse(matches=matches, total_searched=len(store))
677
+
678
+
679
+ @app.post("/match-text", response_model=MatchResponse)
680
+ async def match_by_text(request: MatchTextRequest):
681
+ """
682
+ Поиск похожих объектов по тексту.
683
+
684
+ Генерирует эмбеддинг для текста и ищет похожие объекты.
685
+ """
686
+ if model is None:
687
+ raise HTTPException(status_code=503, detail="Model not loaded")
688
+
689
+ if request.entity_type not in embedding_store:
690
+ raise HTTPException(
691
+ status_code=400,
692
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
693
+ )
694
+
695
+ store = embedding_store[request.entity_type]
696
+ if not store:
697
+ return MatchResponse(matches=[], total_searched=0)
698
+
699
+ try:
700
+ # Генерируем эмбеддинг для текста запроса
701
+ query_embedding = model.encode(request.text, convert_to_numpy=True)
702
+ query_vec = np.array(query_embedding)
703
+
704
+ # Вычисляем схожесть со всеми объектами
705
+ similarities = []
706
+ for entity_id, data in store.items():
707
+ stored_vec = np.array(data["embedding"])
708
+ similarity = _cosine_similarity(query_vec, stored_vec)
709
+ if similarity >= request.min_similarity:
710
+ similarities.append((entity_id, similarity, data.get("metadata")))
711
+
712
+ # Сортируем по убыванию схожести и берем top_k
713
+ similarities.sort(key=lambda x: x[1], reverse=True)
714
+ top_matches = similarities[:request.top_k]
715
+
716
+ matches = [
717
+ MatchResult(entity_id=eid, similarity=sim, metadata=meta)
718
+ for eid, sim, meta in top_matches
719
+ ]
720
+
721
+ return MatchResponse(matches=matches, total_searched=len(store))
722
+ except Exception as e:
723
+ raise HTTPException(status_code=500, detail=f"Match by text failed: {str(e)}")
724
+
725
+
726
+ @app.post("/register", response_model=RegisterResponse)
727
+ async def register_embedding(request: RegisterEmbeddingRequest):
728
+ """
729
+ Регистрация объекта с автоматич��ской генерацией эмбеддинга.
730
+
731
+ Используется для добавления лидов или объектов недвижимости в хранилище.
732
+ """
733
+ if model is None:
734
+ raise HTTPException(status_code=503, detail="Model not loaded")
735
+
736
+ if request.entity_type not in embedding_store:
737
+ raise HTTPException(
738
+ status_code=400,
739
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
740
+ )
741
+
742
+ try:
743
+ # Генерируем эмбеддинг
744
+ embedding = model.encode(request.text, convert_to_numpy=True)
745
+
746
+ # Сохраняем в хранилище
747
+ embedding_store[request.entity_type][request.entity_id] = {
748
+ "embedding": embedding.tolist(),
749
+ "metadata": request.metadata or {}
750
+ }
751
+
752
+ return RegisterResponse(
753
+ success=True,
754
+ entity_id=request.entity_id,
755
+ entity_type=request.entity_type
756
+ )
757
+ except Exception as e:
758
+ raise HTTPException(status_code=500, detail=f"Register embedding failed: {str(e)}")
759
+
760
+
761
+ @app.post("/register-vector", response_model=RegisterResponse)
762
+ async def register_embedding_from_vector(request: RegisterEmbeddingFromVectorRequest):
763
+ """
764
+ Регистрация объекта с готовым эмбеддингом.
765
+
766
+ Используется когда эмбеддинг уже был сгенерирован ранее.
767
+ """
768
+ if request.entity_type not in embedding_store:
769
+ raise HTTPException(
770
+ status_code=400,
771
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
772
+ )
773
+
774
+ # Сохраняем в хранилище
775
+ embedding_store[request.entity_type][request.entity_id] = {
776
+ "embedding": request.embedding,
777
+ "metadata": request.metadata or {}
778
+ }
779
+
780
+ return RegisterResponse(
781
+ success=True,
782
+ entity_id=request.entity_id,
783
+ entity_type=request.entity_type
784
+ )
785
+
786
+
787
+ @app.delete("/register", response_model=RegisterResponse)
788
+ async def delete_embedding(request: DeleteEmbeddingRequest):
789
+ """
790
+ Удаление эмбеддинга объекта из хранилища.
791
+ """
792
+ if request.entity_type not in embedding_store:
793
+ raise HTTPException(
794
+ status_code=400,
795
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
796
+ )
797
+
798
+ store = embedding_store[request.entity_type]
799
+ if request.entity_id not in store:
800
+ raise HTTPException(
801
+ status_code=404,
802
+ detail=f"Entity {request.entity_id} not found in {request.entity_type}"
803
+ )
804
+
805
+ del store[request.entity_id]
806
+
807
+ return RegisterResponse(
808
+ success=True,
809
+ entity_id=request.entity_id,
810
+ entity_type=request.entity_type
811
+ )
812
+
813
+
814
+ @app.get("/store/stats", response_model=StoreStatsResponse)
815
+ async def get_store_stats():
816
+ """
817
+ Получение статистики хранилища эмбеддингов.
818
+ """
819
+ leads_count = len(embedding_store.get("leads", {}))
820
+ properties_count = len(embedding_store.get("properties", {}))
821
+
822
+ return StoreStatsResponse(
823
+ leads_count=leads_count,
824
+ properties_count=properties_count,
825
+ total_count=leads_count + properties_count
826
+ )
827
+
828
+
829
+ @app.get("/store/{entity_type}")
830
+ async def list_registered_entities(entity_type: str):
831
+ """
832
+ Список зарегистрированных объектов указанного типа.
833
+ """
834
+ if entity_type not in embedding_store:
835
+ raise HTTPException(
836
+ status_code=400,
837
+ detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
838
+ )
839
+
840
+ store = embedding_store[entity_type]
841
+ entities = [
842
+ {
843
+ "entity_id": eid,
844
+ "metadata": data.get("metadata", {}),
845
+ "embedding_dimensions": len(data.get("embedding", []))
846
+ }
847
+ for eid, data in store.items()
848
+ ]
849
+
850
+ return {"entity_type": entity_type, "count": len(entities), "entities": entities}
851
+
852
+
853
+ # --- Bulk Indexing Endpoints ---
854
+
855
+ @app.post("/index/bulk", response_model=BulkIndexResponse)
856
+ async def bulk_index(request: BulkIndexRequest):
857
+ """
858
+ Массовая индексация объектов.
859
+
860
+ Позволяет за один запрос проиндексировать множество лидов или объектов.
861
+ Используется для первоначальной загрузки данных или переиндексации.
862
+
863
+ Пример:
864
+ ```
865
+ POST /index/bulk
866
+ {
867
+ "entity_type": "properties",
868
+ "items": [
869
+ {"entity_id": "prop-1", "text": "3-комнатная квартира в центре", "metadata": {"price": 10000000}},
870
+ {"entity_id": "prop-2", "text": "Студия у метро", "metadata": {"price": 5000000}}
871
+ ],
872
+ "clear_existing": false
873
+ }
874
+ ```
875
+ """
876
+ if model is None:
877
+ raise HTTPException(status_code=503, detail="Model not loaded")
878
+
879
+ if request.entity_type not in embedding_store:
880
+ raise HTTPException(
881
+ status_code=400,
882
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
883
+ )
884
+
885
+ # Очистка если нужно
886
+ if request.clear_existing:
887
+ embedding_store[request.entity_type] = {}
888
+
889
+ results: List[BulkIndexResult] = []
890
+ indexed = 0
891
+ failed = 0
892
+
893
+ # Собираем все тексты для батчевой генерации эмбеддингов (быстрее)
894
+ texts = [item.text for item in request.items]
895
+
896
+ try:
897
+ # Генерируем все эмбеддинги за один вызов модели
898
+ embeddings = model.encode(texts, convert_to_numpy=True, show_progress_bar=True)
899
+
900
+ # Сохраняем каждый
901
+ for i, item in enumerate(request.items):
902
+ try:
903
+ embedding_store[request.entity_type][item.entity_id] = {
904
+ "embedding": embeddings[i].tolist(),
905
+ "metadata": item.metadata or {}
906
+ }
907
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
908
+ indexed += 1
909
+ except Exception as e:
910
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(e)))
911
+ failed += 1
912
+ except Exception as e:
913
+ # Если батч не удался, пробуем по одному
914
+ for item in request.items:
915
+ try:
916
+ embedding = model.encode(item.text, convert_to_numpy=True)
917
+ embedding_store[request.entity_type][item.entity_id] = {
918
+ "embedding": embedding.tolist(),
919
+ "metadata": item.metadata or {}
920
+ }
921
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=True))
922
+ indexed += 1
923
+ except Exception as item_error:
924
+ results.append(BulkIndexResult(entity_id=item.entity_id, success=False, error=str(item_error)))
925
+ failed += 1
926
+
927
+ return BulkIndexResponse(
928
+ total=len(request.items),
929
+ indexed=indexed,
930
+ failed=failed,
931
+ results=results
932
+ )
933
+
934
+
935
+ @app.delete("/index/{entity_type}")
936
+ async def clear_index(entity_type: str):
937
+ """
938
+ Очистка индекса для указанного типа сущностей.
939
+
940
+ Удаляет все эмбеддинги указанного типа.
941
+ """
942
+ if entity_type not in embedding_store:
943
+ raise HTTPException(
944
+ status_code=400,
945
+ detail=f"Unknown entity type: {entity_type}. Allowed: leads, properties"
946
+ )
947
+
948
+ count = len(embedding_store[entity_type])
949
+ embedding_store[entity_type] = {}
950
+
951
+ return {"message": f"Cleared {count} {entity_type} from index", "deleted_count": count}
952
+
953
+
954
+ @app.post("/index/sync")
955
+ async def sync_index_info():
956
+ """
957
+ Получение информации для синхронизации.
958
+
959
+ Возвращает список всех entity_id в индексе, чтобы Go Backend мог
960
+ определить какие объекты нужно добавить/удалить.
961
+ """
962
+ return {
963
+ "leads": list(embedding_store["leads"].keys()),
964
+ "properties": list(embedding_store["properties"].keys())
965
+ }
966
+
967
+
968
+ # --- Weighted Matching Endpoint ---
969
+
970
+ @app.post("/match-weighted", response_model=WeightedMatchResponse)
971
+ async def match_weighted(request: WeightedMatchRequest):
972
+ """
973
+ Взвешенный матчинг с настраиваемыми приоритетами параметров.
974
+
975
+ Позволяет задать:
976
+ - Веса для каждого параметра (цена, район, комнаты, площадь, семантика)
977
+ - Жёсткие фильтры (объекты не прошедшие - исключаются)
978
+ - Мягкие критерии (влияют на ранжирование)
979
+
980
+ Пример использования:
981
+ ```json
982
+ {
983
+ "text": "Ищу 2-комнатную квартиру в центре до 10 млн",
984
+ "entity_type": "properties",
985
+ "top_k": 10,
986
+ "weights": {
987
+ "price": 0.35, // Цена - главный приоритет
988
+ "district": 0.30, // Район - второй по важности
989
+ "rooms": 0.20, // Комнаты
990
+ "area": 0.05, // Площадь менее важна
991
+ "semantic": 0.10 // Семантика для "мягких" критериев
992
+ },
993
+ "hard_filters": {
994
+ "price": {"max_price": 12000000},
995
+ "districts": ["Центральный", "Арбат", "Тверской"]
996
+ },
997
+ "soft_criteria": {
998
+ "target_price": 10000000,
999
+ "target_rooms": 2,
1000
+ "target_district": "Центральный"
1001
+ }
1002
+ }
1003
+ ```
1004
+ """
1005
+ if model is None:
1006
+ raise HTTPException(status_code=503, detail="Model not loaded")
1007
+
1008
+ if request.entity_type not in embedding_store:
1009
+ raise HTTPException(
1010
+ status_code=400,
1011
+ detail=f"Unknown entity type: {request.entity_type}. Allowed: leads, properties"
1012
+ )
1013
+
1014
+ store = embedding_store[request.entity_type]
1015
+ if not store:
1016
+ return WeightedMatchResponse(
1017
+ matches=[],
1018
+ total_searched=0,
1019
+ filtered_out=0,
1020
+ weights_used=request.weights or ParameterWeights()
1021
+ )
1022
+
1023
+ # Используем переданные веса или значения по умолчанию
1024
+ weights = request.weights or ParameterWeights()
1025
+
1026
+ # Нормализуем веса чтобы сумма = 1
1027
+ total_weight = weights.price + weights.district + weights.rooms + weights.area + weights.semantic
1028
+ if total_weight > 0:
1029
+ w_price = weights.price / total_weight
1030
+ w_district = weights.district / total_weight
1031
+ w_rooms = weights.rooms / total_weight
1032
+ w_area = weights.area / total_weight
1033
+ w_semantic = weights.semantic / total_weight
1034
+ else:
1035
+ w_price = w_district = w_rooms = w_area = w_semantic = 0.2
1036
+
1037
+ # Генерируем эмбеддинг для текста запроса
1038
+ try:
1039
+ query_embedding = model.encode(request.text, convert_to_numpy=True)
1040
+ query_vec = np.array(query_embedding)
1041
+ except Exception as e:
1042
+ raise HTTPException(status_code=500, detail=f"Failed to generate embedding: {str(e)}")
1043
+
1044
+ # Извлекаем soft criteria
1045
+ soft = request.soft_criteria or SoftCriteria()
1046
+
1047
+ results = []
1048
+ filtered_out = 0
1049
+
1050
+ for entity_id, data in store.items():
1051
+ metadata = data.get("metadata", {})
1052
+
1053
+ # 1. Проверяем жёсткие фильтры
1054
+ if not _passes_hard_filters(metadata, request.hard_filters):
1055
+ filtered_out += 1
1056
+ continue
1057
+
1058
+ # 2. Вычисляем score по каждому параметру
1059
+
1060
+ # Цена
1061
+ price_score = _calculate_price_score(
1062
+ metadata.get("price"),
1063
+ soft.target_price,
1064
+ tolerance_percent=20.0
1065
+ )
1066
+
1067
+ # Район
1068
+ district_score = _calculate_district_score(
1069
+ metadata.get("district"),
1070
+ soft.target_district,
1071
+ soft.preferred_districts
1072
+ )
1073
+
1074
+ # Комнаты
1075
+ rooms_score = _calculate_rooms_score(
1076
+ metadata.get("rooms"),
1077
+ soft.target_rooms
1078
+ )
1079
+
1080
+ # Площадь
1081
+ area_score = _calculate_area_score(
1082
+ metadata.get("area"),
1083
+ soft.target_area
1084
+ )
1085
+
1086
+ # Семантика
1087
+ stored_vec = np.array(data["embedding"])
1088
+ semantic_score = _cosine_similarity(query_vec, stored_vec)
1089
+ # Нормализуем в 0-1 (косинусная близость может быть отрицательной)
1090
+ semantic_score = (semantic_score + 1) / 2
1091
+
1092
+ # 3. Вычисляем взвешенный total score
1093
+ total_score = (
1094
+ w_price * price_score +
1095
+ w_district * district_score +
1096
+ w_rooms * rooms_score +
1097
+ w_area * area_score +
1098
+ w_semantic * semantic_score
1099
+ )
1100
+
1101
+ # Пропускаем если ниже минимального порога
1102
+ if total_score < request.min_total_score:
1103
+ continue
1104
+
1105
+ # Генерируем объяснение
1106
+ explanation = _generate_match_explanation(
1107
+ price_score, district_score, rooms_score, area_score, semantic_score, metadata
1108
+ )
1109
+
1110
+ results.append(WeightedMatchResult(
1111
+ entity_id=entity_id,
1112
+ total_score=round(total_score, 4),
1113
+ price_score=round(price_score, 4),
1114
+ district_score=round(district_score, 4),
1115
+ rooms_score=round(rooms_score, 4),
1116
+ area_score=round(area_score, 4),
1117
+ semantic_score=round(semantic_score, 4),
1118
+ metadata=metadata,
1119
+ match_explanation=explanation
1120
+ ))
1121
+
1122
+ # Сортируем по total_score и берём top_k
1123
+ results.sort(key=lambda x: x.total_score, reverse=True)
1124
+ top_results = results[:request.top_k]
1125
+
1126
+ return WeightedMatchResponse(
1127
+ matches=top_results,
1128
+ total_searched=len(store),
1129
+ filtered_out=filtered_out,
1130
+ weights_used=weights
1131
+ )
1132
+
1133
+
1134
+ @app.get("/weights/presets")
1135
+ async def get_weight_presets():
1136
+ """
1137
+ Получить предустановленные наборы весов для разных сценариев.
1138
+
1139
+ Помогает фронтенду предложить пользователю готовые настройки.
1140
+ """
1141
+ return {
1142
+ "balanced": {
1143
+ "name": "Сбалансированный",
1144
+ "description": "Равномерное распределение приоритетов",
1145
+ "weights": {"price": 0.25, "district": 0.25, "rooms": 0.20, "area": 0.15, "semantic": 0.15}
1146
+ },
1147
+ "budget_first": {
1148
+ "name": "Бюджет важнее всего",
1149
+ "description": "Максимальный приоритет на соответствие бюджету",
1150
+ "weights": {"price": 0.45, "district": 0.20, "rooms": 0.15, "area": 0.10, "semantic": 0.10}
1151
+ },
1152
+ "location_first": {
1153
+ "name": "Локация важнее всего",
1154
+ "description": "Район и расположение - главный приоритет",
1155
+ "weights": {"price": 0.20, "district": 0.40, "rooms": 0.15, "area": 0.10, "semantic": 0.15}
1156
+ },
1157
+ "family": {
1158
+ "name": "Для семьи",
1159
+ "description": "Важны комнаты и площадь",
1160
+ "weights": {"price": 0.20, "district": 0.20, "rooms": 0.30, "area": 0.20, "semantic": 0.10}
1161
+ },
1162
+ "semantic_heavy": {
1163
+ "name": "Умный поиск",
1164
+ "description": "Максимальный приоритет на семантическое понимание запроса",
1165
+ "weights": {"price": 0.15, "district": 0.15, "rooms": 0.15, "area": 0.10, "semantic": 0.45}
1166
+ }
1167
+ }
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
+
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
+