Сервис приёма, нормализации и ИИ-обогащения данных об объектах недвижимости.
Парсеры отправляют полусырые данные через REST API. Сервис валидирует их с помощью локальной LLM (Ollama), приводит к единому формату, анализирует изображения, обогащает текст ИИ и сохраняет в PostgreSQL. При повторном приёме объявления (по source_id + external_id) создаётся снапшот старой версии и обновляется текущая.
sale — продажаrent_long — долгосрочная арендаrent_short — посуточная аренда| Таблица | Поля | Описание |
|---|---|---|
| data_sources | id, slug (UNIQUE), name, url_pattern, description, created_at |
Источники парсеров |
| property_types | id, slug (UNIQUE), name, description |
Типы объектов недвижимости |
| deal_types | id, slug (UNIQUE), name |
Типы сделок |
Приёмник всех данных от парсеров.
| Поле | Тип | Описание |
|---|---|---|
id |
SERIAL PK | |
source_id |
FK → data_sources | |
external_id |
VARCHAR | ID от парсера |
payload |
JSONB | Абсолютно любая структура от парсера |
status |
ENUM | pending, processing, completed, failed, invalid |
validation_result |
VARCHAR | valid, invalid, uncertain |
error_message |
TEXT | nullable |
received_at |
TIMESTAMPTZ | |
processed_at |
TIMESTAMPTZ | nullable |
Уникальность: UNIQUE(source_id, external_id)
Нормализованное объявление. Максимум типичных полей для недвижимости.
| Поле | Тип | Описание |
|---|---|---|
id |
SERIAL PK | |
raw_data_id |
FK → raw_parsing_data (UNIQUE) | |
source_id |
FK → data_sources | |
external_id |
VARCHAR | ID от парсера |
title |
VARCHAR | Оригинальное название |
description |
TEXT | Оригинальное описание |
generated_description |
TEXT | AI-описание |
deal_type |
ENUM | sale, rent_long, rent_short |
property_type_id |
FK → property_types | |
price |
NUMERIC(18,2) | |
currency |
VARCHAR(3) | UAH / USD / EUR |
original_price |
NUMERIC(18,2) | Цена в валюте источника |
original_currency |
VARCHAR(3) | |
price_per_sqm |
NUMERIC(18,2) | |
total_area |
NUMERIC(8,2) | Общая площадь, м² |
living_area |
NUMERIC(8,2) | Жилая площадь |
kitchen_area |
NUMERIC(8,2) | Площадь кухни |
land_area |
NUMERIC(10,2) | Площадь участка, соток/га |
rooms_count |
SMALLINT | |
bedrooms_count |
SMALLINT | |
bathrooms_count |
SMALLINT | |
layout |
VARCHAR | studio, separate, adjacent |
floor |
SMALLINT | |
floors_total |
SMALLINT | |
building_year |
SMALLINT | |
building_type |
VARCHAR | brick, panel, monolith, gas_block, wood |
renovation_status |
VARCHAR | cosmetic, euro, designer, none, construction |
ceiling_height |
NUMERIC(4,2) | В метрах |
material |
VARCHAR | Материал стен |
has_balcony |
BOOLEAN | |
has_loggia |
BOOLEAN | |
balcony_count |
SMALLINT | |
loggia_count |
SMALLINT | |
bathroom_type |
VARCHAR | combined, separate, multiple |
elevator_count |
SMALLINT | |
has_freight_elevator |
BOOLEAN | |
parking_type |
VARCHAR | ground, underground, none, garage |
heating_type |
VARCHAR | central, autonomous, floor, none |
internet |
BOOLEAN | |
security |
BOOLEAN | |
windows_direction |
VARCHAR | Направление окон |
window_view |
VARCHAR | yard, street, park, water, forest |
address_raw |
TEXT | Адрес как пришёл |
city |
VARCHAR | |
district |
VARCHAR | Район |
micro_district |
VARCHAR | Микрорайон |
street |
VARCHAR | Улица |
house_number |
VARCHAR | Номер дома |
metro_station |
VARCHAR | Станция метро |
metro_distance_min |
SMALLINT | Минут до метро |
metro_distance_type |
VARCHAR | walk, transport |
latitude |
NUMERIC(10,7) | |
longitude |
NUMERIC(10,7) | |
contact_phone |
VARCHAR | |
contact_name |
VARCHAR | |
contact_email |
VARCHAR | |
is_agent |
BOOLEAN | |
agency_name |
VARCHAR | |
publish_date |
TIMESTAMPTZ | |
url_source |
TEXT | URL объявления |
listing_status |
ENUM | active, sold, rented, removed, archived |
images_count |
SMALLINT | |
listing_quality_score |
SMALLINT | AI-оценка качества объявления, 1–10 |
reliability_rating |
SMALLINT | AI-оценка надёжности, 1–5 |
sentiment_score |
NUMERIC(3,2) | -1..1 |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Уникальность: UNIQUE(source_id, external_id)
Картинки объявления.
| Поле | Тип | Описание |
|---|---|---|
id |
SERIAL PK | |
property_id |
FK → property_listings | |
url |
TEXT | Исходный URL |
local_path |
VARCHAR | Путь к файлу на диске |
hash |
VARCHAR(64) | SHA-256 для дедупликации |
file_size |
INTEGER | Байты |
width |
SMALLINT | |
height |
SMALLINT | |
download_status |
ENUM | pending, downloaded, failed |
ai_description |
TEXT | AI-описание фото |
analysis_status |
ENUM | pending, completed, failed |
order_index |
SMALLINT | Порядок в галерее |
Универсальные кастомные атрибуты. Всё, что не вошло в типичные поля property_listings.
| Поле | Тип | Описание |
|---|---|---|
id |
SERIAL PK | |
property_id |
FK → property_listings | |
field_name |
VARCHAR | Ключ |
field_value |
TEXT | Значение |
field_type |
VARCHAR | str, int, float, bool, date, json |
Уникальность: UNIQUE(property_id, field_name)
История обновлений объявлений.
| Поле | Тип | Описание |
|---|---|---|
id |
SERIAL PK | |
property_id |
FK → property_listings | |
snapshot_data |
JSONB | Полная копия listing + custom_fields |
changed_fields |
JSONB | Diff (только изменённые поля) |
created_at |
TIMESTAMPTZ |
Результат ИИ-анализа.
| Поле | Тип | Описание |
|---|---|---|
id |
SERIAL PK | |
property_id |
FK → property_listings (UNIQUE) | |
extracted_features |
JSONB | Доп. параметры из текста |
price_assessment |
JSONB | { market_estimate, deviation_percent, confidence, comment } |
listing_quality_score |
SMALLINT | 1–10 |
reliability_rating |
SMALLINT | 1–5 |
sentiment_score |
NUMERIC(3,2) | -1..1 |
classification |
VARCHAR | AI-классификация объявления |
image_analysis_results |
JSONB | Агрегированный анализ фото |
generated_description |
TEXT | AI-сгенерированное описание |
summary |
TEXT | Краткое резюме |
model_version |
VARCHAR | Версия/модель LLM |
processing_time_ms |
INTEGER | |
created_at |
TIMESTAMPTZ |
raw_parsing_data 1:1 property_listings property_listings 1:N property_images property_listings 1:N property_custom_fields property_listings 1:N property_snapshots property_listings 1:1 ai_enrichments property_listings N:1 property_types property_listings N:1 data_sources
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_TEXT_MODEL=llama3.2 OLLAMA_VISION_MODEL=llava OLLAMA_TIMEOUT=120 OLLAMA_MOCK=false IMAGE_STORAGE_PATH=/var/lib/vmk/images
Задача: определить, является ли payload объявлением о недвижимости. Если нет — вернуть is_real_estate: false. Если да — вернуть нормализованную структуру.
Границы валидации:
Поведение при reject:
raw_parsing_data.status = invalidvalidation_result = invalid202 { job_id, status: "invalid", reason: "..." }property_listings.Структурированный выход (JSON schema в prompt):
{
"is_real_estate": true,
"property_type": "apartment",
"deal_type": "sale",
"title": "...",
"description": "...",
"price": 12200000,
"currency": "RUB",
"total_area": 78.5,
"rooms_count": 3,
"floor": 12,
"floors_total": 16,
"building_year": 2019,
"address": "г. Москва, ул. Ленина, 15",
"city": "Москва",
"metro_station": "Таганская",
"metro_distance_min": 8,
"contact_phone": "+7...",
"contact_name": "...",
"is_agent": false,
"images": ["https://..."],
"custom_fields": {
"balcony_type": "лоджия",
"ceiling_height": 2.85
}
}
Задача: анализ каждого изображения объекта.
Вход: base64 картинка. Выход:
{
"overall_condition": "хороший ремонт, современный стиль",
"rooms_observed": 3,
"issues_found": ["пятно на потолке"],
"positive_highlights": ["панорамные окна", "встроенная кухня"],
"view_from_window": "двор, детская площадка",
"furniture_included": true,
"appliances_included": ["холодильник", "стиральная машина"]
}
Процесс:
IMAGE_STORAGE_PATH / {property_id} / {hash}.ext.llava / llama3.2-vision).Задача: анализ текста + результатов image_analysis.
Вход:
image_analysis_resultsВыход:
{
"extracted_features": {
"property_complex": "ЖК Солнечный",
"developer": "Группа Самолет",
"material": "монолит",
"additional_params": { "кондиционер": true }
},
"price_assessment": {
"market_estimate": 12500000,
"deviation_percent": -3.5,
"confidence": 0.82,
"comment": "Цена соответствует рынку"
},
"listing_quality_score": 8,
"reliability_rating": 4,
"sentiment_score": 0.6,
"classification": "secondary_sale_apartment",
"generated_description": "Продаётся уютная 3-комнатная квартира...",
"summary": "3-комнатная квартира, 78 м², ЖК Солнечный, цена 12.2 млн",
"language": "ru"
}
/api/chat с JSON mode (system prompt: «Ответь ТОЛЬКО JSON»)./api/chat с images: [base64...].OLLAMA_MOCK=true возвращается фиксированный JSON без реального вызова.Parser JSON
│
▼
POST /api/v1/properties/ingest
│
▼
Save raw_parsing_data (status = pending)
│
▼
AiNormalizer (Ollama text model)
│
├──► "Это не недвижимость"
│ └──► raw_data.status = invalid
│ Response: 202 { job_id, status: invalid }
│
└──► "Это недвижимость"
│
▼
Structured JSON от Ollama
│
▼
Check: source_id + external_id exists?
│
├──► Нет → CREATE property_listings
│
└──► Да → UPDATE
│
├──► Копируем текущее → property_snapshots
├──► Обновляем property_listings
└──► Удаляем старые custom_fields, пересоздаём
│
▼
Download images (asyncio.gather)
URL → local dir (IMAGE_STORAGE_PATH) → SHA-256 → property_images
│
▼
AiImageAnalyzer (Ollama vision model)
Каждое фото → описание, ремонт, комнаты, проблемы
│
▼
AiEnricher (Ollama text model)
Текст + image_analysis → summary, price_assessment,
quality_score, reliability_rating, generated_description
│
▼
Commit всё в одной транзакции
raw_data.status = completed
│
▼
Response 202 { job_id, property_id, status: completed }
При совпадении source_id + external_id:
property_snapshots.property_listings.property_custom_fields, создаём новые.ai_enrichments.raw_parsing_data.status = completed.Гибкий формат входа (парсеры гарантированно шлют):
{
"source_slug": "olx_parser",
"external_id": "olx_987654",
"payload": {
"title": "Продам 2-комнатную квартиру",
"url": "https://olx.ua/...",
"images": ["https://..."],
"published_at": "2024-06-01T10:00:00Z"
}
}
Опционально могут шлют:
payload.description — текст с кашей параметровpayload.price + payload.currencypayload.contacts — phone, namepayload.address — строкаpayload.params — разнобой ключей от парсераResponse (invalid / rejected):
{
"job_id": 1,
"property_id": null,
"status": "invalid",
"reason": "Payload appears to be a car listing, not real estate",
"message": "Data rejected: not a real estate listing"
}
Response (new listing created):
{
"job_id": 1,
"property_id": 42,
"status": "completed",
"message": "New property listing created and enriched"
}
Response (existing listing updated):
{
"job_id": 1,
"property_id": 42,
"status": "completed",
"message": "Existing property listing updated, snapshot saved",
"snapshot_id": 15
}
test_ai_normalizer.py — валидный reject (авто), валидный accept (квартира), распознавание из каши.test_ai_image_analyzer.py — мок Ollama vision.test_ai_enricher.py — мок Ollama text.test_upsert_pipeline.py — snapshot создан при обновлении.test_image_downloader.py — скачивание, hash, дедупликация.test_api_ingest.py — FastAPI TestClient.test_end_to_end.py — минимальный payload → проверка создания raw + listing + images + custom_fields + enrichment.test_invalid_reject.py — payload про авто → только raw со статусом invalid.test_upsert_integration.py — duplicate external_id → snapshot + update.async_engine — test DB engine.db_session — async session с rollback.client — FastAPI TestClient.mock_ollama_client — фиксированные JSON-ответы.sample_payloads — примеры от разных парсеров (olx, avito, domria).# Database DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/vmk_data DATABASE_POOL_SIZE=20 DATABASE_MAX_OVERFLOW=10 DATABASE_ECHO=false # Application APP_HOST=0.0.0.0 APP_PORT=8000 LOG_LEVEL=info DEBUG=false # Ollama OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_TEXT_MODEL=llama3.2 OLLAMA_VISION_MODEL=llava OLLAMA_TIMEOUT=120 OLLAMA_MOCK=false # Storage IMAGE_STORAGE_PATH=/var/lib/vmk/images # Feature flags ENABLE_IMAGE_ANALYSIS=true ENABLE_PRICE_ESTIMATION=true
| Фаза | Что делаем |
|---|---|
| 1. Фундамент | pyproject.toml, .env.example, docker-compose.yml, директории |
| 2. Модели + миграции | SQLAlchemy модели (8 таблиц), Alembic initial migration, seed property_types |
| 3. Инфраструктура БД | async engine, session, репозитории |
| 4. AI слой | Ollama HTTP client, AiNormalizer, AiImageAnalyzer, AiEnricher |
| 5. Pipeline | Image downloader, PropertyPipeline (upsert + snapshots), FastAPI router |
| 6. Тесты | unit + integration |
| 7. Документация | SPECIFICATION.md, ARCHITECTURE.md, README.md |