"""Pydantic-модели для входных параметров и выходных результатов инструментов."""
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field
class PaginationParams(BaseModel):
"""Базовая пагинация."""
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=(
"Тип сделки: sale=продажа, rent_long=долгосрочная аренда, "
"rent_short=посуточная"
),
)
city: str | None = Field(
default=None,
description=(
"Город на украинском языке. Поиск по подстроке (ILIKE) — "
"достаточно части названия, например 'Київ' или 'Львів'. Регистр не важен."
),
)
district: str | None = Field(
default=None,
description=(
"Район на украинском языке. Поиск по подстроке (ILIKE). "
"Примеры: 'Печерський', 'Шевченківський', 'Галицький'."
),
)
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,
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="Минимальная общая площадь, м².",
)
max_total_area: float | None = Field(
default=None,
ge=0,
description="Максимальная общая площадь, м².",
)
building_type: Literal["brick", "panel", "monolith", "gas_block", "wood"] | None = Field(
default=None,
description=(
"Тип постройки: brick=кирпичный, panel=панельный, monolith=монолитный, "
"gas_block=газоблок, wood=деревянный."
),
)
floor: int | None = Field(
default=None,
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="Статус объявления. По умолчанию сервер не фильтрует — включая все статусы.",
)
metro_station: str | None = Field(
default=None,
description=(
"Станция метро на украинском. Поиск по подстроке (ILIKE). "
"Примеры: 'Арсенальна', 'Театральна', 'Площа Ринок'. "
"Регистр не важен."
),
)
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):
"""Входные параметры для векторного поиска.
Используй, когда пользователь описывает желания, атмосферу или качества
(«уютная квартира с ремонтом у метро»), а не конкретные ключевые слова.
"""
query: str = Field(
...,
description=(
"Текстовый запрос на украинском языке для поиска по смыслу. "
"Формулируй конкретно, без разговорных стоп-слов. "
"✓ Хорошо: '2-кімнатна квартира біля метро з ремонтом'. "
"✗ Плохо: 'дай квартиру недорого' (стоп-слова)."
),
)
filters: MetadataFilters = Field(default_factory=MetadataFilters)
pagination: PaginationParams = Field(default_factory=PaginationParams)
# min_similarity зашит в код всегда 0.7 (широкий поиск)
class SearchMetadataInput(BaseModel):
"""Входные параметры для поиска по метаданным/FTS.
Используй, когда пользователь называет конкретные ключевые слова:
район, станция метро, улица, особенности ('Печерський район метро Арсенальна').
"""
query: str = Field(
...,
description=(
"Текстовый запрос на украинском языке для полнотекстового поиска. "
"Подходит для конкретных названий и ключевых слов. "
"✓ Хорошо: 'Печерський район Арсенальна метро'. "
"✗ Плохо: 'хочу квартиру' (слишком общее, используй search_similar)."
),
)
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):
"""Входные параметры для получения объявления по ID."""
listing_id: int = Field(..., ge=1, description="ID объявления (положительное целое число).")
class ListingResult(BaseModel):
"""Результат поиска — одно объявление."""
id: int
title: str
description: str | None = None
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_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
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_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
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 > limit и offset + limit < total — есть ещё результаты.
Для следующей страницы: offset += limit.
"""
total: int = Field(description="Общее количество найденных записей (без LIMIT).")
limit: int
offset: int
listings: list[ListingResult]