diff --git a/src/vmk_data_mcp/db.py b/src/vmk_data_mcp/db.py index f7b3bd6..cebf402 100644 --- a/src/vmk_data_mcp/db.py +++ b/src/vmk_data_mcp/db.py @@ -11,7 +11,8 @@ # Разрешённые команды начинаются только с SELECT / WITH / VALUES / EXPLAIN _SAFE_PREFIXES = ("select", "with", "values", "explain") -# Колонки, доступные для выборки и фильтрации (белый список) +# Колонки, доступные для выборки и фильтрации (белый список). +# Синхронизировано с реальной схемой БД (исключены технические raw_data_id, source_id, external_id). USER_COLUMNS = frozenset( { "id", @@ -20,35 +21,64 @@ "generated_description", "price", "currency", + "original_price", + "original_currency", + "price_per_sqm", "deal_type", "city", "district", + "micro_district", + "street", + "house_number", + "address_raw", "rooms_count", + "bedrooms_count", + "bathrooms_count", "total_area", "living_area", "kitchen_area", + "land_area", "floor", + "floors_total", "building_type", "building_year", "renovation_status", + "ceiling_height", + "material", "balcony_count", + "loggia_count", + "has_balcony", + "has_loggia", "bathroom_type", + "elevator_count", + "has_freight_elevator", "parking_type", "heating_type", + "internet", + "security", + "layout", "window_view", + "windows_direction", "metro_station", "metro_distance_type", - "metro_distance_meters", + "metro_distance_min", + "latitude", + "longitude", "url_source", "publish_date", "images_count", "contact_phone", + "contact_name", + "contact_email", + "is_agent", + "agency_name", "listing_status", + "listing_quality_score", + "reliability_rating", + "sentiment_score", "archived_at", "created_at", "updated_at", - "search_vector", - "embedding", } ) diff --git a/src/vmk_data_mcp/models.py b/src/vmk_data_mcp/models.py index 6a710af..99353e2 100644 --- a/src/vmk_data_mcp/models.py +++ b/src/vmk_data_mcp/models.py @@ -53,11 +53,29 @@ "Примеры: 'Печерський', 'Шевченківський', 'Галицький'." ), ) + micro_district: str | None = Field( + default=None, + description="Микрорайон. Поиск по подстроке (ILIKE).", + ) + street: str | None = Field( + default=None, + description="Улица. Поиск по подстроке (ILIKE). Пример: 'вул. Шевченка'.", + ) rooms_count: int | None = Field( default=None, ge=0, description="Количество комнат. 0 = студия, 1 = 1-комнатная и т.д.", ) + bedrooms_count: int | None = Field( + default=None, + ge=0, + description="Количество спален.", + ) + bathrooms_count: int | None = Field( + default=None, + ge=0, + description="Количество ванных комнат.", + ) min_price: float | None = Field( default=None, ge=0, @@ -97,6 +115,11 @@ ge=0, description="Этаж квартиры. 0 обычно означает цоколь/подвал (если есть в базе).", ) + floors_total: int | None = Field( + default=None, + ge=0, + description="Общее количество этажей в здании.", + ) listing_status: Literal["active", "sold", "rented", "removed", "archived"] | None = Field( default=None, description="Статус объявления. По умолчанию сервер не фильтрует — включая все статусы.", @@ -109,6 +132,52 @@ "Регистр не важен." ), ) + metro_distance_type: Literal["walk", "transport"] | None = Field( + default=None, + description="Удалённость от метро: walk=пешком, transport=на транспорте.", + ) + metro_distance_min: int | None = Field( + default=None, + ge=0, + description="Время до метро в минутах.", + ) + layout: Literal["studio", "separate", "adjacent"] | None = Field( + default=None, + description="Тип планировки: studio=студия, separate=раздельная, adjacent=смежная.", + ) + renovation_status: ( + Literal["cosmetic", "euro", "designer", "none", "construction"] | None + ) = Field( + default=None, + description="Состояние ремонта.", + ) + bathroom_type: Literal["combined", "separate", "multiple"] | None = Field( + default=None, + description="Тип санузла: combined=совмещённый, separate=раздельный, multiple=несколько.", + ) + parking_type: Literal["ground", "underground", "none", "garage"] | None = Field( + default=None, + description="Тип парковки.", + ) + heating_type: Literal["central", "autonomous", "floor", "none"] | None = Field( + default=None, + description=( + "Тип отопления: central=центральное, autonomous=автономное, " + "floor=подогрев полов, none=нет." + ), + ) + window_view: Literal["yard", "street", "park", "water", "forest"] | None = Field( + default=None, + description="Вид из окон.", + ) + has_balcony: bool | None = Field( + default=None, + description="Наличие балкона.", + ) + has_loggia: bool | None = Field( + default=None, + description="Наличие лоджии.", + ) class SearchSimilarInput(BaseModel): @@ -181,32 +250,61 @@ generated_description: str | None = None price: float | None = None currency: str | None = None + original_price: float | None = None + original_currency: str | None = None + price_per_sqm: float | None = None deal_type: str | None = None city: str | None = None district: str | None = None + micro_district: str | None = None + street: str | None = None + house_number: str | None = None + address_raw: str | None = None rooms_count: int | None = None + bedrooms_count: int | None = None + bathrooms_count: int | None = None total_area: float | None = None living_area: float | None = None kitchen_area: float | None = None + land_area: float | None = None floor: int | None = None - floors_count: int | None = None + floors_total: int | None = None building_type: str | None = None building_year: int | None = None renovation_status: str | None = None + ceiling_height: float | None = None + material: str | None = None balcony_count: int | None = None + loggia_count: int | None = None + has_balcony: bool | None = None + has_loggia: bool | None = None bathroom_type: str | None = None + elevator_count: int | None = None + has_freight_elevator: bool | None = None parking_type: str | None = None heating_type: str | None = None - layout_type: str | None = None + internet: bool | None = None + security: bool | None = None + layout: str | None = None window_view: str | None = None + windows_direction: str | None = None metro_station: str | None = None metro_distance_type: str | None = None - metro_distance_meters: int | None = None + metro_distance_min: int | None = None + latitude: float | None = None + longitude: float | None = None url_source: str | None = None publish_date: date | None = None images_count: int | None = None contact_phone: str | None = None + contact_name: str | None = None + contact_email: str | None = None + is_agent: bool | None = None + agency_name: str | None = None listing_status: str | None = None + listing_quality_score: int | None = None + reliability_rating: int | None = None + sentiment_score: float | None = None archived_at: date | None = None created_at: date | None = None updated_at: date | None = None diff --git a/src/vmk_data_mcp/tools.py b/src/vmk_data_mcp/tools.py index e55b22f..b1f6ea5 100644 --- a/src/vmk_data_mcp/tools.py +++ b/src/vmk_data_mcp/tools.py @@ -84,6 +84,62 @@ conditions.append(f"metro_station ILIKE ${len(params) + 1}") params.append(f"%{filters.metro_station}%") + if filters.micro_district is not None: + conditions.append(f"micro_district ILIKE ${len(params) + 1}") + params.append(f"%{filters.micro_district}%") + + if filters.street is not None: + conditions.append(f"street ILIKE ${len(params) + 1}") + params.append(f"%{filters.street}%") + + if filters.bedrooms_count is not None: + conditions.append(f"bedrooms_count = ${len(params) + 1}") + params.append(filters.bedrooms_count) + + if filters.bathrooms_count is not None: + conditions.append(f"bathrooms_count = ${len(params) + 1}") + params.append(filters.bathrooms_count) + + if filters.floors_total is not None: + conditions.append(f"floors_total = ${len(params) + 1}") + params.append(filters.floors_total) + + if filters.metro_distance_min is not None: + conditions.append(f"metro_distance_min = ${len(params) + 1}") + params.append(filters.metro_distance_min) + + if filters.layout is not None: + conditions.append(f"layout = ${len(params) + 1}") + params.append(filters.layout) + + if filters.renovation_status is not None: + conditions.append(f"renovation_status = ${len(params) + 1}") + params.append(filters.renovation_status) + + if filters.bathroom_type is not None: + conditions.append(f"bathroom_type = ${len(params) + 1}") + params.append(filters.bathroom_type) + + if filters.parking_type is not None: + conditions.append(f"parking_type = ${len(params) + 1}") + params.append(filters.parking_type) + + if filters.heating_type is not None: + conditions.append(f"heating_type = ${len(params) + 1}") + params.append(filters.heating_type) + + if filters.window_view is not None: + conditions.append(f"window_view = ${len(params) + 1}") + params.append(filters.window_view) + + if filters.has_balcony is not None: + conditions.append(f"has_balcony = ${len(params) + 1}") + params.append(filters.has_balcony) + + if filters.has_loggia is not None: + conditions.append(f"has_loggia = ${len(params) + 1}") + params.append(filters.has_loggia) + where_sql = " AND ".join(conditions) if conditions else "TRUE" return where_sql, params @@ -388,7 +444,8 @@ ), "filter_best_practices": ( "Обязательно указывай currency при min_price/max_price. " - "city и district — поиск по подстроке (ILIKE), регистр не важен. " + "city, district, micro_district, street, metro_station — " + "поиск по подстроке (ILIKE), регистр не важен. " "rooms_count: 0 = студия. " "Если результатов мало — убери district или metro_station." ), @@ -409,7 +466,10 @@ "description": {"type": "text", "description": "Описание от продавца"}, "generated_description": {"type": "text", "description": "AI-сгенерированное описание"}, "price": {"type": "numeric", "description": "Цена"}, - "currency": {"type": "enum(USD,EUR,UAH)", "description": "Валюта"}, + "currency": {"type": "text", "description": "Валюта (USD, EUR, UAH)"}, + "original_price": {"type": "numeric", "description": "Цена в исходной валюте"}, + "original_currency": {"type": "text", "description": "Исходная валюта"}, + "price_per_sqm": {"type": "numeric", "description": "Цена за м²"}, "deal_type": { "type": "enum", "values": ["sale", "rent_long", "rent_short"], @@ -417,11 +477,19 @@ }, "city": {"type": "text", "description": "Город (украинский)"}, "district": {"type": "text", "description": "Район (украинский)"}, + "micro_district": {"type": "text", "description": "Микрорайон"}, + "street": {"type": "text", "description": "Улица"}, + "house_number": {"type": "text", "description": "Номер дома"}, + "address_raw": {"type": "text", "description": "Сырой адрес строкой"}, "rooms_count": {"type": "integer", "description": "Количество комнат (0 = студия)"}, + "bedrooms_count": {"type": "integer", "description": "Количество спален"}, + "bathrooms_count": {"type": "integer", "description": "Количество ванных комнат"}, "total_area": {"type": "numeric", "description": "Общая площадь, м²"}, "living_area": {"type": "numeric", "description": "Жилая площадь, м²"}, "kitchen_area": {"type": "numeric", "description": "Площадь кухни, м²"}, + "land_area": {"type": "numeric", "description": "Площадь участка, м²"}, "floor": {"type": "integer", "description": "Этаж"}, + "floors_total": {"type": "integer", "description": "Всего этажей в здании"}, "building_type": { "type": "enum", "values": ["brick", "panel", "monolith", "gas_block", "wood"], @@ -430,58 +498,85 @@ "building_year": {"type": "integer", "description": "Год постройки"}, "renovation_status": { "type": "enum", - "values": [ - "no_renovation", - "cosmetic", - "european", - "designer", - "full", - ], + "values": ["cosmetic", "euro", "designer", "none", "construction"], "description": "Состояние ремонта", }, + "ceiling_height": {"type": "numeric", "description": "Высота потолков, м"}, + "material": {"type": "text", "description": "Материал стен"}, "balcony_count": {"type": "integer", "description": "Количество балконов"}, + "loggia_count": {"type": "integer", "description": "Количество лоджий"}, + "has_balcony": {"type": "boolean", "description": "Есть балкон"}, + "has_loggia": {"type": "boolean", "description": "Есть лоджия"}, "bathroom_type": { "type": "enum", - "values": ["combined", "separate", "two_or_more"], + "values": ["combined", "separate", "multiple"], "description": "Тип санузла", }, + "elevator_count": {"type": "integer", "description": "Количество лифтов"}, + "has_freight_elevator": {"type": "boolean", "description": "Есть грузовой лифт"}, "parking_type": { "type": "enum", - "values": ["no_parking", "ground", "underground", "garage"], + "values": ["ground", "underground", "none", "garage"], "description": "Тип парковки", }, "heating_type": { "type": "enum", - "values": ["central", "autonomous", "individual", "none"], + "values": ["central", "autonomous", "floor", "none"], "description": "Тип отопления", }, + "internet": {"type": "boolean", "description": "Наличие интернета"}, + "security": {"type": "boolean", "description": "Охрана/безопасность"}, + "layout": { + "type": "enum", + "values": ["studio", "separate", "adjacent"], + "description": "Тип планировки", + }, "window_view": { "type": "enum", - "values": ["courtyard", "street", "park", "water", "mixed"], + "values": ["yard", "street", "park", "water", "forest"], "description": "Вид из окон", }, + "windows_direction": {"type": "text", "description": "Направление окон"}, "metro_station": { "type": "text", "description": "Станция метро (украинский)", }, "metro_distance_type": { "type": "enum", - "values": ["walking", "transport", "far"], + "values": ["walk", "transport"], "description": "Удалённость от метро", }, - "metro_distance_meters": { + "metro_distance_min": { "type": "integer", - "description": "Расстояние до метро в метрах", + "description": "Время до метро в минутах", }, + "latitude": {"type": "numeric", "description": "Широта"}, + "longitude": {"type": "numeric", "description": "Долгота"}, "url_source": {"type": "text", "description": "Исходный URL объявления"}, "publish_date": {"type": "date", "description": "Дата публикации"}, "images_count": {"type": "integer", "description": "Количество фото"}, "contact_phone": {"type": "text", "description": "Телефон контакта"}, + "contact_name": {"type": "text", "description": "Имя контакта"}, + "contact_email": {"type": "text", "description": "Email контакта"}, + "is_agent": {"type": "boolean", "description": "Контакт — агент"}, + "agency_name": {"type": "text", "description": "Название агентства"}, "listing_status": { "type": "enum", "values": ["active", "sold", "rented", "removed", "archived"], "description": "Статус объявления", }, + "listing_quality_score": { + "type": "integer", + "description": "Оценка качества объявления", + }, + "reliability_rating": { + "type": "integer", + "description": "Рейтинг надёжности", + }, + "sentiment_score": { + "type": "numeric", + "description": "Сентимент-оценка", + }, "archived_at": {"type": "date", "description": "Дата архивации"}, "created_at": {"type": "timestamp", "description": "Дата создания записи"}, "updated_at": {"type": "timestamp", "description": "Дата обновления записи"}, @@ -515,6 +610,7 @@ "city + deal_type + rooms_count + currency", "city + district + deal_type + min_price + max_price + currency", "city + metro_station + rooms_count + listing_status=active", + "street + layout + floors_total", ], "avoid": [ "min_price/max_price без currency — фильтрует по всем валютам сразу", @@ -528,6 +624,10 @@ "model": "nomic-embed-text (Ollama)", "description": "Семантический поиск по смыслу. Запросы на украинском.", "similarity_threshold": "Всегда 0.7 (широкий поиск).", + "note": ( + "Требует колонки embedding в БД (pgvector). " + "Если отсутствует — vector-поиск не работает." + ), }, "full_text_search": { "column": "search_vector tsvector (generated)", @@ -536,6 +636,7 @@ "description": ( "Полнотекстовый поиск по title, description, city, district, metro_station" ), + "note": "Требует колонки search_vector в БД. Если отсутствует — FTS не работает.", }, }, }