Сервис построен по принципу слоистой архитектуры (Layered Architecture) с разделением на слои:
┌─────────────────────────────────────────────────────────────┐
│ API Layer │
│ POST /api/v1/properties/ingest │
│ Pydantic validation → FastAPI router │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Service Layer │
│ PropertyPipeline │
│ ├── AiNormalizer (Ollama) │
│ ├── PropertyNormalizer │
│ ├── ImageDownloader │
│ ├── AiImageAnalyzer (Ollama Vision) │
│ └── AiEnricher (Ollama) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Repository Layer │
│ RawDataRepository PropertyRepository │
│ ImageRepository CustomFieldRepository │
│ SnapshotRepository AiEnrichmentRepository │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ PostgreSQL (asyncpg) Ollama API (httpx) │
│ Local filesystem (images) structlog │
└─────────────────────────────────────────────────────────────┘
Parser ──► POST /api/v1/properties/ingest
│
▼
RawDataIngestSchema (Pydantic validate)
│
▼
[RawDataRepository] save(status=pending)
│
▼
PropertyPipeline.process(raw_data_id)
│
├─► [AiNormalizer] LLM validates & structures
│ ├──► reject → raw_data.status = invalid
│ └──► accept → NormalizedPropertySchema
│
├─► [PropertyRepository] upsert
│ ├──► new → INSERT property_listings
│ └──► existing → INSERT snapshot + UPDATE listing
│
├─► [ImageDownloader] async download
│ └──► local filesystem + property_images records
│
├─► [AiImageAnalyzer] vision analysis per image
│ └──► ai_description + analysis_status
│
├─► [AiEnricher] text + image_analysis
│ └──► ai_enrichments record
│
└─► [RawDataRepository] status = completed
│
▼
Response 202 Accepted
Same external_id from same source
│
▼
Load existing property_listings
│
▼
[SnapshotRepository] save current state as JSONB
│
▼
[PropertyRepository] update fields
│
▼
[CustomFieldRepository] delete old + insert new
│
▼
[ImageDownloader] skip existing (hash match), download new
│
▼
Re-run AiImageAnalyzer + AiEnricher
│
▼
Response 202 { property_id, snapshot_id }
┌──────────────────┐ ┌─────────────────────┐
│ data_sources │ │ property_types │
├──────────────────┤ ├─────────────────────┤
│ id (PK) │ │ id (PK) │
│ slug (UQ) │ │ slug (UQ) │
│ name │ │ name │
│ url_pattern │ │ description │
│ description │ └─────────────────────┘
│ created_at │
└──────────────────┘
│
│ 1:N
▼
┌──────────────────────────────────────────────┐
│ raw_parsing_data │
├──────────────────────────────────────────────┤
│ id (PK) │
│ source_id (FK) ──────────────────────────────┐
│ external_id │
│ payload (JSONB) │
│ status: pending/processing/completed/failed/invalid
│ validation_result │
│ error_message │
│ received_at │
│ processed_at │
└──────────────────────────────────────────────┘
│
│ 1:1
▼
┌──────────────────────────────────────────────┐
│ property_listings │
├──────────────────────────────────────────────┤
│ id (PK) │
│ raw_data_id (FK, UQ) ◄───────────────────────┘
│ source_id (FK) │
│ external_id │
│ title, description, generated_description │
│ deal_type, property_type_id (FK) │
│ price, currency, original_price, original_currency
│ price_per_sqm │
│ total_area, living_area, kitchen_area, land_area
│ rooms_count, bedrooms_count, bathrooms_count │
│ layout, floor, floors_total │
│ building_year, building_type │
│ renovation_status, ceiling_height, material │
│ has_balcony, has_loggia, balcony_count │
│ loggia_count, bathroom_type │
│ elevator_count, has_freight_elevator │
│ parking_type, heating_type │
│ internet, security │
│ windows_direction, window_view │
│ address_raw, city, district, micro_district │
│ street, house_number │
│ metro_station, metro_distance_min │
│ metro_distance_type │
│ latitude, longitude │
│ contact_phone, contact_name, contact_email │
│ is_agent, agency_name │
│ publish_date, url_source │
│ listing_status │
│ images_count │
│ listing_quality_score │
│ reliability_rating │
│ sentiment_score │
│ created_at, updated_at │
└──────────────────────────────────────────────┘
│
┌────┼────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────────┐ ┌─────────────────┐
│property│ │property_custom│ │ ai_enrichments │
│_images │ │ _fields │ │ │
├────────┤ ├──────────────┤ ├─────────────────┤
│id (PK) │ │id (PK) │ │id (PK) │
│property│ │property_id(FK) │ │property_id(FK,UQ)│
│_id(FK) │ │field_name │ │extracted_features│
│url │ │field_value │ │price_assessment │
│local_ │ │field_type │ │listing_quality_score│
│path │ │ │ │reliability_rating│
│hash │ │UQ(property_id,│ │sentiment_score │
│file_size│ │ field_name) │ │classification │
│width │ │ │ │image_analysis_ │
│height │ │ │ │ results │
│download│ │ │ │generated_description│
│_status │ │ │ │summary │
│ai_ │ │ │ │model_version │
│description│ │ │processing_time_ms│
│analysis│ │ │ │created_at │
│_status │ │ │ └─────────────────┘
│order_ │ │ │
│index │ │ │
└────────┘ └──────────────┘
│
▼
┌──────────────────────┐
│ property_snapshots │
├──────────────────────┤
│ id (PK) │
│ property_id (FK) │
│ snapshot_data (JSONB)│
│ changed_fields (JSONB)│
│ created_at │
└──────────────────────┘
raw_parsing_data.payload (title, description, params).NormalizedPropertySchema или ValidationError.property_listings + property_custom_fields.custom_fields.httpx async GET по URL.IMAGE_STORAGE_PATH / {property_id} / {hash}.{ext}.property_images записей.ImageAnalysisSchema (состояние, комнаты, проблемы, плюсы).llava или llama3.2-vision.image_analysis_results.AiEnrichmentSchema.llama3.2 или аналог.IMAGE_STORAGE_PATH/
└── properties/
└── {property_id}/
├── {hash1}.jpg
├── {hash2}.png
└── {hash3}.jpg
Примеры payload, которые должны получить status = invalid:
// Автомобиль
{ "title": "Продам Toyota Camry 2020", "price": 1500000, ... }
// Телефон
{ "title": "iPhone 15 Pro Max 256GB", ... }
// Животное
{ "title": "Котёнок британский даром", ... }
// Вакансия
{ "title": "Требуется водитель категории B", ... }
Примеры, которые должны пройти (status = completed):
// Квартира (даже если неструктурированно)
{ "title": "Продам 2-к квартиру 55м² на 5 этаже" }
// Дом
{ "title": "Дом 120м² на участке 6 соток" }
// Гараж
{ "title": "Продам гараж 18м² в ГСК Маяк" }
// Земля
{ "title": "Участок 10 соток ИЖС в с. Вешки" }
Два complementary endpoint'а для поиска объявлений:
POST /api/v1/search/similarnomic-embed-text:latest → embedding <=> query_vector в PostgreSQLPOST /api/v1/search/fulltextukrainian (копия simple + ukrainian_stop словарь)scripts/ukrainian.stopквартира ≠ квартири). См. [[REVIEW_FOLLOWUP.md]] пункт 15.1. FTS pre-filter: tsquery находит кандидатов (top-100) 2. Vector re-rank: cosine similarity среди кандидатов (top-10) 3. Комбинированный score: α * rank_score + β * similarity_score
Текущая архитектура рассчитана на среднюю нагрузку (синхронная обработка в HTTP-запросе).
Для масштабирования:
PropertyPipeline.process() в фоновую задачу (Celery / RQ / arq).202 Accepted с job_id и сразу отдаёт ответ.| Решение | Обоснование |
|---|---|
| Async SQLAlchemy + asyncpg | FastAPI async. Лучшее использование I/O-bound операций (БД + HTTP к Ollama). |
| JSONB для payload / snapshot | Парсеры присылают разные структуры. Гибкость без ALTER TABLE. |
| Structured LLM output | Ollama поддерживает JSON mode. Надёжнее парсинга свободного текста. |
| Repository pattern | Легко мокировать в тестах. Замена БД без изменения бизнес-логики. |
| SHA-256 дедупликация изображений | Одна и та же картинка может быть по разным URL. |
| Custom fields таблица | Парсеры постоянно добавляют новые атрибуты. Не нужно менять схему БД. |
| Snapshots | Требование бизнеса: история изменений объявлений. |
| Ollama (локальный LLM) | Независимость от внешних API. Конфиденциальность данных. Контроль затрат. |
| Pillow для изображений | Лёгкая зависимость. Извлечение метаданных без тяжёлых библиотек. |