diff --git a/src/vmk_data_mcp/main.py b/src/vmk_data_mcp/main.py index 6ce97a1..7f626b9 100644 --- a/src/vmk_data_mcp/main.py +++ b/src/vmk_data_mcp/main.py @@ -95,10 +95,19 @@ ## Фильтры (общие для обоих инструментов) - Обязательно указывай `currency` при `min_price`/`max_price`. -- `city`, `district`, `metro_station` — поиск по подстроке (ILIKE), регистр не важен. +- `city`, `district`, `metro_station`, `street` — поиск по подстроке (ILIKE), регистр не важен. + Для точного поиска по району/метро/улице используй **фильтры**, а не текстовый `query`. - `rooms_count`: 0 = студия. - Если результатов мало — убери 1–2 фильтра (обычно district или metro_station). +## search_by_metadata — особенности +- `query` можно оставить пустым: тогда поиск будет только по фильтрам. +- Если ищешь по району или станции метро — передавай их в `filters.district` + или `filters.metro_station`, а не в `query`. +- Слова в `query` ищутся через FTS: все слова должны присутствовать в тексте объявления. + Например, запрос 'Лівобережна метро' не найдёт ничего, потому что в базе нет слова 'метро' + рядом с названием станции. + ## Сортировка (только search_by_metadata) - `relevance` — по релевантности FTS (по умолчанию) - `price_asc`/`price_desc` — по цене @@ -213,7 +222,7 @@ @mcp.tool() async def search_by_metadata_tool( - query: str, + query: str = "", filters: MetadataFilters | None = None, pagination: PaginationParams | None = None, sort_by: str = "relevance", @@ -223,9 +232,11 @@ Используй, когда пользователь называет **конкретные ключевые слова** («Печерський район», «Арсенальна метро», «вул. Шевченка»). Работает через готовую FTS-колонку `search_vector` (украинский конфиг) + GIN-индекс. + `query` можно оставить пустым — тогда поиск будет только по фильтрам. **Важно:** запрос `query` должен быть на **украинском** языке. - Используй имена собственные и термины, а не разговорные описания. + Для поиска по району/метро/улице используй фильтры (`filters.district`, + `filters.metro_station`, `filters.street`), а не текстовый `query`. **Фильтры:** объект `MetadataFilters` — те же поля, что и для vector-поиска. Обязательно указывай `currency` при `min_price`/`max_price`. diff --git a/src/vmk_data_mcp/models.py b/src/vmk_data_mcp/models.py index 02b823b..4670fce 100644 --- a/src/vmk_data_mcp/models.py +++ b/src/vmk_data_mcp/models.py @@ -208,10 +208,11 @@ район, станция метро, улица, особенности ('Печерський район метро Арсенальна'). """ - query: str = Field( - ..., + query: str | None = Field( + default=None, description=( "Текстовый запрос на украинском языке для полнотекстового поиска. " + "Можно оставить пустым, тогда поиск будет только по фильтрам. " "Подходит для конкретных названий и ключевых слов. " "✓ Хорошо: 'Печерський район Арсенальна метро'. " "✗ Плохо: 'хочу квартиру' (слишком общее, используй search_similar)." diff --git a/src/vmk_data_mcp/tools.py b/src/vmk_data_mcp/tools.py index 3b7bb1a..1bec02a 100644 --- a/src/vmk_data_mcp/tools.py +++ b/src/vmk_data_mcp/tools.py @@ -204,8 +204,11 @@ return "\n".join(hints) -def _build_invalid_query_hint(query: str) -> str: +def _build_invalid_query_hint(query: str | None) -> str: """Возвращает подсказку при некорректном формате запроса.""" + if not query: + return "" + problems: list[str] = [] # Простые эвристики @@ -218,12 +221,6 @@ "Убери их — они ухудшают качество поиска." ) - if len(query.strip().split()) < 2: - problems.append( - "⚠️ Запрос слишком короткий. Добавь детали: тип недвижимости, " - "город, район, метро, площадь или цену." - ) - if not problems: return "" @@ -238,7 +235,9 @@ """Строит ORDER BY SQL-фрагмент для metadata-поиска.""" match sort_by: case "relevance": - return f"ts_rank_cd(search_vector, {tsquery_placeholder}, 32) DESC" + if tsquery_placeholder: + return f"ts_rank_cd(search_vector, {tsquery_placeholder}, 32) DESC" + return "publish_date DESC NULLS LAST, id DESC" case "price_asc": return "price ASC NULLS LAST" case "price_desc": @@ -248,7 +247,9 @@ case "area_desc": return "total_area DESC NULLS LAST" case _: - return f"ts_rank_cd(search_vector, {tsquery_placeholder}, 32) DESC" + if tsquery_placeholder: + return f"ts_rank_cd(search_vector, {tsquery_placeholder}, 32) DESC" + return "publish_date DESC NULLS LAST, id DESC" async def search_similar_listings(args: SearchSimilarInput) -> str: @@ -359,12 +360,20 @@ 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 + query_value = args.query or "" + tsquery = "" + if query_value.strip(): + # Добавляем FTS-условие + tsquery = f"plainto_tsquery('ukrainian', ${len(params) + 1})" + fts_condition = f"search_vector @@ {tsquery}" + params.append(query_value) + full_where = ( + f"{where_sql} AND {fts_condition}" + if where_sql != "TRUE" + else fts_condition + ) + else: + full_where = where_sql # COUNT count_sql = f""" @@ -376,10 +385,10 @@ order_by = _build_order_by_clause(args.sort_by, tsquery) - # SELECT с ранжированием + # SELECT с ранжированием (rank_score = NULL если query пустой) select_sql = f""" SELECT {_SELECT_COLUMNS}, - ts_rank_cd(search_vector, {tsquery}, 32) AS rank_score + {f"ts_rank_cd(search_vector, {tsquery}, 32)" if tsquery else "NULL"} AS rank_score FROM property_listings WHERE {full_where} ORDER BY {order_by} diff --git a/tests/test_models.py b/tests/test_models.py index 910097f..fc92b6b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -50,6 +50,11 @@ assert inp.pagination.limit == 20 +def test_search_metadata_input_empty_query(): + inp = SearchMetadataInput() + assert inp.query is None + + def test_search_metadata_sort_by_validation(): # Допустимое значение inp = SearchMetadataInput(query="test", sort_by="price_asc")