Обзор архитектуры¶
TenderLens — минимально распределённое событийное приложение: три процесса разделяют один домен и одну БД, а медленная индексация отделена durable-очередью.
Контекст системы¶
flowchart TB
OP["Оператор / разработчик"] --> UI["Web UI"]
CLIENT["API-клиент"] --> API["FastAPI"]
UI --> API
API --> PG[("PostgreSQL + pgvector")]
API --> OLLAMA["Ollama"]
TED["TED"] --> CRAWLER["Crawler"]
CF["Contracts Finder"] --> CRAWLER
CRAWLER --> PG
CRAWLER --> FILES[("Attachment volume")]
CRAWLER --> NATS["NATS JetStream"]
NATS --> INDEXER["Indexer"]
INDEXER --> FILES
INDEXER --> OLLAMA
INDEXER --> PG
Почему три роли, а не три проекта¶
Пакет tender_lens содержит общие модели, контракты, настройки и интеграции. Один Docker image запускается разными командами. Это исключает копирование типов и упрощает тестовое задание, но оставляет процессную изоляцию там, где она действительно нужна.
| Роль | Вход | Выход | Может перезапускаться независимо |
|---|---|---|---|
| crawler | внешние HTTP API, cursor | rows, files, NATS event | да |
| indexer | durable NATS event | chunks, embeddings, index status | да |
| API | HTTP request | JSON/UI response | да |
Crawler не ждёт embeddings. API не извлекает документы. Indexer не обращается к внешним реестрам.
Владение данными¶
flowchart LR
CRAWLER["Crawler"] -->|"создаёт/обновляет"| SOURCES["sources"]
CRAWLER -->|"создаёт/обновляет"| TENDERS["tenders"]
CRAWLER -->|"скачивает"| ATT["attachments"]
INDEXER["Indexer"] -->|"заменяет атомарно"| CHUNKS["chunks"]
INDEXER -->|"меняет index_*"| TENDERS
API["API"] -->|"читает"| TENDERS
API -->|"читает/vector search"| CHUNKS
API -->|"блокирует счётчик"| KEYS["api_keys"]
Владение здесь означает «кто имеет право менять основное состояние», а не отдельную физическую БД. Ограничение поддерживается слоями сервиса и тестами.
Слои Python-пакета¶
entrypoints crawler/__main__.py · indexer/__main__.py · api/main.py · cli.py
orchestration crawler/service.py · indexer/service.py · search.py
adapters crawler/ted.py · crawler/contracts_finder.py · ai.py
domain schemas.py · hashing.py · errors.py
persistence models.py · db.py · migrations/
infrastructure nats.py · storage.py · logging.py
presentation api/routes.py · web/
Зависимости направлены преимущественно сверху вниз. Доменные схемы не открывают соединения, импорт модулей не должен требовать PostgreSQL/NATS/Ollama, а connection lifecycle создаётся в entrypoint или FastAPI lifespan.
Синхронные и асинхронные границы¶
- Сетевой I/O и SQLAlchemy работают через
asyncio. - Ограниченная параллельность —
asyncio.BoundedSemaphore, а не OS threads. - Извлечение PDF сейчас синхронное и выполняется внутри indexer; для больших документов это известная граница MVP.
- NATS отделяет ingestion от embeddings и обеспечивает at-least-once delivery.
- HTTP Search/Ask синхронны с точки зрения клиента и не проходят через NATS.
Инварианты¶
Инвариант — условие, которое должно оставаться истинным при повторе, сбое или конкуренции.
| Инвариант | Механизм | Реализация |
|---|---|---|
| закупка уникальна внутри источника | unique (source_id, external_id) |
Tender |
| событие относится к версии данных | content_hash в row и event |
TenderChangedV1 |
| старое событие не побеждает новое | повторная hash-проверка под row lock | IndexerService.process() |
| незавершённый publish восстанавливается | index_status=pending |
republish_pending() |
| файлы не видны частично | .part + fsync + os.replace |
download_attachment() |
| лимит атомарен между API-процессами | PostgreSQL FOR UPDATE |
consume_rate_limit() |
Технологический словарь¶
- FastAPI — HTTP routing, dependency injection и OpenAPI.
- SQLAlchemy asyncio — async ORM/session layer.
- PostgreSQL — транзакционное состояние.
- pgvector — тип
VECTORи cosine distance operator. - NATS JetStream — durable stream и explicit ACK.
- Ollama API — локальные embeddings и generation.
- httpx — асинхронный HTTP-клиент.
- Pydantic — runtime-валидация контрактов.
Конкретное значение каждого термина в проекте раскрыто в глоссарии.