# VMK 360 Data Collector — Техническое задание

## 1. Общее описание

Сервис приёма, нормализации и ИИ-обогащения данных об объектах недвижимости.

Парсеры отправляют полусырые данные через REST API. Сервис валидирует их с помощью локальной LLM (Ollama), приводит к единому формату, анализирует изображения, обогащает текст ИИ и сохраняет в PostgreSQL. При повторном приёме объявления (по `source_id` + `external_id`) создаётся снапшот старой версии и обновляется текущая.

## 2. Домен: объекты недвижимости

### 2.1 Типы объектов (property_types)
- Квартира
- Дом
- Таунхаус
- Коммерческая
- Земельный участок
- Гараж
- Офис
- Склад
- Торговая площадь
- Коттедж
- Комната
- Новостройка

### 2.2 Типы сделок (deal_types)
- `sale` — продажа
- `rent_long` — долгосрочная аренда
- `rent_short` — посуточная аренда

## 3. Модель данных

### 3.1 Справочники

| Таблица | Поля | Описание |
|---------|------|----------|
| **data_sources** | `id`, `slug` (UNIQUE), `name`, `url_pattern`, `description`, `created_at` | Источники парсеров |
| **property_types** | `id`, `slug` (UNIQUE), `name`, `description` | Типы объектов недвижимости |
| **deal_types** | `id`, `slug` (UNIQUE), `name` | Типы сделок |

### 3.2 Основные сущности

#### raw_parsing_data
Приёмник всех данных от парсеров.

| Поле | Тип | Описание |
|------|-----|----------|
| `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)`

#### property_listings
Нормализованное объявление. Максимум типичных полей для недвижимости.

| Поле | Тип | Описание |
|------|-----|----------|
| `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)`

#### property_images
Картинки объявления.

| Поле | Тип | Описание |
|------|-----|----------|
| `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_custom_fields
Универсальные кастомные атрибуты. Всё, что не вошло в типичные поля `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)`

#### property_snapshots
История обновлений объявлений.

| Поле | Тип | Описание |
|------|-----|----------|
| `id` | SERIAL PK | |
| `property_id` | FK → property_listings | |
| `snapshot_data` | JSONB | Полная копия listing + custom_fields |
| `changed_fields` | JSONB | Diff (только изменённые поля) |
| `created_at` | TIMESTAMPTZ | |

#### ai_enrichments
Результат ИИ-анализа.

| Поле | Тип | Описание |
|------|-----|----------|
| `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 | |

### 3.3 Связи

```
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
```

## 4. AI-слой (Ollama)

### 4.1 Конфигурация

```bash
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
```

### 4.2 AiNormalizer (валидация + структуризация)

**Задача**: определить, является ли payload объявлением о недвижимости. Если нет — вернуть `is_real_estate: false`. Если да — вернуть нормализованную структуру.

**Границы валидации**:
- **Reject**: автомобили, телефоны, вакансии, мебель, бытовая техника, животные.
- **Accept**: квартиры, дома, гаражи, земля, офисы, склады, торговые площади.
- **Uncertain**: стройматериалы, услуги ремонта — требуют внимания, но пока accept.

**Поведение при reject**:
- `raw_parsing_data.status = invalid`
- `validation_result = invalid`
- Ответ API: `202 { job_id, status: "invalid", reason: "..." }`
- НИЧЕГО не создаётся в `property_listings`.

**Структурированный выход** (JSON schema в prompt):
```json
{
  "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
  }
}
```

### 4.3 AiImageAnalyzer

**Задача**: анализ каждого изображения объекта.

**Вход**: base64 картинка.
**Выход**:
```json
{
  "overall_condition": "хороший ремонт, современный стиль",
  "rooms_observed": 3,
  "issues_found": ["пятно на потолке"],
  "positive_highlights": ["панорамные окна", "встроенная кухня"],
  "view_from_window": "двор, детская площадка",
  "furniture_included": true,
  "appliances_included": ["холодильник", "стиральная машина"]
}
```

**Процесс**:
1. Скачивание картинок по URL (asyncio.gather).
2. Сохранение в `IMAGE_STORAGE_PATH / {property_id} / {hash}.ext`.
3. Дедупликация по SHA-256.
4. Извлечение width/height (Pillow).
5. Vision-анализ через Ollama (`llava` / `llama3.2-vision`).

### 4.4 AiEnricher (финальное обогащение)

**Задача**: анализ текста + результатов image_analysis.

**Вход**:
- Нормализованный текст (title, description, custom_fields)
- Агрегированные `image_analysis_results`

**Выход**:
```json
{
  "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"
}
```

### 4.5 Ollama клиент

- **Текст**: POST `/api/chat` с JSON mode (system prompt: «Ответь ТОЛЬКО JSON»).
- **Vision**: `/api/chat` с `images: [base64...]`.
- **Retries**: exponential backoff (2^n сек), max 5 попыток.
- **Timeout**: 60–120 секунд.
- **Circuit breaker**: при 5 ошибок подряд — fallback на mock или queue.
- **Mock режим**: при `OLLAMA_MOCK=true` возвращается фиксированный JSON без реального вызова.

## 5. Пайплайн обработки (PropertyPipeline)

```
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 }
```

### 5.1 Повторные объявления (Upsert)

При совпадении `source_id + external_id`:
1. Создаём снапшот текущей версии в `property_snapshots`.
2. Обновляем `property_listings`.
3. Удаляем старые `property_custom_fields`, создаём новые.
4. Обрабатываем изображения: новые добавляем, существующие по hash пропускаем.
5. Пересоздаём `ai_enrichments`.
6. Обновляем `raw_parsing_data.status = completed`.

## 6. API

### POST /api/v1/properties/ingest

**Гибкий формат входа** (парсеры гарантированно шлют):
```json
{
  "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.currency`
- `payload.contacts` — phone, name
- `payload.address` — строка
- `payload.params` — разнобой ключей от парсера

**Response (invalid / rejected)**:
```json
{
  "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)**:
```json
{
  "job_id": 1,
  "property_id": 42,
  "status": "completed",
  "message": "New property listing created and enriched"
}
```

**Response (existing listing updated)**:
```json
{
  "job_id": 1,
  "property_id": 42,
  "status": "completed",
  "message": "Existing property listing updated, snapshot saved",
  "snapshot_id": 15
}
```

## 7. Тесты

### Unit-тесты
- `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, дедупликация.

### Integration-тесты
- `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).

## 8. Переменные окружения (.env)

```bash
# 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
```

## 9. Технологический стек

- **Python 3.12+**
- **FastAPI** — REST API
- **PostgreSQL** + **asyncpg** — БД
- **SQLAlchemy 2.x** (async) + **Alembic** — ORM и миграции
- **Pydantic** — валидация и Settings
- **httpx** — HTTP клиент (Ollama API + скачивание картинок)
- **Pillow** — обработка изображений (width/height)
- **tenacity** — retries
- **structlog** — логирование
- **pytest + pytest-asyncio** — тестирование
- **Docker Compose** — PostgreSQL и Ollama

## 10. Порядок реализации

| Фаза | Что делаем |
|------|-----------|
| **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` |
