diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..997ab0a --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# PostgreSQL (asyncpg) +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vmk_data + +# DB Pool +DB_POOL_MIN_SIZE=2 +DB_POOL_MAX_SIZE=10 +DB_QUERY_TIMEOUT=30 + +# Ollama API +OLLAMA_BASE_URL=http://192.168.1.75:11434 +OLLAMA_EMBED_MODEL=nomic-embed-text +OLLAMA_EMBED_DIMENSIONS=768 +OLLAMA_REQUEST_TIMEOUT=60.0 + +# MCP Server +MCP_SERVER_NAME=vmk-data-mcp +MCP_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b933db8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.env +.venv/ +venv/ +ENV/ +dist/ +build/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f244d80 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка системных зависимостей для asyncpg +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Копируем зависимости и ставим +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e "." + +# Копируем код +COPY src/ ./src/ + +# Переустановка в editable mode после копирования кода +RUN pip install --no-cache-dir -e "." + +EXPOSE 8080 + +CMD ["python", "-m", "vmk_data_mcp.main"] diff --git a/README.md b/README.md index 3dc95d0..69d558e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,56 @@ -vmk-360_data_mcp -=============== +# VMK Data MCP Server + +MCP-сервер для поиска по базе данных недвижимости (`vmk_data`). +Предоставляет AI-агенту (Claude) инструменты для: +- **семантического (векторного) поиска** через pgvector + HNSW +- **полнотекстового поиска** через готовую FTS-колонку (украинский конфиг) +- **фильтрации по метаданным** (цена, район, комнаты, метро и т.д.) +- **пагинации и сортировки** по релевантности + +## Транспорт + +**Streamable HTTP** (MCP 2025 spec) на порту **8080**, endpoint `/mcp`. +Поддерживает как GET (SSE-стрим), так и POST (отправка сообщений). + +## Инструменты + +| Инструмент | Описание | +|------------|----------| +| `search_similar_listings` | Векторный поиск по смыслу запроса + фильтры | +| `search_by_metadata` | Полнотекстовый поиск (FTS) + фильтры | +| `get_listing_by_id` | Получить объявление по ID | +| `describe_schema` | Описание схемы БД для AI-агента | + +## Язык + +База данных содержит украинский текст. **Все запросы должны быть на украинском** — +AI-агент сам переводит запрос пользователя перед вызовом инструмента. + +## Запуск + +### Локально (для разработки) + +```bash +# .env скопирован из .env.example и отредактирован +pip install -e "." +python -m vmk_data_mcp.main +``` + +### Docker + +```bash +docker-compose up --build +``` + +## Зависимости + +- Python 3.11+ +- PostgreSQL 15+ с `pgvector` extension +- Ollama (`nomic-embed-text`, 768d) на `192.168.1.75:11434` + +## Безопасность + +- **Read-only** пул соединений (only SELECT / WITH / VALUES / EXPLAIN) +- Параметризованные запросы (SQL-инъекции невозможны) +- Белый список колонок (`USER_COLUMNS`) +- Таймаут запросов (30 сек) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b1f935b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + mcp-server: + build: . + container_name: vmk-data-mcp + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5433/vmk_data + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + - MCP_PORT=8080 + env_file: + - .env + restart: unless-stopped + # Для Linux host.docker.internal работает с extra_hosts + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..6317a98 --- /dev/null +++ b/plan.md @@ -0,0 +1,278 @@ +# MCP-сервер для пошуку по базі даних нерухомості + +## Контекст +Проєкт: **vmk-data-mcp** — MCP-сервер для vector + metadata пошуку по PostgreSQL-базі `vmk_data` (оголошення про нерухомість). Сервер надає інструменти AI-агенту (Claude) для семантичного та структурованого пошуку оголошень. + +**Мова**: база даних містить переважно **український** текст. Всі текстові запити до `search_similar_listings` та `search_by_metadata` повинні бути на українській мові. AI-агент самостійно перекладає запити користувача перед викликом інструментів. + +## Архітектурні рішення + +### Стек +- **Python 3.11+** +- **MCP Python SDK v1.x** (`FastMCP`) — стабільна версія +- **Transport**: `streamable-http` (рекомендований у 2025 замість SSE) +- **БД**: PostgreSQL + pgvector (вже розгорнуті в `data_collector`) +- **Драйвер**: `asyncpg` (async) + `pgvector.asyncpg` для типів +- **Конфіг**: Pydantic Settings (`pydantic-settings`) з `.env` +- **Embeddings**: `nomic-embed-text` (768d) через Ollama API (`ollama` SDK або `httpx`) + +### Принципи безпеки +- **Тільки SELECT**: сервер відкриває read-only пул з'єднань (`SET default_transaction_read_only = on`) +- **Параметризовані запити**: захист від SQL-ін'єкцій +- **Обмеження**: `MAX_LIMIT = 50`, `QUERY_TIMEOUT = 10s` +- **Жодного DDL/DML**: запити будуються через whitelist колонок +- **Embeddings isolation**: клієнт передає `query_text`, сервер сам генерує embedding через внутрішній embedder + +## Структура проєкту + +``` +vmk-data-mcp/ +├── src/ +│ └── vmk_data_mcp/ +│ ├── __init__.py +│ ├── main.py # точка входу, запуск FastMCP (streamable-http) +│ ├── config.py # Pydantic Settings (DB, Ollama, server) +│ ├── db.py # asyncpg pool + register_vector + read-only guard +│ ├── embedder.py # абстракція Embedder (Ollama nomic-embed-text) +│ ├── tools/ +│ │ ├── __init__.py +│ │ ├── search.py # векторний пошук: query_text → embedding → pgvector +│ │ ├── listings.py # отримання оголошення за ID +│ │ ├── metadata_search.py # текстовий/структурований пошук (FTS + фільтри) +│ │ └── schema_info.py # статичний опис схеми, enums, фільтри, приклади +│ └── resources/ # відкладено до наступних ітерацій +│ └── __init__.py +├── pyproject.toml +├── .env.example +├── Dockerfile +└── README.md +``` + +## Компоненти + +### Embedder (`embedder.py`) +```python +class Embedder(Protocol): + async def embed(self, texts: list[str]) -> list[list[float]]: ... + +class OllamaEmbedder: + """Ollama API для nomic-embed-text (768d).""" + # Конфіг: OLLAMA_HOST, OLLAMA_MODEL=nomic-embed-text + # Використовує ollama.embed() або httpx POST /api/embed + # Кешування розмірності на старті (validate 768d) +``` + +### DB (`db.py`) +- `asyncpg.create_pool(...)` з `min_size`/`max_size` з конфіга +- `register_vector(conn)` при ініціалізації з'єднання +- `pgvector` типи: `Vector(768)` +- Read-only guard: `SET default_transaction_read_only = on` при коннекті +- Query timeout: `asyncio.wait_for` або `command_timeout` в asyncpg + +## Інструменти (Tools) + +### 1. `search_similar_listings` +Семантичний векторний пошук. Клієнт передає текст українською, сервер сам генерує embedding і шукає найближчих сусідів. + +**Вхідні параметри**: +- `query_text`: `str` — текстовий запит українською (наприклад: "2-кімнатна квартира біля метро, новобудова") +- `limit`: `int` (default=10, max=50) +- `offset`: `int` (default=0) +- `filters`: `dict` — опціональні фільтри (whitelist полів): + - `city`, `district`, `deal_type`, `property_type_id` + - `min_price`, `max_price` + - `min_rooms`, `max_rooms` + - `listing_status` (default=`active`) + - `min_area`, `max_area` + - `has_balcony`, `building_type`, `renovation_status` +- `distance_metric`: `enum` — `cosine` (default), `l2`, `inner_product` + +**Логіка**: +1. Генерувати `embedding[768]` через `Embedder.embed([query_text])` +2. Побудувати `WHERE` з безпечного whitelist (parametrized) +3. Виконати: + ```sql + SELECT id, title, description, price, city, district, rooms_count, + embedding <=> $1 AS distance + FROM property_listings + WHERE ... + ORDER BY distance + LIMIT $n OFFSET $m + ``` +4. Повернути JSON: список оголошень + `similarity_score` (1 - distance для cosine) + +**Сортування**: за `distance` ASC (чим менше, тим релевантніше). Для cosine: `similarity_score = 1 - distance`. + +--- + +### 2. `search_by_metadata` +Текстовий/структурований пошук без векторів, з сортуванням та пагінацією. Використовує готову FTS-колонку `search_vector` з `data_collector`. + +**Вхідні параметри**: +- `query`: `str | None` — пошук по `search_vector` (FTS) або `title`/`description`/`address_raw` (ILIKE fallback) +- `filters`: ті ж, що в `search_similar_listings` +- `sort_by`: `enum` — `relevance` (default), `price_asc`, `price_desc`, `date_desc`, `area_desc` +- `limit`: `int` (default=10, max=50) +- `offset`: `int` (default=0) + +**Логіка**: +1. Якщо `query` задано: + - **Primary**: `WHERE search_vector @@ plainto_tsquery('ukrainian', $1)` (використовує GIN-індекс `ix_property_listings_search_vector_gin`) + - **Fallback**: якщо FTS не дав результатів — `WHERE (title ILIKE $1 OR description ILIKE $1 OR address_raw ILIKE $1)` +2. Застосувати `filters` (whitelist) +3. Сортування: + - `relevance`: `ts_rank_cd(search_vector, plainto_tsquery('ukrainian', $1)) DESC` (використовує готову колонку) + - `price_asc`/`price_desc`: `price ASC/DESC` + - `date_desc`: `publish_date DESC` + - `area_desc`: `total_area DESC` +4. `LIMIT $n OFFSET $m` +5. `total_count`: окремий `SELECT COUNT(*)` з тими ж WHERE-умовами + +**Вихід**: список оголошень + `total_count` (для UI пагінації) + +--- + +### 3. `get_listing_by_id` +Повна картка оголошення. + +**Вхідні параметри**: +- `listing_id`: `int` + +**Вихід**: всі "пользовательські" поля `property_listings` + `property_types.name` (JOIN). Поля включають: `title`, `description`, `generated_description`, `price`, `currency`, `city`, `district`, `rooms_count`, `total_area`, `floor`, `building_type`, `metro_station`, `url_source`, `publish_date`, `images_count`, `contact_phone`, `contact_name`, та інші. Без службових (`raw_data_id`, `embedding`, `created_at`, `updated_at`). + +--- + +### 4. `describe_schema` +Статичний опис схеми. Дані захардкожені з `data_collector` моделей. + +**Вхідні параметри**: відсутні + +**Вихід**: +```json +{ + "tables": [ + { + "name": "property_listings", + "description": "Оголошення про нерухомість", + "columns": ["id", "title", "description", "price", "city", "rooms_count", ...], + "indexes": [ + "ix_property_listings_embedding_hnsw (hnsw vector_cosine_ops)", + "ix_property_listings_search_vector_gin (gin)", + "ix_property_listings_city (btree)", + "ix_property_listings_price (btree)" + ] + }, + { + "name": "property_types", + "description": "Типи нерухомості", + "columns": ["id", "slug", "name", "description"] + } + ], + "enums": { + "deal_type": ["sale", "rent_long", "rent_short"], + "listing_status": ["active", "sold", "rented", "removed", "archived"], + "building_type": ["brick", "panel", "monolith", "gas_block", "wood"], + "renovation_status": ["cosmetic", "euro", "designer", "none", "construction"], + "bathroom_type": ["combined", "separate", "multiple"], + "parking_type": ["ground", "underground", "none", "garage"], + "heating_type": ["central", "autonomous", "floor", "none"], + "layout_type": ["studio", "separate", "adjacent"], + "window_view": ["yard", "street", "park", "water", "forest"], + "metro_distance_type": ["walk", "transport"] + }, + "available_filters": [ + "city", "district", "deal_type", "property_type_id", + "min_price", "max_price", "min_rooms", "max_rooms", + "listing_status", "min_area", "max_area", + "has_balcony", "building_type", "renovation_status" + ], + "embedding_model": "nomic-embed-text (768d)", + "language": "ukrainian", + "sample_queries": [ + "2-кімнатна квартира в центрі до 2 млн грн", + "Новобудова з балконом біля метро", + "Будинок з ділянкою в передмісті" + ] +} +``` + +## Конфігурація (.env) + +```env +# Database (from data_collector docker-compose) +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=vmk_data +DB_USER=postgres +DB_PASSWORD=postgres +DB_POOL_MIN=1 +DB_POOL_MAX=10 + +# Ollama (embeddings) — already deployed on 192.168.1.75 +OLLAMA_HOST=http://192.168.1.75:11434 +OLLAMA_MODEL=nomic-embed-text +OLLAMA_TIMEOUT_SEC=30 + +# Server +MCP_SERVER_NAME=vmk-data-mcp +MCP_HOST=0.0.0.0 +MCP_PORT=3000 +MCP_TRANSPORT=streamable-http +MCP_STATELESS_HTTP=true + +# Safety +MAX_LIMIT=50 +QUERY_TIMEOUT_SEC=10 +``` + +## План реалізації + +### Етап 1: Каркас +- [ ] `pyproject.toml` з залежностями: `mcp[cli]`, `asyncpg`, `pgvector`, `pydantic-settings`, `ollama` +- [ ] `config.py` — Pydantic Settings (DB + Ollama + server) +- [ ] `db.py` — asyncpg pool + `register_vector` + read-only guard +- [ ] `embedder.py` — `OllamaEmbedder` з валідацією 768d на старті +- [ ] `main.py` — FastMCP сервер з `streamable-http` + +### Етап 2: Інструменти +- [ ] `describe_schema` — статичний опис схеми (хардкод з data_collector моделей) +- [ ] `get_listing_by_id` — SELECT + JOIN property_types, тільки користувацькі поля +- [ ] `search_by_metadata` — FTS через `search_vector` (ukrainian) + фільтри + sort_by + pagination + total_count +- [ ] `search_similar_listings` — query_text → embed → vector <=> + фільтри + pagination + +### Етап 3: Доробки +- [ ] Обробка помилок: DB timeout, Ollama недоступний, embedding ≠ 768d +- [ ] Логування: запити, latency, embedding time +- [ ] Graceful shutdown: закриття DB pool + +### Етап 4: Інфраструктура +- [ ] `Dockerfile` (multi-stage, uv/uvicorn) +- [ ] `docker-compose.yml` (тільки MCP-сервер, Ollama окремо) +- [ ] `README.md` — підключення до Claude Desktop / Claude Code +- [ ] `.env.example` + +## Гібридний пошук (майбутнє) + +Інструмент `hybrid_search` (не в MVP): +1. Паралельно виконувати `search_similar_listings` (vector) та `search_by_metadata` (text) +2. Об'єднати результати через Reciprocal Rank Fusion (RRF) +3. Пересортувати та повернути top-N + +## Resources (майбутнє) + +Відкладено до наступних ітерацій: +- `listing://{id}` — JSON з повним оголошенням +- `schema://property_listings` — опис схеми таблиці +- `stats://database` — агрегати (total_listings, price_range, top_cities) + +## Рішення, що прийняті + +| Питання | Рішення | +|---------|---------| +| Async vs Sync драйвер | **asyncpg** (async) | +| FTS індекс | Вже є в `data_collector`: колонка `search_vector` + GIN (`ukrainian` конфіг) | +| `describe_schema` | **Статика** (хардкод з моделей data_collector) | +| Resources | **Відкладено** (не в MVP) | +| `get_listing_by_id` | Всі **користувацькі** поля, без службових (`raw_data_id`, `embedding`, `created_at`) | +| Деплой | Docker-контейнер, Ollama вже є на 192.168.1.75 | +| Мова | **Українська**. AI-агент перекладає запити | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..854a16b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "vmk-data-mcp" +version = "0.1.0" +description = "MCP server for searching VMK real estate database" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.0.0", + "asyncpg>=0.29.0", + "httpx>=0.27.0", + "python-dotenv>=1.0.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/vmk_data_mcp"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B", "C4", "SIM"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/src/vmk_data_mcp/__init__.py b/src/vmk_data_mcp/__init__.py new file mode 100644 index 0000000..1ec57ac --- /dev/null +++ b/src/vmk_data_mcp/__init__.py @@ -0,0 +1,3 @@ +"""VMK Data MCP Server — поиск по базе недвижимости.""" + +__version__ = "0.1.0" diff --git a/src/vmk_data_mcp/config.py b/src/vmk_data_mcp/config.py new file mode 100644 index 0000000..ddd5e19 --- /dev/null +++ b/src/vmk_data_mcp/config.py @@ -0,0 +1,46 @@ +"""Конфигурация сервера — читается из .env и env-переменных.""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # PostgreSQL + database_url: str = Field( + default="postgresql://postgres:postgres@localhost:5432/vmk_data", + description="URL подключения к PostgreSQL (asyncpg)", + ) + db_pool_min_size: int = Field(default=2, description="Минимум соединений в пуле") + db_pool_max_size: int = Field(default=10, description="Максимум соединений в пуле") + db_query_timeout: int = Field( + default=30, description="Таймаут SQL-запросов в секундах" + ) + + # Ollama / Embeddings + ollama_base_url: str = Field( + default="http://192.168.1.75:11434", + description="Базовый URL Ollama API", + ) + ollama_embed_model: str = Field( + default="nomic-embed-text", + description="Модель для генерации эмбеддингов", + ) + ollama_embed_dimensions: int = Field( + default=768, description="Размерность вектора эмбеддинга" + ) + ollama_request_timeout: float = Field( + default=60.0, description="Таймаут запроса к Ollama в секундах" + ) + + # MCP Server + mcp_server_name: str = Field(default="vmk-data-mcp") + mcp_port: int = Field(default=8080, description="Порт HTTP транспорта") + + +settings = Settings() diff --git a/src/vmk_data_mcp/db.py b/src/vmk_data_mcp/db.py new file mode 100644 index 0000000..050c0b1 --- /dev/null +++ b/src/vmk_data_mcp/db.py @@ -0,0 +1,120 @@ +"""Асинхронный пул PostgreSQL с read-only защитой и таймаутами.""" + +from contextlib import asynccontextmanager + +import asyncpg + +from vmk_data_mcp.config import settings + +_pool: asyncpg.Pool | None = None + +# Разрешённые команды начинаются только с SELECT / WITH / VALUES / EXPLAIN +_SAFE_PREFIXES = ("select", "with", "values", "explain") + +# Колонки, доступные для выборки и фильтрации (белый список) +USER_COLUMNS = frozenset( + { + "id", + "title", + "description", + "generated_description", + "price", + "currency", + "deal_type", + "city", + "district", + "rooms_count", + "total_area", + "living_area", + "kitchen_area", + "floor", + "floors_count", + "building_type", + "building_year", + "renovation_status", + "balcony_count", + "bathroom_type", + "parking_type", + "heating_type", + "layout_type", + "window_view", + "metro_station", + "metro_distance_type", + "metro_distance_meters", + "url_source", + "publish_date", + "images_count", + "contact_phone", + "listing_status", + "archived_at", + "created_at", + "updated_at", + "search_vector", + "embedding", + } +) + + +def _is_safe_query(sql: str) -> bool: + """Проверяет, что запрос начинается с безопасного префикса и не содержит + дополнительных команд после точки с запятой.""" + cleaned = sql.strip().lower() + # Запрещаем точку с запятой внутри запроса (multi-statement) + if ";" in cleaned.rstrip(";"): + return False + return any(cleaned.startswith(prefix) for prefix in _SAFE_PREFIXES) + + +async def _init_conn(conn: asyncpg.Connection) -> None: + """Инициализатор нового соединения: включает read-only по умолчанию.""" + await conn.execute("SET default_transaction_read_only = on") + + +async def init_pool() -> asyncpg.Pool: + """Инициализирует пул соединений с PostgreSQL.""" + global _pool + if _pool is not None: + return _pool + + _pool = await asyncpg.create_pool( + settings.database_url, + min_size=settings.db_pool_min_size, + max_size=settings.db_pool_max_size, + command_timeout=settings.db_query_timeout, + init=_init_conn, + ) + return _pool + + +async def close_pool() -> None: + """Закрывает пул соединений.""" + global _pool + if _pool is not None: + await _pool.close() + _pool = None + + +@asynccontextmanager +async def get_connection(): + """Контекстный менеджер для получения соединения из пула.""" + pool = await init_pool() + async with pool.acquire() as conn: + yield conn + + +async def fetch(sql: str, *args) -> list[asyncpg.Record]: + """Выполняет SELECT-запрос и возвращает строки.""" + if not _is_safe_query(sql): + raise ValueError("Only read-only queries are allowed") + + async with get_connection() as conn: + return await conn.fetch(sql, *args) + + +async def fetchrow(sql: str, *args) -> asyncpg.Record | None: + """Выполняет SELECT-запрос и возвращает одну строку.""" + if not _is_safe_query(sql): + raise ValueError("Only read-only queries are allowed") + + async with get_connection() as conn: + return await conn.fetchrow(sql, *args) diff --git a/src/vmk_data_mcp/embedder.py b/src/vmk_data_mcp/embedder.py new file mode 100644 index 0000000..9ed1f69 --- /dev/null +++ b/src/vmk_data_mcp/embedder.py @@ -0,0 +1,64 @@ +"""Клиент для генерации эмбеддингов через Ollama API.""" + +import httpx + +from vmk_data_mcp.config import settings + +_OLLAMA_CLIENT: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + """Возвращает (или создаёт) асинхронный HTTP-клиент.""" + global _OLLAMA_CLIENT + if _OLLAMA_CLIENT is None: + _OLLAMA_CLIENT = httpx.AsyncClient( + base_url=settings.ollama_base_url, + timeout=httpx.Timeout(settings.ollama_request_timeout), + ) + return _OLLAMA_CLIENT + + +async def get_embedding(text: str) -> list[float]: + """Генерирует вектор эмбеддинга для текста через Ollama. + + Args: + text: Текст для эмбеддинга (должен быть на украинском). + + Returns: + Список float размерностью `ollama_embed_dimensions`. + + Raises: + httpx.HTTPError: при ошибке сети или HTTP. + ValueError: если размерность ответа не совпадает с ожидаемой. + """ + client = _get_client() + payload = { + "model": settings.ollama_embed_model, + "input": text, + } + + response = await client.post("/api/embed", json=payload) + response.raise_for_status() + data = response.json() + + # Ollama /api/embed возвращает "embeddings": [[0.1, 0.2, ...]] + embeddings = data.get("embeddings") + if not embeddings or not isinstance(embeddings, list): + raise ValueError(f"Unexpected Ollama response format: {data}") + + vector = embeddings[0] + if len(vector) != settings.ollama_embed_dimensions: + raise ValueError( + f"Embedding dimension mismatch: expected {settings.ollama_embed_dimensions}, " + f"got {len(vector)}" + ) + + return list(vector) + + +async def close_client() -> None: + """Закрывает HTTP-клиент.""" + global _OLLAMA_CLIENT + if _OLLAMA_CLIENT is not None: + await _OLLAMA_CLIENT.aclose() + _OLLAMA_CLIENT = None diff --git a/src/vmk_data_mcp/main.py b/src/vmk_data_mcp/main.py new file mode 100644 index 0000000..2bcc221 --- /dev/null +++ b/src/vmk_data_mcp/main.py @@ -0,0 +1,206 @@ +"""Точка входа MCP-сервера с HTTP-транспортом (streamable-http).""" + +import json +import logging +from contextlib import asynccontextmanager + +import asyncpg +import httpx +from mcp.server import FastMCP + +from vmk_data_mcp.config import settings +from vmk_data_mcp.db import close_pool, init_pool +from vmk_data_mcp.embedder import close_client +from vmk_data_mcp.models import ( + GetListingInput, + SearchMetadataInput, + SearchSimilarInput, +) +from vmk_data_mcp.tools import ( + describe_schema, + get_listing_by_id, + search_by_metadata, + search_similar_listings, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def _error_json(message: str) -> str: + """Возвращает стандартизированный JSON с ошибкой.""" + return json.dumps({"error": message}, indent=2, ensure_ascii=False) + + +@asynccontextmanager +async def app_lifespan(server): + """Жизненный цикл приложения — инициализация и закрытие ресурсов.""" + logger.info("Initializing DB pool ...") + await init_pool() + logger.info("DB pool ready.") + yield + logger.info("Shutting down ...") + await close_pool() + await close_client() + logger.info("Shutdown complete.") + + +mcp = FastMCP( + settings.mcp_server_name, + host="0.0.0.0", + port=settings.mcp_port, + lifespan=app_lifespan, +) + +# ── Регистрация инструментов ────────────────────────────────────────── + + +@mcp.tool() +async def search_similar_listings_tool( + query: str, + deal_type: str | None = None, + city: str | None = None, + district: str | None = None, + rooms_count: int | None = None, + min_price: float | None = None, + max_price: float | None = None, + currency: str | None = None, + min_total_area: float | None = None, + max_total_area: float | None = None, + building_type: str | None = None, + floor: int | None = None, + listing_status: str | None = None, + metro_station: str | None = None, + limit: int = 20, + offset: int = 0, + min_similarity: float = 0.7, +) -> str: + """🔍 Векторный (семантический) поиск объявлений. + + Находит объявления по **смыслу** запроса, используя pgvector + HNSW. + Поддерживает фильтры по метаданным (цена, район, комнаты и т.д.). + + **Важно:** запрос `query` должен быть на **украинском** языке. + """ + try: + args = SearchSimilarInput( + query=query, + filters={ + "deal_type": deal_type, + "city": city, + "district": district, + "rooms_count": rooms_count, + "min_price": min_price, + "max_price": max_price, + "currency": currency, + "min_total_area": min_total_area, + "max_total_area": max_total_area, + "building_type": building_type, + "floor": floor, + "listing_status": listing_status, + "metro_station": metro_station, + }, + pagination={"limit": limit, "offset": offset}, + min_similarity=min_similarity, + ) + return await search_similar_listings(args) + except httpx.HTTPError as e: + logger.warning("Ollama error: %s", e) + return _error_json(f"Сервис эмбеддингов недоступен: {e}") + except (asyncpg.PostgresError, ValueError) as e: + logger.warning("DB/validation error: %s", e) + return _error_json(f"Ошибка при поиске: {e}") + except Exception as e: + logger.exception("Unexpected error in search_similar_listings") + return _error_json(f"Неожиданная ошибка: {e}") + + +@mcp.tool() +async def search_by_metadata_tool( + query: str, + deal_type: str | None = None, + city: str | None = None, + district: str | None = None, + rooms_count: int | None = None, + min_price: float | None = None, + max_price: float | None = None, + currency: str | None = None, + min_total_area: float | None = None, + max_total_area: float | None = None, + building_type: str | None = None, + floor: int | None = None, + listing_status: str | None = None, + metro_station: str | None = None, + limit: int = 20, + offset: int = 0, +) -> str: + """📋 Полнотекстовый поиск + фильтры по метаданным. + + Ищет по словам в заголовке, описании, городе, районе, метро + через готовую FTS-колонку `search_vector` (украинский конфиг) + GIN-индекс. + Поддерживает те же фильтры, что и `search_similar_listings`. + + **Важно:** запрос `query` должен быть на **украинском** языке. + """ + try: + args = SearchMetadataInput( + query=query, + filters={ + "deal_type": deal_type, + "city": city, + "district": district, + "rooms_count": rooms_count, + "min_price": min_price, + "max_price": max_price, + "currency": currency, + "min_total_area": min_total_area, + "max_total_area": max_total_area, + "building_type": building_type, + "floor": floor, + "listing_status": listing_status, + "metro_station": metro_station, + }, + pagination={"limit": limit, "offset": offset}, + ) + return await search_by_metadata(args) + except (asyncpg.PostgresError, ValueError) as e: + logger.warning("DB/validation error: %s", e) + return _error_json(f"Ошибка при поиске: {e}") + except Exception as e: + logger.exception("Unexpected error in search_by_metadata") + return _error_json(f"Неожиданная ошибка: {e}") + + +@mcp.tool() +async def get_listing_by_id_tool(listing_id: int) -> str: + """📄 Получить полную карточку объявления по ID. + + Возвращает все пользовательские поля объявления. + """ + try: + args = GetListingInput(listing_id=listing_id) + return await get_listing_by_id(args) + except (asyncpg.PostgresError, ValueError) as e: + logger.warning("DB/validation error: %s", e) + return _error_json(f"Ошибка при получении объявления: {e}") + except Exception as e: + logger.exception("Unexpected error in get_listing_by_id") + return _error_json(f"Неожиданная ошибка: {e}") + + +@mcp.tool() +async def describe_schema_tool() -> str: + """ℹ️ Описание схемы данных. + + Возвращает описание таблицы `property_listings`: + поля, типы, enum-значения, особенности поиска (vector + FTS). + """ + try: + return await describe_schema() + except Exception as e: + logger.exception("Unexpected error in describe_schema") + return _error_json(f"Неожиданная ошибка: {e}") + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/src/vmk_data_mcp/models.py b/src/vmk_data_mcp/models.py new file mode 100644 index 0000000..e077a4c --- /dev/null +++ b/src/vmk_data_mcp/models.py @@ -0,0 +1,119 @@ +"""Pydantic-модели для входных параметров и выходных результатов инструментов.""" + +from datetime import date +from typing import Literal + +from pydantic import BaseModel, Field + + +class PaginationParams(BaseModel): + """Базовая пагинация.""" + + limit: int = Field(default=20, ge=1, le=100, description="Количество результатов на странице") + offset: int = Field(default=0, ge=0, description="Смещение для пагинации") + + +class MetadataFilters(BaseModel): + """Фильтры по метаданным, общие для всех инструментов поиска.""" + + deal_type: Literal["sale", "rent_long", "rent_short"] | None = Field( + default=None, description="Тип сделки" + ) + city: str | None = Field(default=None, description="Город (украинский)") + district: str | None = Field(default=None, description="Район (украинский)") + rooms_count: int | None = Field(default=None, ge=0, description="Количество комнат") + min_price: float | None = Field(default=None, ge=0, description="Минимальная цена") + max_price: float | None = Field(default=None, ge=0, description="Максимальная цена") + currency: Literal["USD", "EUR", "UAH"] | None = Field(default=None, description="Валюта") + min_total_area: float | None = Field( + default=None, ge=0, description="Минимальная общая площадь (м²)" + ) + max_total_area: float | None = Field( + default=None, ge=0, description="Максимальная общая площадь (м²)" + ) + building_type: Literal["brick", "panel", "monolith", "gas_block", "wood"] | None = Field( + default=None, description="Тип постройки" + ) + floor: int | None = Field(default=None, ge=0, description="Этаж") + listing_status: Literal["active", "sold", "rented", "removed", "archived"] | None = Field( + default=None, description="Статус объявления" + ) + metro_station: str | None = Field(default=None, description="Станция метро (украинский)") + + +class SearchSimilarInput(BaseModel): + """Входные параметры для векторного поиска.""" + + query: str = Field(..., description="Текстовый запрос на украинском языке для поиска по смыслу") + filters: MetadataFilters = Field(default_factory=MetadataFilters) + pagination: PaginationParams = Field(default_factory=PaginationParams) + min_similarity: float = Field( + default=0.7, ge=0.0, le=1.0, description="Минимальный порог косинусной близости" + ) + + +class SearchMetadataInput(BaseModel): + """Входные параметры для поиска по метаданным/FTS.""" + + query: str = Field( + ..., description="Текстовый запрос на украинском языке для полнотекстового поиска" + ) + filters: MetadataFilters = Field(default_factory=MetadataFilters) + pagination: PaginationParams = Field(default_factory=PaginationParams) + + +class GetListingInput(BaseModel): + """Входные параметры для получения объявления по ID.""" + + listing_id: int = Field(..., ge=1, description="ID объявления") + + +class ListingResult(BaseModel): + """Результат поиска — одно объявление.""" + + id: int + title: str + description: str | None = None + generated_description: str | None = None + price: float | None = None + currency: str | None = None + deal_type: str | None = None + city: str | None = None + district: str | None = None + rooms_count: int | None = None + total_area: float | None = None + living_area: float | None = None + kitchen_area: float | None = None + floor: int | None = None + floors_count: int | None = None + building_type: str | None = None + building_year: int | None = None + renovation_status: str | None = None + balcony_count: int | None = None + bathroom_type: str | None = None + parking_type: str | None = None + heating_type: str | None = None + layout_type: str | None = None + window_view: str | None = None + metro_station: str | None = None + metro_distance_type: str | None = None + metro_distance_meters: int | None = None + url_source: str | None = None + publish_date: date | None = None + images_count: int | None = None + contact_phone: str | None = None + listing_status: str | None = None + archived_at: date | None = None + created_at: date | None = None + updated_at: date | None = None + similarity_score: float | None = None # для векторного поиска + rank_score: float | None = None # для FTS + + +class SearchResult(BaseModel): + """Результат поиска — список объявлений + мета.""" + + total: int = Field(description="Общее количество найденных записей") + limit: int + offset: int + listings: list[ListingResult] diff --git a/src/vmk_data_mcp/tools.py b/src/vmk_data_mcp/tools.py new file mode 100644 index 0000000..7eefee7 --- /dev/null +++ b/src/vmk_data_mcp/tools.py @@ -0,0 +1,344 @@ +"""Реализация инструментов MCP-сервера для поиска по базе недвижимости.""" + +import json +from datetime import datetime + +from vmk_data_mcp.db import USER_COLUMNS, fetch, fetchrow +from vmk_data_mcp.embedder import get_embedding +from vmk_data_mcp.models import ( + GetListingInput, + ListingResult, + MetadataFilters, + SearchMetadataInput, + SearchResult, + SearchSimilarInput, +) + +# Список колонок для SELECT (пользовательские) +_SELECT_COLUMNS = ", ".join( + f"{col}" + for col in sorted(USER_COLUMNS) + if col not in {"search_vector", "embedding"} +) + + +def _build_where_clause(filters: MetadataFilters) -> tuple[str, list]: + """Строит WHERE-условия из фильтров. Возвращает (sql_fragment, params). + + Все параметры передаются через placeholders ($1, $2...) — SQL-инъекции невозможны. + """ + conditions: list[str] = [] + params: list = [] + + if filters.deal_type is not None: + conditions.append(f"deal_type = ${len(params) + 1}") + params.append(filters.deal_type) + + if filters.city is not None: + conditions.append(f"city ILIKE ${len(params) + 1}") + params.append(f"%{filters.city}%") + + if filters.district is not None: + conditions.append(f"district ILIKE ${len(params) + 1}") + params.append(f"%{filters.district}%") + + if filters.rooms_count is not None: + conditions.append(f"rooms_count = ${len(params) + 1}") + params.append(filters.rooms_count) + + if filters.min_price is not None: + conditions.append(f"price >= ${len(params) + 1}") + params.append(filters.min_price) + + if filters.max_price is not None: + conditions.append(f"price <= ${len(params) + 1}") + params.append(filters.max_price) + + if filters.currency is not None: + conditions.append(f"currency = ${len(params) + 1}") + params.append(filters.currency) + + if filters.min_total_area is not None: + conditions.append(f"total_area >= ${len(params) + 1}") + params.append(filters.min_total_area) + + if filters.max_total_area is not None: + conditions.append(f"total_area <= ${len(params) + 1}") + params.append(filters.max_total_area) + + if filters.building_type is not None: + conditions.append(f"building_type = ${len(params) + 1}") + params.append(filters.building_type) + + if filters.floor is not None: + conditions.append(f"floor = ${len(params) + 1}") + params.append(filters.floor) + + if filters.listing_status is not None: + conditions.append(f"listing_status = ${len(params) + 1}") + params.append(filters.listing_status) + + if filters.metro_station is not None: + conditions.append(f"metro_station ILIKE ${len(params) + 1}") + params.append(f"%{filters.metro_station}%") + + where_sql = " AND ".join(conditions) if conditions else "TRUE" + return where_sql, params + + +def _record_to_listing( + row: dict, similarity: float | None = None, rank: float | None = None +) -> ListingResult: + """Конвертирует asyncpg.Record в ListingResult.""" + data = dict(row) + data.pop("search_vector", None) + data.pop("embedding", None) + # Приводим datetime -> date где нужно + for key in ("publish_date", "archived_at", "created_at", "updated_at"): + if data.get(key) and isinstance(data[key], datetime): + data[key] = data[key].date() + data["similarity_score"] = similarity + data["rank_score"] = rank + return ListingResult(**data) + + +async def search_similar_listings(args: SearchSimilarInput) -> str: + """🔍 Векторный поиск объявлений по смыслу запроса. + + Использует pgvector + HNSW-индекс для косинусной близости. + Запрос должен быть на украинском языке. + """ + embedding = await get_embedding(args.query) + embedding_str = "[" + ",".join(str(v) for v in embedding) + "]" + + where_sql, params = _build_where_clause(args.filters) + # pgvector <=> возвращает cosine distance с диапазоном [0, 2]. + # similarity = 1 - distance/2. Для min_similarity максимальное distance: + # distance_max = 2 * (1 - min_similarity) + max_distance = 2.0 * (1.0 - args.min_similarity) + + count_sql = f""" + SELECT COUNT(*) FROM property_listings + WHERE {where_sql} + AND embedding <=> ${len(params) + 1}::vector <= ${len(params) + 2} + """ + count_params = [*params, embedding_str, max_distance] + total_row = await fetchrow(count_sql, *count_params) + total = total_row[0] if total_row else 0 + + select_sql = f""" + SELECT {_SELECT_COLUMNS}, + 1 - (embedding <=> ${len(params) + 1}::vector) / 2.0 AS similarity_score + FROM property_listings + WHERE {where_sql} + AND embedding <=> ${len(params) + 1}::vector <= ${len(params) + 2} + ORDER BY embedding <=> ${len(params) + 1}::vector ASC + LIMIT ${len(params) + 3} OFFSET ${len(params) + 4} + """ + select_params = [ + *params, + embedding_str, + max_distance, + args.pagination.limit, + args.pagination.offset, + ] + + rows = await fetch(select_sql, *select_params) + listings = [_record_to_listing(r, similarity=r["similarity_score"]) for r in rows] + + result = SearchResult( + total=total, + limit=args.pagination.limit, + offset=args.pagination.offset, + listings=listings, + ) + return result.model_dump_json(indent=2, ensure_ascii=False) + + +async def search_by_metadata(args: SearchMetadataInput) -> str: + """📋 Полнотекстовый поиск + фильтры по метаданным. + + Использует готовую FTS-колонку `search_vector` (ukrainian) + GIN-индекс. + Запрос должен быть на украинском языке. + """ + where_sql, params = _build_where_clause(args.filters) + + # Добавляем FTS-условие + tsquery = f"plainto_tsquery('ukrainian', ${len(params) + 1})" + fts_condition = f"search_vector @@ {tsquery}" + params.append(args.query) + + full_where = f"{where_sql} AND {fts_condition}" if where_sql != "TRUE" else fts_condition + + # COUNT + count_sql = f""" + SELECT COUNT(*) FROM property_listings + WHERE {full_where} + """ + total_row = await fetchrow(count_sql, *params) + total = total_row[0] if total_row else 0 + + # SELECT с ранжированием + select_sql = f""" + SELECT {_SELECT_COLUMNS}, + ts_rank_cd(search_vector, {tsquery}, 32) AS rank_score + FROM property_listings + WHERE {full_where} + ORDER BY ts_rank_cd(search_vector, {tsquery}, 32) DESC + LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} + """ + select_params = [*params, args.pagination.limit, args.pagination.offset] + + rows = await fetch(select_sql, *select_params) + listings = [_record_to_listing(r, rank=r["rank_score"]) for r in rows] + + result = SearchResult( + total=total, + limit=args.pagination.limit, + offset=args.pagination.offset, + listings=listings, + ) + return result.model_dump_json(indent=2, ensure_ascii=False) + + +async def get_listing_by_id(args: GetListingInput) -> str: + """📄 Получить объявление по ID. + + Возвращает полную карточку объявления со всеми пользовательскими полями. + """ + sql = f""" + SELECT {_SELECT_COLUMNS} + FROM property_listings + WHERE id = $1 + LIMIT 1 + """ + row = await fetchrow(sql, args.listing_id) + if row is None: + return json.dumps({"error": "Listing not found"}, ensure_ascii=False) + + listing = _record_to_listing(row) + return listing.model_dump_json(indent=2, ensure_ascii=False) + + +async def describe_schema() -> str: + """ℹ️ Описание схемы базы данных. + + Возвращает статическое описание таблицы `property_listings` — + поля, типы, enum-значения и примеры запросов. + """ + schema_info = { + "table": "property_listings", + "description": "Объявления о недвижимости (квартиры, дома, аренда, продажа)", + "language": "ukrainian", + "note": "Все текстовые запросы должны быть на украинском языке.", + "columns": { + "id": {"type": "integer", "description": "Уникальный ID объявления"}, + "title": {"type": "text", "description": "Заголовок объявления"}, + "description": {"type": "text", "description": "Описание от продавца"}, + "generated_description": {"type": "text", "description": "AI-сгенерированное описание"}, + "price": {"type": "numeric", "description": "Цена"}, + "currency": {"type": "enum(USD,EUR,UAH)", "description": "Валюта"}, + "deal_type": { + "type": "enum", + "values": ["sale", "rent_long", "rent_short"], + "description": "Тип сделки: продажа | долгосрочная аренда | посуточная аренда", + }, + "city": {"type": "text", "description": "Город (украинский)"}, + "district": {"type": "text", "description": "Район (украинский)"}, + "rooms_count": {"type": "integer", "description": "Количество комнат"}, + "total_area": {"type": "numeric", "description": "Общая площадь, м²"}, + "living_area": {"type": "numeric", "description": "Жилая площадь, м²"}, + "kitchen_area": {"type": "numeric", "description": "Площадь кухни, м²"}, + "floor": {"type": "integer", "description": "Этаж"}, + "floors_count": {"type": "integer", "description": "Всего этажей в доме"}, + "building_type": { + "type": "enum", + "values": ["brick", "panel", "monolith", "gas_block", "wood"], + "description": "Тип постройки", + }, + "building_year": {"type": "integer", "description": "Год постройки"}, + "renovation_status": { + "type": "enum", + "values": [ + "no_renovation", + "cosmetic", + "european", + "designer", + "full", + ], + "description": "Состояние ремонта", + }, + "balcony_count": {"type": "integer", "description": "Количество балконов"}, + "bathroom_type": { + "type": "enum", + "values": ["combined", "separate", "two_or_more"], + "description": "Тип санузла", + }, + "parking_type": { + "type": "enum", + "values": ["no_parking", "ground", "underground", "garage"], + "description": "Тип парковки", + }, + "heating_type": { + "type": "enum", + "values": ["central", "autonomous", "individual", "none"], + "description": "Тип отопления", + }, + "layout_type": { + "type": "enum", + "values": [ + "studio", + "one_room", + "two_room", + "three_room", + "four_room_plus", + "duplex", + ], + "description": "Тип планировки", + }, + "window_view": { + "type": "enum", + "values": ["courtyard", "street", "park", "water", "mixed"], + "description": "Вид из окон", + }, + "metro_station": {"type": "text", "description": "Станция метро (украинский)"}, + "metro_distance_type": { + "type": "enum", + "values": ["walking", "transport", "far"], + "description": "Удалённость от метро", + }, + "metro_distance_meters": { + "type": "integer", + "description": "Расстояние до метро в метрах", + }, + "url_source": {"type": "text", "description": "Исходный URL объявления"}, + "publish_date": {"type": "date", "description": "Дата публикации"}, + "images_count": {"type": "integer", "description": "Количество фото"}, + "contact_phone": {"type": "text", "description": "Телефон контакта"}, + "listing_status": { + "type": "enum", + "values": ["active", "sold", "rented", "removed", "archived"], + "description": "Статус объявления", + }, + "archived_at": {"type": "date", "description": "Дата архивации"}, + "created_at": {"type": "timestamp", "description": "Дата создания записи"}, + "updated_at": {"type": "timestamp", "description": "Дата обновления записи"}, + }, + "search_features": { + "vector_search": { + "column": "embedding vector(768)", + "index": "HNSW (vector_cosine_ops)", + "model": "nomic-embed-text (Ollama)", + "description": "Семантический поиск по смыслу. Запросы на украинском.", + }, + "full_text_search": { + "column": "search_vector tsvector (generated)", + "index": "GIN", + "config": "ukrainian", + "description": ( + "Полнотекстовый поиск по title, description, city, district, metro_station" + ), + }, + }, + } + return json.dumps(schema_info, indent=2, ensure_ascii=False) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..6be12cf --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,64 @@ +"""Базовые тесты моделей и структуры проекта.""" + +import pytest + +from vmk_data_mcp.models import ( + GetListingInput, + ListingResult, + MetadataFilters, + PaginationParams, + SearchMetadataInput, + SearchResult, + SearchSimilarInput, +) + + +def test_pagination_defaults(): + p = PaginationParams() + assert p.limit == 20 + assert p.offset == 0 + + +def test_pagination_bounds(): + with pytest.raises(ValueError): + PaginationParams(limit=0) + with pytest.raises(ValueError): + PaginationParams(limit=101) + with pytest.raises(ValueError): + PaginationParams(offset=-1) + + +def test_metadata_filters_empty(): + f = MetadataFilters() + assert f.deal_type is None + assert f.city is None + + +def test_search_similar_input(): + inp = SearchSimilarInput(query="двокімнатна квартира київ") + assert inp.query == "двокімнатна квартира київ" + assert inp.min_similarity == 0.7 + assert inp.pagination.limit == 20 + + +def test_search_metadata_input(): + inp = SearchMetadataInput(query="центр міста") + assert inp.query == "центр міста" + + +def test_get_listing_input(): + inp = GetListingInput(listing_id=42) + assert inp.listing_id == 42 + + +def test_listing_result_serializes(): + listing = ListingResult(id=1, title="Test") + data = listing.model_dump() + assert data["id"] == 1 + assert data["title"] == "Test" + + +def test_search_result_serializes(): + result = SearchResult(total=5, limit=10, offset=0, listings=[]) + data = result.model_dump() + assert data["total"] == 5