"""Реализация инструментов MCP-сервера для поиска по базе недвижимости."""
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 (
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)
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"
"✗ Плохой пример: 'дай квартиру недорого'"
)
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:
"""🔍 Векторный поиск объявлений по смыслу запроса.
Использует 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) + "]"
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 - settings.mcp_default_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,
)
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:
"""📋 Полнотекстовый поиск + фильтры по метаданным.
Использует готовую 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-условие
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
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 {order_by}
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,
)
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:
"""📄 Получить объявление по 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": "Объявление не найдено",
"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)
async def describe_schema() -> str:
"""ℹ️ Описание схемы базы данных.
Возвращает описание таблицы `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."
),
"sorting": (
"search_by_metadata поддерживает сортировку: "
"relevance=по релевантности (умолч.), price_asc/desc=по цене, "
"date_desc=новые сверху, area_desc=по площади."
),
"pagination_strategy": (
"total > limit означает, что есть следующая страница. "
"offset += limit для перехода вперёд. "
"offset -= limit (но не ниже 0) — для перехода назад."
),
},
"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": "Количество комнат (0 = студия)"},
"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": "Дата обновления записи"},
},
"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)",
"index": "GIN",
"config": "ukrainian",
"description": (
"Полнотекстовый поиск по title, description, city, district, metro_station"
),
},
},
}
return json.dumps(schema_info, indent=2, ensure_ascii=False)