diff --git a/src/vmk_data_mcp/config.py b/src/vmk_data_mcp/config.py index ddd5e19..8e910cb 100644 --- a/src/vmk_data_mcp/config.py +++ b/src/vmk_data_mcp/config.py @@ -41,6 +41,12 @@ # MCP Server mcp_server_name: str = Field(default="vmk-data-mcp") mcp_port: int = Field(default=8080, description="Порт HTTP транспорта") + mcp_default_similarity: float = Field( + default=0.7, + ge=0.0, + le=1.0, + description="Порог косинусной близости для vector-поиска (0.7 = широкий)", + ) settings = Settings() diff --git a/src/vmk_data_mcp/main.py b/src/vmk_data_mcp/main.py index 0381430..c85201e 100644 --- a/src/vmk_data_mcp/main.py +++ b/src/vmk_data_mcp/main.py @@ -23,6 +23,8 @@ from vmk_data_mcp.embedder import close_client from vmk_data_mcp.models import ( GetListingInput, + MetadataFilters, + PaginationParams, SearchMetadataInput, SearchSimilarInput, ) @@ -55,11 +57,74 @@ logger.info("Shutdown complete.") +SERVER_INSTRUCTIONS = """\ +# VMK Data MCP Server — инструкция для AI-агента + +## Назначение +Этот сервер предоставляет инструменты для поиска объявлений о недвижимости +(квартиры, дома, аренда, продажа) из базы данных `vmk_data`. + +## Язык +Все текстовые запросы (`query`) должны быть на **украинском языке**. +AI-агент переводит запрос пользователя перед вызовом инструмента. + +## Два инструмента поиска — выбирай правильно + +### search_similar_listings — векторный (семантический) +Используй, когда пользователь описывает **желания, атмосферу, качества**: +- "уютная квартира с ремонтом у метро" +- "светлая студия в центрі міста" +- "простора 3-кімнатна з балконом" + +Запрос на украинском. Конкретно, без стоп-слов. +✓ Хорошо: "2-кімнатна квартира біля метро з ремонтом" +✗ Плохо: "дай квартиру недорого" + +### search_by_metadata — полнотекстовый (FTS) +Используй, когда пользователь называет **конкретные ключевые слова**: +- "Печерський район Арсенальна метро" +- "вул. Шевченка Львів оренда" +- "новобудова моноліт Солом'янський" + +Запрос на украинском. Имена собственные и термины. +✓ Хорошо: "Печерський район Арсенальна метро" +✗ Плохо: "хочу квартиру" (слишком общее — используй vector) + +## Фильтры (общие для обоих инструментов) +- Обязательно указывай `currency` при `min_price`/`max_price`. +- `city`, `district`, `metro_station` — поиск по подстроке (ILIKE), регистр не важен. +- `rooms_count`: 0 = студия. +- Если результатов мало — убери 1–2 фильтра (обычно district или metro_station). + +## Сортировка (только search_by_metadata) +- `relevance` — по релевантности FTS (по умолчанию) +- `price_asc`/`price_desc` — по цене +- `date_desc` — новые сверху +- `area_desc` — по площади + +## Fallback при 0 результатов +1. Попробуй другой инструмент (vector → FTS или FTS → vector). +2. Убери 1–2 жёстких фильтра. +3. Упрости query — убери разговорные слова. +4. Проверь, что query на украинском. + +## Пагинация +- `total` > `limit` → есть следующая страница. +- Следующая: `offset += limit`. Предыдущая: `offset -= limit` (≥ 0). +- `limit` от 1 до 100. + +## describe_schema +Возвращает полное описание таблицы, гайды, примеры запросов и лучшие практики. +Вызывай, если не уверен какой инструмент выбрать или какие фильтры применить. +""" + + mcp = FastMCP( settings.mcp_server_name, host="0.0.0.0", port=settings.mcp_port, lifespan=app_lifespan, + instructions=SERVER_INSTRUCTIONS, ) # ── Prompts (гайды для AI-агента) ─────────────────────────────────── @@ -68,62 +133,7 @@ @mcp.prompt() def search_guide() -> str: """📘 Гайд: как эффективно искать объявления через VMK Data MCP.""" - return """\ -# Гайд по поиску объявлений (VMK Data MCP) - -## Два инструмента поиска — выбирай правильно - -### 1. search_similar_listings — векторный (семантический) поиск -Используй, когда пользователь описывает **желания, атмосферу, качества**: -- ✅ "уютная квартира с ремонтом у метро" -- ✅ "светлая студия в центрі міста" -- ✅ "простора 3-кімнатна з балконом" - -**Как формулировать query (украинский):** -- Конкретно, без разговорных стоп-слов. -- ✓ Хорошо: "2-кімнатна квартира біля метро з ремонтом" -- ✗ Плохо: "дай квартиру недорого" (стоп-слова: дай, недорого) - -**Фильтры:** -- Обязательно указывай `currency` при `min_price`/`max_price`. -- `city` и `district` — поиск по подстроке, регистр не важен. -- `rooms_count`: 0 = студия. - -### 2. search_by_metadata — полнотекстовый поиск (FTS) -Используй, когда пользователь называет **конкретные ключевые слова**: -- ✅ "Печерський район метро Арсенальна" -- ✅ "вул. Шевченка Львів оренда" -- ✅ "новобудова моноліт Солом'янський" - -**Как формулировать query (украинский):** -- Используй имена собственные и термины. -- ✓ Хорошо: "Печерський район Арсенальна метро" -- ✗ Плохо: "хочу квартиру" (слишком общее — используй vector) - -## Fallback-стратегия при 0 результатов - -1. Попробуй другой инструмент (vector → FTS или FTS → vector). -2. Убери 1–2 жёстких фильтра (обычно `district` или `metro_station`). -3. Упрости query — убери разговорные слова. -4. Проверь, что query на украинском. - -## Пагинация - -- `total` > `limit` → есть следующая страница. -- Следующая страница: `offset += limit`. -- Предыдущая страница: `offset -= limit` (но не ниже 0). -- `limit` от 1 до 100. - -## Фильтры — лучшие комбинации - -- `city` + `deal_type` + `rooms_count` + `currency` -- `city` + `district` + `deal_type` + `min_price` + `max_price` + `currency` -- `city` + `metro_station` + `rooms_count` + `listing_status=active` - -**Избегай:** -- `min_price`/`max_price` без `currency` — фильтрует по всем валютам. -- Более 5 фильтров одновременно — часто даёт 0 результатов. -""" + return SERVER_INSTRUCTIONS # ── Регистрация инструментов ──────────────────────────────────────── @@ -132,21 +142,8 @@ @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, + filters: MetadataFilters | None = None, + pagination: PaginationParams | None = None, ) -> str: """🔍 Векторный (семантический) поиск объявлений по смыслу. @@ -158,32 +155,22 @@ **Важно:** запрос `query` должен быть на **украинском** языке. Формулируй конкретно, без разговорных стоп-слов. - **Фильтры:** обязательно указывай `currency` при `min_price`/`max_price`. - `city` и `district` — поиск по подстроке (ILIKE), регистр не важен. + **Фильтры:** объект `MetadataFilters` — city, district, deal_type, + rooms_count, min/max_price (с обязательной currency), building_type и др. + `city`/`district`/`metro_station` — поиск по подстроке, регистр не важен. `rooms_count`: 0 = студия. + **Пагинация:** объект `PaginationParams` с `limit` (1–100, умолч. 20) и + `offset` (≥ 0). Следующая страница: `offset += limit`. + **Fallback при 0 результатов:** сервер вернёт подсказку с рекомендациями (попробовать FTS, убрать фильтры, упростить 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}, + filters=filters or MetadataFilters(), + pagination=pagination or PaginationParams(), ) return await search_similar_listings(args) except httpx.HTTPError as e: @@ -200,21 +187,9 @@ @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, + filters: MetadataFilters | None = None, + pagination: PaginationParams | None = None, + sort_by: str = "relevance", ) -> str: """📋 Полнотекстовый поиск + фильтры по метаданным. @@ -225,31 +200,26 @@ **Важно:** запрос `query` должен быть на **украинском** языке. Используй имена собственные и термины, а не разговорные описания. - **Фильтры:** те же, что и для `search_similar_listings`. + **Фильтры:** объект `MetadataFilters` — те же поля, что и для vector-поиска. Обязательно указывай `currency` при `min_price`/`max_price`. + **Сортировка:** + - `relevance` — по релевантности FTS (по умолчанию) + - `price_asc`/`price_desc` — по цене + - `date_desc` — новые сверху + - `area_desc` — по площади + + **Пагинация:** объект `PaginationParams`. + **Fallback при 0 результатов:** сервер вернёт подсказку с рекомендациями (попробовать vector search, убрать фильтры). """ 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}, + filters=filters or MetadataFilters(), + pagination=pagination or PaginationParams(), + sort_by=sort_by, ) return await search_by_metadata(args) except (asyncpg.PostgresError, ValueError) as e: @@ -281,11 +251,16 @@ @mcp.tool() async def describe_schema_tool() -> str: - """ℹ️ Описание схемы данных. + """ℹ️ Описание схемы данных и гайд по поиску. - Возвращает описание таблицы `property_listings` с гайдами по использованию: - когда использовать vector/FTS, лучшие комбинации фильтров, - примеры запросов, стратегия fallback и пагинации. + Возвращает: + - описание таблицы `property_listings` (поля, типы, enum-значения) + - гайд когда использовать vector/FTS + - примеры хороших и плохих запросов + - лучшие комбинации фильтров + - стратегию fallback и пагинации + + Вызывай, если не уверен какой инструмент выбрать или какие фильтры применить. """ try: return await describe_schema() diff --git a/src/vmk_data_mcp/models.py b/src/vmk_data_mcp/models.py index 5c78049..6a710af 100644 --- a/src/vmk_data_mcp/models.py +++ b/src/vmk_data_mcp/models.py @@ -150,6 +150,20 @@ ) filters: MetadataFilters = Field(default_factory=MetadataFilters) pagination: PaginationParams = Field(default_factory=PaginationParams) + sort_by: Literal[ + "relevance", + "price_asc", + "price_desc", + "date_desc", + "area_desc", + ] = Field( + default="relevance", + description=( + "Сортировка результатов: relevance=по релевантности FTS (умолч.), " + "price_asc=цена по возрастанию, price_desc=цена по убыванию, " + "date_desc=новые сверху, area_desc=большая площадь сверху." + ), + ) class GetListingInput(BaseModel): diff --git a/src/vmk_data_mcp/tools.py b/src/vmk_data_mcp/tools.py index 8cf8bf3..af77680 100644 --- a/src/vmk_data_mcp/tools.py +++ b/src/vmk_data_mcp/tools.py @@ -3,6 +3,7 @@ import json from datetime import datetime +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.models import ( @@ -21,8 +22,6 @@ if col not in {"search_vector", "embedding"} ) -# Минимальная similarity зашита на уровень широкого поиска (≈ 0.7) -_MIN_SIMILARITY = 0.7 def _build_where_clause(filters: MetadataFilters) -> tuple[str, list]: @@ -173,6 +172,23 @@ ) +def _build_order_by_clause(sort_by: str, tsquery_placeholder: str) -> str: + """Строит ORDER BY SQL-фрагмент для metadata-поиска.""" + match sort_by: + case "relevance": + return f"ts_rank_cd(search_vector, {tsquery_placeholder}, 32) DESC" + case "price_asc": + return "price ASC NULLS LAST" + case "price_desc": + return "price DESC NULLS LAST" + case "date_desc": + return "publish_date DESC NULLS LAST" + case "area_desc": + return "total_area DESC NULLS LAST" + case _: + return f"ts_rank_cd(search_vector, {tsquery_placeholder}, 32) DESC" + + async def search_similar_listings(args: SearchSimilarInput) -> str: """🔍 Векторный поиск объявлений по смыслу запроса. @@ -201,7 +217,7 @@ # pgvector <=> возвращает cosine distance с диапазоном [0, 2]. # similarity = 1 - distance/2. Для min_similarity максимальное distance: # distance_max = 2 * (1 - min_similarity) - max_distance = 2.0 * (1.0 - _MIN_SIMILARITY) + max_distance = 2.0 * (1.0 - settings.mcp_default_similarity) count_sql = f""" SELECT COUNT(*) FROM property_listings @@ -285,13 +301,15 @@ total_row = await fetchrow(count_sql, *params) total = total_row[0] if total_row else 0 + order_by = _build_order_by_clause(args.sort_by, tsquery) + # 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 + ORDER BY {order_by} LIMIT ${len(params) + 1} OFFSET ${len(params) + 2} """ select_params = [*params, args.pagination.limit, args.pagination.offset] @@ -374,6 +392,11 @@ "rooms_count: 0 = студия. " "Если результатов мало — убери district или metro_station." ), + "sorting": ( + "search_by_metadata поддерживает сортировку: " + "relevance=по релевантности (умолч.), price_asc/desc=по цене, " + "date_desc=новые сверху, area_desc=по площади." + ), "pagination_strategy": ( "total > limit означает, что есть следующая страница. " "offset += limit для перехода вперёд. " diff --git a/tests/test_models.py b/tests/test_models.py index 2cc2fea..cda927c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -38,13 +38,24 @@ inp = SearchSimilarInput(query="двокімнатна квартира київ") assert inp.query == "двокімнатна квартира київ" assert inp.pagination.limit == 20 - # min_similarity зашит внутри tools.py (всегда 0.7) + # min_similarity зашит внутри tools.py через config (всегда 0.7) assert not hasattr(inp, "min_similarity") -def test_search_metadata_input(): +def test_search_metadata_input_defaults(): inp = SearchMetadataInput(query="центр міста") assert inp.query == "центр міста" + assert inp.sort_by == "relevance" + assert inp.pagination.limit == 20 + + +def test_search_metadata_sort_by_validation(): + # Допустимое значение + inp = SearchMetadataInput(query="test", sort_by="price_asc") + assert inp.sort_by == "price_asc" + # Недопустимое значение должно вызывать ошибку + with pytest.raises(ValueError): + SearchMetadataInput(query="test", sort_by="invalid_sort") def test_get_listing_input(): @@ -84,3 +95,11 @@ # Просто проверяем что модель создаётся и сериализуется assert listing.similarity_score is None assert listing.rank_score is None + + +def test_nested_schema_generation(): + """Проверяем, что JSON-схема генерирует вложенные $defs для Filters и Pagination.""" + schema = SearchSimilarInput.model_json_schema() + assert "$defs" in schema + assert "MetadataFilters" in schema["$defs"] + assert "PaginationParams" in schema["$defs"]