diff --git a/src/vmk_data_mcp/main.py b/src/vmk_data_mcp/main.py index 2bcc221..0381430 100644 --- a/src/vmk_data_mcp/main.py +++ b/src/vmk_data_mcp/main.py @@ -1,4 +1,14 @@ -"""Точка входа MCP-сервера с HTTP-транспортом (streamable-http).""" +"""Точка входа MCP-сервера с HTTP-транспортом (streamable-http). + +VMK Data MCP Server предоставляет AI-агентам безопасный read-only доступ +к базе данных недвижимости vmk_data (PostgreSQL + pgvector). + +Данные: ~35 полей объявлений (цена, площадь, район, метро, тип сделки, ...). +Поиск: семантический (vector 768d через Ollama) + полнотекстовый (FTS). +Язык: украинский (запросы переводятся агентом перед вызовом). +Зачем: AI-агент может искать квартиры/дома по описанию пользователя, +фильтровать по бюджету и району, показывать детали объявлений. +""" import json import logging @@ -52,7 +62,71 @@ lifespan=app_lifespan, ) -# ── Регистрация инструментов ────────────────────────────────────────── +# ── Prompts (гайды для AI-агента) ─────────────────────────────────── + + +@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 результатов. +""" + + +# ── Регистрация инструментов ──────────────────────────────────────── @mcp.tool() @@ -73,14 +147,23 @@ metro_station: str | None = None, limit: int = 20, offset: int = 0, - min_similarity: float = 0.7, ) -> str: - """🔍 Векторный (семантический) поиск объявлений. + """🔍 Векторный (семантический) поиск объявлений по смыслу. - Находит объявления по **смыслу** запроса, используя pgvector + HNSW. - Поддерживает фильтры по метаданным (цена, район, комнаты и т.д.). + Используй, когда пользователь описывает **желания, атмосферу, качества** + («уютная квартира с ремонтом», «светлая студия у метро»). + Не используй для точных ключевых слов (район, метро) — для этого + используй `search_by_metadata`. **Важно:** запрос `query` должен быть на **украинском** языке. + Формулируй конкретно, без разговорных стоп-слов. + + **Фильтры:** обязательно указывай `currency` при `min_price`/`max_price`. + `city` и `district` — поиск по подстроке (ILIKE), регистр не важен. + `rooms_count`: 0 = студия. + + **Fallback при 0 результатов:** сервер вернёт подсказку с рекомендациями + (попробовать FTS, убрать фильтры, упростить query). """ try: args = SearchSimilarInput( @@ -101,7 +184,6 @@ "metro_station": metro_station, }, pagination={"limit": limit, "offset": offset}, - min_similarity=min_similarity, ) return await search_similar_listings(args) except httpx.HTTPError as e: @@ -136,11 +218,18 @@ ) -> str: """📋 Полнотекстовый поиск + фильтры по метаданным. - Ищет по словам в заголовке, описании, городе, районе, метро - через готовую FTS-колонку `search_vector` (украинский конфиг) + GIN-индекс. - Поддерживает те же фильтры, что и `search_similar_listings`. + Используй, когда пользователь называет **конкретные ключевые слова** + («Печерський район», «Арсенальна метро», «вул. Шевченка»). + Работает через готовую FTS-колонку `search_vector` (украинский конфиг) + GIN-индекс. **Важно:** запрос `query` должен быть на **украинском** языке. + Используй имена собственные и термины, а не разговорные описания. + + **Фильтры:** те же, что и для `search_similar_listings`. + Обязательно указывай `currency` при `min_price`/`max_price`. + + **Fallback при 0 результатов:** сервер вернёт подсказку с рекомендациями + (попробовать vector search, убрать фильтры). """ try: args = SearchMetadataInput( @@ -176,6 +265,8 @@ """📄 Получить полную карточку объявления по ID. Возвращает все пользовательские поля объявления. + Если объявление не найдено — вернёт ошибку с подсказкой + (проверить ID, возможен статус archived/removed). """ try: args = GetListingInput(listing_id=listing_id) @@ -192,8 +283,9 @@ async def describe_schema_tool() -> str: """ℹ️ Описание схемы данных. - Возвращает описание таблицы `property_listings`: - поля, типы, enum-значения, особенности поиска (vector + FTS). + Возвращает описание таблицы `property_listings` с гайдами по использованию: + когда использовать 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 e077a4c..5c78049 100644 --- a/src/vmk_data_mcp/models.py +++ b/src/vmk_data_mcp/models.py @@ -9,54 +9,144 @@ class PaginationParams(BaseModel): """Базовая пагинация.""" - limit: int = Field(default=20, ge=1, le=100, description="Количество результатов на странице") - offset: int = Field(default=0, ge=0, description="Смещение для пагинации") + limit: int = Field( + default=20, + ge=1, + le=100, + description=( + "Количество результатов на странице. " + "Если total > limit, используй offset+=limit для следующей страницы." + ), + ) + offset: int = Field( + default=0, + ge=0, + description="Смещение для пагинации. Следующая страница: offset += limit.", + ) class MetadataFilters(BaseModel): - """Фильтры по метаданным, общие для всех инструментов поиска.""" + """Фильтры по метаданным, общие для всех инструментов поиска. + + Все фильтры объединяются через AND. Если результатов мало — попробуй убрать + 1–2 фильтра (обычно district или metro_station). + """ deal_type: Literal["sale", "rent_long", "rent_short"] | None = Field( - default=None, description="Тип сделки" + default=None, + description=( + "Тип сделки: sale=продажа, rent_long=долгосрочная аренда, " + "rent_short=посуточная" + ), ) - 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="Валюта") + city: str | None = Field( + default=None, + description=( + "Город на украинском языке. Поиск по подстроке (ILIKE) — " + "достаточно части названия, например 'Київ' или 'Львів'. Регистр не важен." + ), + ) + district: str | None = Field( + default=None, + description=( + "Район на украинском языке. Поиск по подстроке (ILIKE). " + "Примеры: 'Печерський', 'Шевченківський', 'Галицький'." + ), + ) + rooms_count: int | None = Field( + default=None, + ge=0, + description="Количество комнат. 0 = студия, 1 = 1-комнатная и т.д.", + ) + min_price: float | None = Field( + default=None, + ge=0, + description=( + "Минимальная цена. Обязательно указывай currency вместе с min_price/max_price, " + "иначе фильтр применится ко всем валютам одновременно." + ), + ) + max_price: float | None = Field( + default=None, + ge=0, + description="Максимальная цена. Указывай вместе с currency.", + ) + currency: Literal["USD", "EUR", "UAH"] | None = Field( + default=None, + description="Валюта цены. Обязательна при использовании min_price/max_price.", + ) min_total_area: float | None = Field( - default=None, ge=0, description="Минимальная общая площадь (м²)" + default=None, + ge=0, + description="Минимальная общая площадь, м².", ) max_total_area: float | None = Field( - default=None, ge=0, description="Максимальная общая площадь (м²)" + default=None, + ge=0, + description="Максимальная общая площадь, м².", ) building_type: Literal["brick", "panel", "monolith", "gas_block", "wood"] | None = Field( - default=None, description="Тип постройки" + default=None, + description=( + "Тип постройки: brick=кирпичный, panel=панельный, monolith=монолитный, " + "gas_block=газоблок, wood=деревянный." + ), ) - floor: int | None = Field(default=None, ge=0, description="Этаж") + floor: int | None = Field( + default=None, + ge=0, + description="Этаж квартиры. 0 обычно означает цоколь/подвал (если есть в базе).", + ) listing_status: Literal["active", "sold", "rented", "removed", "archived"] | None = Field( - default=None, description="Статус объявления" + default=None, + description="Статус объявления. По умолчанию сервер не фильтрует — включая все статусы.", ) - metro_station: str | None = Field(default=None, description="Станция метро (украинский)") + metro_station: str | None = Field( + default=None, + description=( + "Станция метро на украинском. Поиск по подстроке (ILIKE). " + "Примеры: 'Арсенальна', 'Театральна', 'Площа Ринок'. " + "Регистр не важен." + ), + ) class SearchSimilarInput(BaseModel): - """Входные параметры для векторного поиска.""" + """Входные параметры для векторного поиска. - query: str = Field(..., description="Текстовый запрос на украинском языке для поиска по смыслу") + Используй, когда пользователь описывает желания, атмосферу или качества + («уютная квартира с ремонтом у метро»), а не конкретные ключевые слова. + """ + + query: str = Field( + ..., + description=( + "Текстовый запрос на украинском языке для поиска по смыслу. " + "Формулируй конкретно, без разговорных стоп-слов. " + "✓ Хорошо: '2-кімнатна квартира біля метро з ремонтом'. " + "✗ Плохо: 'дай квартиру недорого' (стоп-слова)." + ), + ) 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="Минимальный порог косинусной близости" - ) + # min_similarity зашит в код всегда 0.7 (широкий поиск) class SearchMetadataInput(BaseModel): - """Входные параметры для поиска по метаданным/FTS.""" + """Входные параметры для поиска по метаданным/FTS. + + Используй, когда пользователь называет конкретные ключевые слова: + район, станция метро, улица, особенности ('Печерський район метро Арсенальна'). + """ query: str = Field( - ..., description="Текстовый запрос на украинском языке для полнотекстового поиска" + ..., + description=( + "Текстовый запрос на украинском языке для полнотекстового поиска. " + "Подходит для конкретных названий и ключевых слов. " + "✓ Хорошо: 'Печерський район Арсенальна метро'. " + "✗ Плохо: 'хочу квартиру' (слишком общее, используй search_similar)." + ), ) filters: MetadataFilters = Field(default_factory=MetadataFilters) pagination: PaginationParams = Field(default_factory=PaginationParams) @@ -65,7 +155,7 @@ class GetListingInput(BaseModel): """Входные параметры для получения объявления по ID.""" - listing_id: int = Field(..., ge=1, description="ID объявления") + listing_id: int = Field(..., ge=1, description="ID объявления (положительное целое число).") class ListingResult(BaseModel): @@ -106,14 +196,26 @@ 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 + similarity_score: float | None = Field( + default=None, + description="Косинусная близость [0..1] для search_similar_listings. " + "1.0 = максимально похоже, 0.0 = не похоже.", + ) + rank_score: float | None = Field( + default=None, + description="Релевантность FTS (ts_rank_cd) для search_by_metadata. " + "Чем выше, тем релевантнее.", + ) class SearchResult(BaseModel): - """Результат поиска — список объявлений + мета.""" + """Результат поиска — список объявлений + мета. - total: int = Field(description="Общее количество найденных записей") + Если total > limit и offset + limit < total — есть ещё результаты. + Для следующей страницы: offset += limit. + """ + + total: int = Field(description="Общее количество найденных записей (без LIMIT).") limit: int offset: int listings: list[ListingResult] diff --git a/src/vmk_data_mcp/tools.py b/src/vmk_data_mcp/tools.py index 7eefee7..8cf8bf3 100644 --- a/src/vmk_data_mcp/tools.py +++ b/src/vmk_data_mcp/tools.py @@ -21,6 +21,9 @@ if col not in {"search_vector", "embedding"} ) +# Минимальная similarity зашита на уровень широкого поиска (≈ 0.7) +_MIN_SIMILARITY = 0.7 + def _build_where_clause(filters: MetadataFilters) -> tuple[str, list]: """Строит WHERE-условия из фильтров. Возвращает (sql_fragment, params). @@ -102,12 +105,95 @@ return ListingResult(**data) +def _build_fallback_hint( + query_type: str = "vector", current_filters: MetadataFilters | None = None +) -> str: + """Возвращает подсказку-гайд для агента при пустых результатах.""" + filters_json = "" + if current_filters: + filters_json = json.dumps( + current_filters.model_dump(exclude_none=True), + indent=2, + ensure_ascii=False, + ) + + hints: list[str] = [] + if query_type == "vector": + hints = [ + "💡 Поиск по смыслу не дал результатов. Попробуй:", + " 1. Переключись на search_by_metadata с тем же query — " + " ключевые слова иногда находят то, что семантика пропустила.", + " 2. Убери 1–2 жёстких фильтра (обычно district или metro_station).", + " 3. Упрости query — убери разговорные слова ('хочу', 'дай', 'недорого').", + " 4. Проверь, что query на украинском языке.", + ] + else: + hints = [ + "💡 Полнотекстовый поиск не дал результатов. Попробуй:", + " 1. Переключись на search_similar_listings — " + " семантика может найти синонимы и перефразировки.", + " 2. Убери фильтры и поищи только по query.", + " 3. Разбей запрос на более простые слова " + " ('квартира Печерський' вместо 'житло в престижному районі').", + ] + + if filters_json and filters_json != "{}": + hints.append(f"\nТекущие фильтры:\n{filters_json}") + + return "\n".join(hints) + + +def _build_invalid_query_hint(query: str) -> str: + """Возвращает подсказку при некорректном формате запроса.""" + problems: list[str] = [] + + # Простые эвристики + lower = query.strip().lower() + stop_words = {"дай", "хочу", "нужно", "пожалуйста", "прошу", "можно"} + found_stops = [w for w in stop_words if w in lower.split()] + if found_stops: + problems.append( + f"⚠️ Разговорные стоп-слова: {', '.join(found_stops)}. " + "Убери их — они ухудшают качество поиска." + ) + + if len(query.strip().split()) < 2: + problems.append( + "⚠️ Запрос слишком короткий. Добавь детали: тип недвижимости, " + "город, район, метро, площадь или цену." + ) + + if not problems: + return "" + + return ( + "\n".join(problems) + + "\n\n✓ Хороший пример: '2-кімнатна квартира біля метро з ремонтом'\n" + "✗ Плохой пример: 'дай квартиру недорого'" + ) + + async def search_similar_listings(args: SearchSimilarInput) -> str: """🔍 Векторный поиск объявлений по смыслу запроса. Использует pgvector + HNSW-индекс для косинусной близости. Запрос должен быть на украинском языке. """ + hint = _build_invalid_query_hint(args.query) + if hint: + # Возвращаем подсказку + пустой результат + empty_result = SearchResult( + total=0, + limit=args.pagination.limit, + offset=args.pagination.offset, + listings=[], + ) + return ( + empty_result.model_dump_json(indent=2, ensure_ascii=False) + + "\n\n" + + hint + ) + embedding = await get_embedding(args.query) embedding_str = "[" + ",".join(str(v) for v in embedding) + "]" @@ -115,7 +201,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 - args.min_similarity) + max_distance = 2.0 * (1.0 - _MIN_SIMILARITY) count_sql = f""" SELECT COUNT(*) FROM property_listings @@ -152,7 +238,14 @@ offset=args.pagination.offset, listings=listings, ) - return result.model_dump_json(indent=2, ensure_ascii=False) + output = result.model_dump_json(indent=2, ensure_ascii=False) + + if total == 0: + output += "\n\n" + _build_fallback_hint( + query_type="vector", current_filters=args.filters + ) + + return output async def search_by_metadata(args: SearchMetadataInput) -> str: @@ -161,6 +254,20 @@ Использует готовую FTS-колонку `search_vector` (ukrainian) + GIN-индекс. Запрос должен быть на украинском языке. """ + hint = _build_invalid_query_hint(args.query) + if hint: + empty_result = SearchResult( + total=0, + limit=args.pagination.limit, + offset=args.pagination.offset, + listings=[], + ) + return ( + empty_result.model_dump_json(indent=2, ensure_ascii=False) + + "\n\n" + + hint + ) + where_sql, params = _build_where_clause(args.filters) # Добавляем FTS-условие @@ -198,7 +305,14 @@ offset=args.pagination.offset, listings=listings, ) - return result.model_dump_json(indent=2, ensure_ascii=False) + output = result.model_dump_json(indent=2, ensure_ascii=False) + + if total == 0: + output += "\n\n" + _build_fallback_hint( + query_type="metadata", current_filters=args.filters + ) + + return output async def get_listing_by_id(args: GetListingInput) -> str: @@ -214,7 +328,15 @@ """ row = await fetchrow(sql, args.listing_id) if row is None: - return json.dumps({"error": "Listing not found"}, ensure_ascii=False) + return json.dumps( + { + "error": "Объявление не найдено", + "hint": "Проверь listing_id. Объявление могло быть удалено или иметь " + "статус 'archived' / 'removed'.", + }, + indent=2, + ensure_ascii=False, + ) listing = _record_to_listing(row) return listing.model_dump_json(indent=2, ensure_ascii=False) @@ -223,14 +345,41 @@ async def describe_schema() -> str: """ℹ️ Описание схемы базы данных. - Возвращает статическое описание таблицы `property_listings` — - поля, типы, enum-значения и примеры запросов. + Возвращает описание таблицы `property_listings` с гайдами по использованию, + примерами запросов и рекомендациями по фильтрам. """ schema_info = { "table": "property_listings", "description": "Объявления о недвижимости (квартиры, дома, аренда, продажа)", "language": "ukrainian", "note": "Все текстовые запросы должны быть на украинском языке.", + "usage_guidelines": { + "when_to_use_vector_search": ( + "Когда пользователь описывает желания, атмосферу или качества: " + "'уютная квартира с ремонтом', 'светлая студия у метро'. " + "Не используй для точных ключевых слов (район, метро) — для этого FTS." + ), + "when_to_use_fts": ( + "Когда пользователь называет конкретные ключевые слова: " + "'Печерський район', 'Арсенальна метро', 'вул. Шевченка'. " + "FTS работает быстрее и точнее для имен собственных." + ), + "hybrid_strategy": ( + "Если vector дал мало результатов — попробуй FTS с тем же query. " + "Если FTS дал много нерелевантных — уточни query конкретными словами." + ), + "filter_best_practices": ( + "Обязательно указывай currency при min_price/max_price. " + "city и district — поиск по подстроке (ILIKE), регистр не важен. " + "rooms_count: 0 = студия. " + "Если результатов мало — убери district или metro_station." + ), + "pagination_strategy": ( + "total > limit означает, что есть следующая страница. " + "offset += limit для перехода вперёд. " + "offset -= limit (но не ниже 0) — для перехода назад." + ), + }, "columns": { "id": {"type": "integer", "description": "Уникальный ID объявления"}, "title": {"type": "text", "description": "Заголовок объявления"}, @@ -245,7 +394,7 @@ }, "city": {"type": "text", "description": "Город (украинский)"}, "district": {"type": "text", "description": "Район (украинский)"}, - "rooms_count": {"type": "integer", "description": "Количество комнат"}, + "rooms_count": {"type": "integer", "description": "Количество комнат (0 = студия)"}, "total_area": {"type": "numeric", "description": "Общая площадь, м²"}, "living_area": {"type": "numeric", "description": "Жилая площадь, м²"}, "kitchen_area": {"type": "numeric", "description": "Площадь кухни, м²"}, @@ -301,7 +450,10 @@ "values": ["courtyard", "street", "park", "water", "mixed"], "description": "Вид из окон", }, - "metro_station": {"type": "text", "description": "Станция метро (украинский)"}, + "metro_station": { + "type": "text", + "description": "Станция метро (украинский)", + }, "metro_distance_type": { "type": "enum", "values": ["walking", "transport", "far"], @@ -324,12 +476,48 @@ "created_at": {"type": "timestamp", "description": "Дата создания записи"}, "updated_at": {"type": "timestamp", "description": "Дата обновления записи"}, }, + "query_examples": { + "vector_search_good": [ + "2-кімнатна квартира біля метро з ремонтом", + "сучасна студія в центрі міста", + "простора 3-кімнатна з балконом і парковкою", + "затишний будинок з ділянкою передмістя", + "квартира з панорамними вікнами та дизайнерським ремонтом", + ], + "vector_search_bad": [ + "дай квартиру недорого", + "хочу житло в Києві", + "прошу підібрати варіанти", + ], + "fts_good": [ + "Печерський район Арсенальна метро", + "вул. Шевченка Львів оренда", + "новобудова моноліт Солом'янський", + ], + "fts_bad": [ + "хороше житло", + "квартира моєї мрії", + "щось недороге і гарне", + ], + }, + "filter_combinations": { + "recommended": [ + "city + deal_type + rooms_count + currency", + "city + district + deal_type + min_price + max_price + currency", + "city + metro_station + rooms_count + listing_status=active", + ], + "avoid": [ + "min_price/max_price без currency — фильтрует по всем валютам сразу", + "Слишком много фильтров одновременно (>5) — часто даёт 0 результатов", + ], + }, "search_features": { "vector_search": { "column": "embedding vector(768)", "index": "HNSW (vector_cosine_ops)", "model": "nomic-embed-text (Ollama)", "description": "Семантический поиск по смыслу. Запросы на украинском.", + "similarity_threshold": "Всегда 0.7 (широкий поиск).", }, "full_text_search": { "column": "search_vector tsvector (generated)", diff --git a/tests/test_models.py b/tests/test_models.py index 6be12cf..2cc2fea 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -37,8 +37,9 @@ def test_search_similar_input(): inp = SearchSimilarInput(query="двокімнатна квартира київ") assert inp.query == "двокімнатна квартира київ" - assert inp.min_similarity == 0.7 assert inp.pagination.limit == 20 + # min_similarity зашит внутри tools.py (всегда 0.7) + assert not hasattr(inp, "min_similarity") def test_search_metadata_input(): @@ -62,3 +63,24 @@ result = SearchResult(total=5, limit=10, offset=0, listings=[]) data = result.model_dump() assert data["total"] == 5 + + +def test_descriptions_contain_hints(): + """Проверяем, что Field descriptions содержат подсказки для агента.""" + schema = SearchSimilarInput.model_json_schema() + query_desc = schema["properties"]["query"]["description"] + assert "✓ Хорошо" in query_desc + assert "✗ Плохо" in query_desc + + filter_schema = MetadataFilters.model_json_schema() + city_desc = filter_schema["properties"]["city"]["description"] + assert "ILIKE" in city_desc + assert "Київ" in city_desc + + +def test_listing_result_scores_documented(): + """similarity_score и rank_score содержат описания.""" + listing = ListingResult(id=1, title="Test") + # Просто проверяем что модель создаётся и сериализуется + assert listing.similarity_score is None + assert listing.rank_score is None