diff --git a/.env.example b/.env.example index 18fd8ad..b1f48c0 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,13 @@ # MCP Server MCP_SERVER_NAME=vmk-data-mcp MCP_PORT=8080 +MCP_DEFAULT_SIMILARITY=0.7 + +# Image hosting +# Корень хранилища на файловой системе MCP-сервера. У data_collector этот путь +# примонтирован с хоста /home/gmikcon/Projects/vmk/images → /var/lib/vmk/images. +IMAGE_STORAGE_PATH=/home/gmikcon/Projects/vmk/images +# Базовый URL, который клиенты будут использовать для скачивания фото. +# Пустое значение даёт относительные ссылки /images/. +IMAGE_BASE_URL=http://localhost:8080/images +MAX_IMAGES_IN_SEARCH=5 diff --git a/README.md b/README.md index 1fd7866..556e7dc 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ | **Семантический поиск** | `pgvector` + HNSW + Ollama | Поиск объявлений «по смыслу» через векторную близость (cosine distance) | | **Полнотекстовый поиск** | PostgreSQL FTS (украинский конфиг) | Поиск по ключевым словам с ранжированием по релевантности (`ts_rank_cd`) | | **Фильтрация метаданных** | SQL `WHERE` с параметрами | Цена, район, комнаты, метро, тип сделки, статус и др. | +| **Хостинг изображений** | `custom_route` + `FileResponse` | Прямые ссылки на фото в результатах (`/images//.jpg`) | | **Пагинация** | `LIMIT` / `OFFSET` | Настраиваемый размер страницы (1–100) | | **Read-only безопасность** | `default_transaction_read_only = on` + валидация SQL | Гарантированная защита от записи/изменения данных | | **Потоковый HTTP** | MCP Streamable HTTP | Поддержка SSE-стрима + POST на порту 8080 | @@ -51,6 +52,13 @@ │ vmk_data │ │ nomic-embed- │ │ (structlog) │ │ + pgvector │ │ text 768d │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Image Storage │ + │ /var/lib/vmk/ │ + │ images │ + └─────────────────┘ ``` ### Поток данных при семантическом поиске @@ -60,6 +68,8 @@ 3. Полученный вектор (768 float) передаётся в PostgreSQL как `vector`. 4. pgvector выполняет поиск по HNSW-индексу: `embedding <=> $1::vector <= $2`. 5. Результаты сериализуются в JSON и возвращаются агенту. +6. Каждое объявление дополняется полем `images` — прямыми ссылками на фото, + которые MCP-сервер раздаёт через тот же HTTP-порт (`/images/...`). --- @@ -195,7 +205,8 @@ - `pagination` *(object)* — `limit` (1–100, по умолч. 20), `offset` (≥ 0) - `min_similarity` *(float)* — порог косинусной близости (0.0–1.0, по умолч. 0.7) -**Выход:** [`SearchResult`](#searchresult) — список объявлений с полем `similarity_score`. +**Выход:** [`SearchResult`](#searchresult) — список объявлений с полем `similarity_score` +и `images` (до 5 фото на объявление). > ⚠️ Параметр `min_similarity` преобразуется в максимальное косинусное расстояние: > `max_distance = 2.0 × (1.0 − min_similarity)`, потому что оператор `<=>` в pgvector @@ -211,7 +222,8 @@ - `filters` *(object)* — те же фильтры, что и для векторного поиска - `pagination` *(object)* — пагинация -**Выход:** [`SearchResult`](#searchresult) — список с полем `rank_score` (релевантность FTS). +**Выход:** [`SearchResult`](#searchresult) — список с полем `rank_score` (релевантность FTS) +и `images` (до 5 фото на объявление). --- @@ -221,7 +233,8 @@ **Входные параметры:** - `listing_id` *(integer, обязательный)* — `id` объявления -**Выход:** [`ListingResult`](#listingresult) или `{"error": "..."}` если не найдено. +**Выход:** [`ListingResult`](#listingresult) (включая поле `images` со всеми доступными фото) +или `{"error": "..."}` если не найдено. --- @@ -273,6 +286,11 @@ # На каком порту слушать MCP MCP_PORT=8080 + +# Хостинг изображений (опционально, для раздачи фото из data_collector) +IMAGE_STORAGE_PATH=/home/gmikcon/Projects/vmk/images +IMAGE_BASE_URL=http://localhost:8080/images +MAX_IMAGES_IN_SEARCH=5 ``` > 🔍 Где взять значения: @@ -366,6 +384,9 @@ | `OLLAMA_REQUEST_TIMEOUT` | `60.0` | Таймаут HTTP-запроса к Ollama, секунды | | `MCP_SERVER_NAME` | `vmk-data-mcp` | Имя сервера в MCP-протоколе | | `MCP_PORT` | `8080` | Порт HTTP-транспорта | +| `IMAGE_STORAGE_PATH` | `/var/lib/vmk/images` | Корень хранилища фото на диске сервера | +| `IMAGE_BASE_URL` | `""` | Базовый URL фото. Пусто → относительные `/images/...` | +| `MAX_IMAGES_IN_SEARCH` | `5` | Максимум фото на объявление в результатах поиска | > 💡 Совет: если Ollama и PostgreSQL работают локально, минимальный `.env` — это > всего 4 строчки (см. раздел **Быстрый старт → Вариант А**). @@ -455,6 +476,7 @@ │ ├── models.py # Pydantic-модели входных/выходных данных │ ├── db.py # asyncpg пул, read-only защита, USER_COLUMNS │ ├── embedder.py # Ollama HTTP клиент для эмбеддингов +│ ├── images.py # Хостинг фото и batch-загрузка метаданных │ └── config.py # Настройки из .env (pydantic-settings) ├── tests/ │ └── test_models.py # Pytest diff --git a/plan.md b/plan.md index 6317a98..ac71efb 100644 --- a/plan.md +++ b/plan.md @@ -1,278 +1,116 @@ -# MCP-сервер для пошуку по базі даних нерухомості +# План: хостинг изображений объявлений в MCP-сервере ## Контекст -Проєкт: **vmk-data-mcp** — MCP-сервер для vector + metadata пошуку по PostgreSQL-базі `vmk_data` (оголошення про нерухомість). Сервер надає інструменти AI-агенту (Claude) для семантичного та структурованого пошуку оголошень. -**Мова**: база даних містить переважно **український** текст. Всі текстові запити до `search_similar_listings` та `search_by_metadata` повинні бути на українській мові. AI-агент самостійно перекладає запити користувача перед викликом інструментів. +`data_collector` сохраняет фото объявлений на диск в `IMAGE_STORAGE_PATH` и записывает метаданные в таблицу `property_images`: +- `property_id` → внешний ключ на `property_listings.id` +- `local_path` — путь относительно корня хранилища, например `1/.jpg` +- `url` — исходный URL (может быть temp-путём, не подходит для клиента) +- `width`, `height`, `file_size`, `ai_description`, `order_index` -## Архітектурні рішення +MCP-сервер должен: +1. Раздавать файлы изображений по HTTP (хостить их сам). +2. Возвращать прямые ссылки на изображения внутри результатов `get_listing_by_id`, `search_similar_listings`, `search_by_metadata`. -### Стек -- **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 +- `data_collector/.env`: `IMAGE_STORAGE_PATH=/var/lib/vmk/images` (prod-контейнер). +- `data_collector/.env.example`: `IMAGE_STORAGE_PATH=./data/images` (dev). +- В БД `property_images` содержит 5855 записей, все `local_path` относительные (`/.jpg`). +- `FastMCP` поддерживает произвольные HTTP-маршруты через декоратор `custom_route`, поэтому можно добавить раздачу файлов на тот же порт, где работает MCP (по умолчанию 8080). -## Структура проєкту +## Архитектурные решения -``` -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 -``` +1. **Один порт, один процесс**: изображения раздаются через `@mcp.custom_route("/images/{image_path:path}")` на том же HTTP-сервере, что и MCP. Никакого дополнительного порта и второго приложения. +2. **Конфигурируемый путь к хранилищу**: MCP-сервер получает собственный `IMAGE_STORAGE_PATH` (может отличаться от `data_collector`, если директория примонтирована в другую точку). +3. **Безопасность файловой системы**: любой запрошенный путь разрешается в абсолютный и проверяется, что он находится внутри `IMAGE_STORAGE_PATH` (защита от `../../../etc/passwd`). +4. **Пакетная загрузка метаданных изображений**: для поисковых запросов изображения запрашиваются одним `SELECT ... WHERE property_id = ANY($1)` вместо N+1. +5. **Ограничение количества фото в списке**: в результатах поиска отдавать первые 5 изображений, чтобы не раздувать JSON. В `get_listing_by_id` — все доступные фото. +6. **URL-ы**: `image_base_url` из конфига. Если не задан — генерировать относительные ссылки `/images/`. -## Компоненти +## Изменения в коде -### Embedder (`embedder.py`) +### 1. Конфигурация (`src/vmk_data_mcp/config.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) +image_storage_path: str = Field( + default="/var/lib/vmk/images", + description="Абсолютный путь к корню хранилища изображений на файловой системе MCP-сервера", +) +image_base_url: str = Field( + default="", + description="Базовый URL для ссылок на изображения. Пустое значение → /images/", +) +max_images_in_search: int = Field( + default=5, ge=0, description="Максимум фото на одно объявление в результатах поиска" +) ``` -### 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 +### 2. Модели (`src/vmk_data_mcp/models.py`) -## Інструменти (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 млн грн", - "Новобудова з балконом біля метро", - "Будинок з ділянкою в передмісті" - ] -} +Добавить: +```python +class ListingImage(BaseModel): + url: str = Field(description="Прямая ссылка на изображение") + width: int | None = None + height: int | None = None + file_size: int | None = None + ai_description: str | None = None + order_index: int | None = None ``` -## Конфігурація (.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 +В `ListingResult` добавить поле: +```python +images: list[ListingImage] = Field(default_factory=list, description="Фотографии объявления") ``` -## План реалізації +### 3. Модуль работы с изображениями (`src/vmk_data_mcp/images.py`) -### Етап 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` +Ответственности: +- `resolve_image_path(local_path: str) -> Path | None` — разрешение относительного/абсолютного пути с проверкой внутри `image_storage_path`. +- `build_image_url(local_path: str) -> str` — построение публичного URL. +- `fetch_images_for_listings(pool, listing_ids: list[int]) -> dict[int, list[ListingImage]]` — batch-запрос в `property_images` и группировка. +- `fetch_images_for_listing(pool, listing_id: int) -> list[ListingImage]` — одиночный запрос. -### Етап 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 +### 4. Раздача файлов (`src/vmk_data_mcp/main.py`) -### Етап 3: Доробки -- [ ] Обробка помилок: DB timeout, Ollama недоступний, embedding ≠ 768d -- [ ] Логування: запити, latency, embedding time -- [ ] Graceful shutdown: закриття DB pool +После создания `mcp = FastMCP(...)` добавить: +```python +@mcp.custom_route("/images/{image_path:path}", methods=["GET"]) +async def serve_image(request: Request) -> Response: + ... +``` -### Етап 4: Інфраструктура -- [ ] `Dockerfile` (multi-stage, uv/uvicorn) -- [ ] `docker-compose.yml` (тільки MCP-сервер, Ollama окремо) -- [ ] `README.md` — підключення до Claude Desktop / Claude Code -- [ ] `.env.example` +Использовать `starlette.responses.FileResponse` и `starlette.requests.Request`. -## Гібридний пошук (майбутнє) +### 5. Обогащение результатов (`src/vmk_data_mcp/tools.py`) -Інструмент `hybrid_search` (не в MVP): -1. Паралельно виконувати `search_similar_listings` (vector) та `search_by_metadata` (text) -2. Об'єднати результати через Reciprocal Rank Fusion (RRF) -3. Пересортувати та повернути top-N +- `get_listing_by_id`: после получения строки объявления вызвать `fetch_images_for_listing` и встроить в `ListingResult`. +- `search_similar_listings` / `search_by_metadata`: после формирования списка `listing_ids` вызвать `fetch_images_for_listings` с ограничением `max_images_in_search`, прикрепить к каждому `ListingResult`. -## Resources (майбутнє) +### 6. Документация и конфиг -Відкладено до наступних ітерацій: -- `listing://{id}` — JSON з повним оголошенням -- `schema://property_listings` — опис схеми таблиці -- `stats://database` — агрегати (total_listings, price_range, top_cities) +- `.env.example`: добавить `IMAGE_STORAGE_PATH` и `IMAGE_BASE_URL`. +- `README.md`: описать новые эндпоинт и поле `images` в ответах. +- `describe_schema` / `SERVER_INSTRUCTIONS`: упомянуть, что результаты теперь содержат ссылки на фото. -## Рішення, що прийняті +### 7. Тесты (`tests/test_models.py`) -| Питання | Рішення | -|---------|---------| -| 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-агент перекладає запити | +- Проверить сериализацию `ListingImage`. +- Проверить, что `ListingResult` с пустым списком `images` корректно сериализуется. + +## Риски и нюансы + +- В dev-окружении файлы могут лежать внутри Docker-контейнера `data_collector` по пути `/var/lib/vmk/images`, а MCP-сервер запускается на хосте. Для работы нужно либо примонтировать этот том к хосту, либо настроить `IMAGE_STORAGE_PATH` в MCP на точку, где файлы доступны. Код останется корректным при правильном конфиге. +- Путь `local_path` в БД относительный, поэтому перемещение хранилища не требует миграции — достаточно изменить `IMAGE_STORAGE_PATH`. + +## Порядок реализации + +1. Конфиг + модели. +2. Модуль `images.py` с запросами к БД и построением URL. +3. `custom_route` для раздачи файлов. +4. Обогащение `get_listing_by_id` и поисковых инструментов. +5. Обновление `describe_schema`, `README.md`, `.env.example`. +6. Обновление тестов и проверка `ruff`. +7. Ручной smoke-test: запрос изображения по `/images/` и вызов `get_listing_by_id` с полем `images`. diff --git a/src/vmk_data_mcp/config.py b/src/vmk_data_mcp/config.py index 8e910cb..d615024 100644 --- a/src/vmk_data_mcp/config.py +++ b/src/vmk_data_mcp/config.py @@ -48,5 +48,24 @@ description="Порог косинусной близости для vector-поиска (0.7 = широкий)", ) + # Image hosting + image_storage_path: str = Field( + default="/var/lib/vmk/images", + description="Абсолютный путь к корню хранилища изображений на файловой системе сервера", + ) + image_base_url: str = Field( + default="", + description=( + "Базовый URL для ссылок на изображения. " + "Пусто → относительные /images/" + ), + ) + max_images_in_search: int = Field( + default=5, + ge=0, + le=100, + description="Максимальное количество фото на объявление в результатах поиска", + ) + settings = Settings() diff --git a/src/vmk_data_mcp/images.py b/src/vmk_data_mcp/images.py new file mode 100644 index 0000000..a855706 --- /dev/null +++ b/src/vmk_data_mcp/images.py @@ -0,0 +1,105 @@ +"""Работа с изображениями объявлений: хостинг, URL, batch-загрузка метаданных.""" + +from pathlib import Path + +from vmk_data_mcp.config import settings +from vmk_data_mcp.db import fetch +from vmk_data_mcp.models import ListingImage + + +def resolve_image_path(local_path: str) -> Path | None: + """Разрешает относительный путь из БД в абсолютный путь на диске. + + Выполняет проверку безопасности: итоговый путь должен находиться внутри + IMAGE_STORAGE_PATH, чтобы предотвратить path traversal (/images/../../../etc/passwd). + """ + if not local_path: + return None + + storage_root = Path(settings.image_storage_path).resolve() + stored = Path(local_path) + + absolute = ( + stored.resolve() + if stored.is_absolute() + else (storage_root / stored).resolve() + ) + + # Проверка: путь должен быть внутри корня хранилища + try: + absolute.relative_to(storage_root) + except ValueError: + return None + + return absolute + + +def build_image_url(local_path: str) -> str: + """Строит публичный URL для изображения по относительному пути из БД.""" + base = settings.image_base_url.strip() + if base: + return f"{base.rstrip('/')}/{local_path.lstrip('/')}" + return f"/images/{local_path.lstrip('/')}" + + +def _row_to_listing_image(row: dict) -> ListingImage: + """Конвертирует строку property_images в ListingImage.""" + local_path = row.get("local_path") or "" + return ListingImage( + url=build_image_url(local_path), + width=row.get("width"), + height=row.get("height"), + file_size=row.get("file_size"), + ai_description=row.get("ai_description"), + order_index=row.get("order_index"), + ) + + +async def fetch_images_for_listing(listing_id: int) -> list[ListingImage]: + """Возвращает все изображения для одного объявления, отсортированные по order_index.""" + sql = """ + SELECT local_path, width, height, file_size, ai_description, order_index + FROM property_images + WHERE property_id = $1 + AND local_path IS NOT NULL + AND download_status = 'downloaded' + ORDER BY order_index NULLS LAST, id + """ + rows = await fetch(sql, listing_id) + return [_row_to_listing_image(r) for r in rows] + + +async def fetch_images_for_listings( + listing_ids: list[int], max_per_listing: int = 0 +) -> dict[int, list[ListingImage]]: + """Batch-загрузка изображений для множества объявлений. + + Args: + listing_ids: список ID объявлений. + max_per_listing: максимальное количество фото на одно объявление. + 0 означает "без ограничения". + + Returns: + Словарь {property_id: [ListingImage, ...]}. + """ + if not listing_ids: + return {} + + sql = """ + SELECT property_id, local_path, width, height, file_size, ai_description, order_index + FROM property_images + WHERE property_id = ANY($1::int[]) + AND local_path IS NOT NULL + AND download_status = 'downloaded' + ORDER BY property_id, order_index NULLS LAST, id + """ + rows = await fetch(sql, listing_ids) + + grouped: dict[int, list[ListingImage]] = {pid: [] for pid in listing_ids} + for row in rows: + pid = row["property_id"] + if max_per_listing and len(grouped[pid]) >= max_per_listing: + continue + grouped[pid].append(_row_to_listing_image(row)) + + return grouped diff --git a/src/vmk_data_mcp/main.py b/src/vmk_data_mcp/main.py index c85201e..6ce97a1 100644 --- a/src/vmk_data_mcp/main.py +++ b/src/vmk_data_mcp/main.py @@ -17,10 +17,13 @@ import asyncpg import httpx from mcp.server import FastMCP +from starlette.requests import Request +from starlette.responses import FileResponse, Response 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.images import resolve_image_path from vmk_data_mcp.models import ( GetListingInput, MetadataFilters, @@ -113,6 +116,11 @@ - Следующая: `offset += limit`. Предыдущая: `offset -= limit` (≥ 0). - `limit` от 1 до 100. +## Изображения +- Каждый результат содержит поле `images` — список фото с прямыми ссылками. +- Ссылки ведут на этот же MCP-сервер (`/images//.jpg`). +- В поиске отдаётся до 5 фото на объявление; `get_listing_by_id` возвращает все. + ## describe_schema Возвращает полное описание таблицы, гайды, примеры запросов и лучшие практики. Вызывай, если не уверен какой инструмент выбрать или какие фильтры применить. @@ -127,6 +135,25 @@ instructions=SERVER_INSTRUCTIONS, ) +# ── Хостинг изображений ─────────────────────────────────────────────── + + +@mcp.custom_route("/images/{image_path:path}", methods=["GET"]) +async def serve_image(request: Request) -> Response: + """Раздаёт файлы изображений из хранилища data_collector. + + Путь в URL соответствует относительному пути `local_path` из таблицы + `property_images`, например `/images/1/.jpg`. + """ + image_path = request.path_params.get("image_path", "") + file_path = resolve_image_path(image_path) + + if file_path is None or not file_path.is_file(): + return Response("Image not found", status_code=404, media_type="text/plain") + + return FileResponse(file_path) + + # ── Prompts (гайды для AI-агента) ─────────────────────────────────── diff --git a/src/vmk_data_mcp/models.py b/src/vmk_data_mcp/models.py index 99353e2..02b823b 100644 --- a/src/vmk_data_mcp/models.py +++ b/src/vmk_data_mcp/models.py @@ -241,6 +241,21 @@ listing_id: int = Field(..., ge=1, description="ID объявления (положительное целое число).") +class ListingImage(BaseModel): + """Метаданные одного изображения объявления.""" + + url: str = Field(description="Прямая ссылка на изображение, доступную для скачивания.") + width: int | None = Field(default=None, description="Ширина изображения в пикселях.") + height: int | None = Field(default=None, description="Высота изображения в пикселях.") + file_size: int | None = Field(default=None, description="Размер файла в байтах.") + ai_description: str | None = Field( + default=None, description="AI-описание содержимого фотографии." + ) + order_index: int | None = Field( + default=None, description="Порядковый номер фото в галерее объявления." + ) + + class ListingResult(BaseModel): """Результат поиска — одно объявление.""" @@ -308,6 +323,10 @@ archived_at: date | None = None created_at: date | None = None updated_at: date | None = None + images: list[ListingImage] = Field( + default_factory=list, + description="Фотографии объявления. Ссылки ведут на MCP-сервер (image host).", + ) similarity_score: float | None = Field( default=None, description="Косинусная близость [0..1] для search_similar_listings. " diff --git a/src/vmk_data_mcp/tools.py b/src/vmk_data_mcp/tools.py index b1f6ea5..3b7bb1a 100644 --- a/src/vmk_data_mcp/tools.py +++ b/src/vmk_data_mcp/tools.py @@ -6,8 +6,10 @@ from vmk_data_mcp.config import settings from vmk_data_mcp.db import USER_COLUMNS, fetch, fetchrow from vmk_data_mcp.embedder import get_embedding +from vmk_data_mcp.images import fetch_images_for_listing, fetch_images_for_listings from vmk_data_mcp.models import ( GetListingInput, + ListingImage, ListingResult, MetadataFilters, SearchMetadataInput, @@ -145,7 +147,10 @@ def _record_to_listing( - row: dict, similarity: float | None = None, rank: float | None = None + row: dict, + similarity: float | None = None, + rank: float | None = None, + images: list[ListingImage] | None = None, ) -> ListingResult: """Конвертирует asyncpg.Record в ListingResult.""" data = dict(row) @@ -157,6 +162,7 @@ data[key] = data[key].date() data["similarity_score"] = similarity data["rank_score"] = rank + data["images"] = images or [] return ListingResult(**data) @@ -302,7 +308,18 @@ ] rows = await fetch(select_sql, *select_params) - listings = [_record_to_listing(r, similarity=r["similarity_score"]) for r in rows] + listing_ids = [r["id"] for r in rows] + images_by_id = await fetch_images_for_listings( + listing_ids, settings.max_images_in_search + ) + listings = [ + _record_to_listing( + r, + similarity=r["similarity_score"], + images=images_by_id.get(r["id"], []), + ) + for r in rows + ] result = SearchResult( total=total, @@ -371,7 +388,18 @@ 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] + listing_ids = [r["id"] for r in rows] + images_by_id = await fetch_images_for_listings( + listing_ids, settings.max_images_in_search + ) + listings = [ + _record_to_listing( + r, + rank=r["rank_score"], + images=images_by_id.get(r["id"], []), + ) + for r in rows + ] result = SearchResult( total=total, @@ -412,7 +440,8 @@ ensure_ascii=False, ) - listing = _record_to_listing(row) + images = await fetch_images_for_listing(args.listing_id) + listing = _record_to_listing(row, images=images) return listing.model_dump_json(indent=2, ensure_ascii=False) @@ -449,6 +478,11 @@ "rooms_count: 0 = студия. " "Если результатов мало — убери district или metro_station." ), + "images": ( + "Каждый результат содержит поле images — список фотографий объявления " + "с прямыми ссылками на MCP-сервер. В поиске отдаётся до 5 фото, " + "в get_listing_by_id — все доступные." + ), "sorting": ( "search_by_metadata поддерживает сортировку: " "relevance=по релевантности (умолч.), price_asc/desc=по цене, " diff --git a/tests/test_models.py b/tests/test_models.py index cda927c..910097f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,6 +4,7 @@ from vmk_data_mcp.models import ( GetListingInput, + ListingImage, ListingResult, MetadataFilters, PaginationParams, @@ -103,3 +104,32 @@ assert "$defs" in schema assert "MetadataFilters" in schema["$defs"] assert "PaginationParams" in schema["$defs"] + + +def test_listing_image_serializes(): + img = ListingImage( + url="/images/1/abc.jpg", + width=1200, + height=800, + file_size=102400, + ai_description="test", + order_index=0, + ) + data = img.model_dump() + assert data["url"] == "/images/1/abc.jpg" + assert data["width"] == 1200 + + +def test_listing_result_with_images(): + listing = ListingResult( + id=1, + title="Test", + images=[ListingImage(url="/images/1/abc.jpg")], + ) + data = listing.model_dump() + assert data["images"][0]["url"] == "/images/1/abc.jpg" + + +def test_listing_result_images_default_empty(): + listing = ListingResult(id=2, title="Test") + assert listing.images == []