Перейти к содержанию

Данные и контракты

Контракт отвечает на вопрос «какие поля допустимы и что они означают». Pydantic-контракты защищают границы Python, SQL constraints — постоянное состояние, JSON Schema — межпроцессное событие.

ER-модель

erDiagram
    SOURCES ||--o{ TENDERS : contains
    TENDERS ||--o{ ATTACHMENTS : owns
    TENDERS ||--o{ CHUNKS : indexed_as
    ATTACHMENTS o|--o{ CHUNKS : contributes

    SOURCES {
      uuid id PK
      varchar code UK
      text cursor
      timestamptz last_sync_at
    }
    TENDERS {
      uuid id PK
      uuid source_id FK
      text external_id
      text title
      char content_hash
      char indexed_hash
      varchar index_status
      jsonb raw_payload
    }
    ATTACHMENTS {
      uuid id PK
      uuid tender_id FK
      text source_url
      text local_path
      char sha256
      bigint size_bytes
      varchar download_status
    }
    CHUNKS {
      uuid id PK
      uuid tender_id FK
      uuid attachment_id FK
      char chunk_key UK
      text content
      vector embedding
      varchar embedding_model
    }
    API_KEYS {
      uuid id PK
      char key_hash UK
      boolean enabled
      integer limit_per_minute
      integer request_count
    }

Таблицы

sources

Одна строка на внешний источник. cursor — непрозрачная позиция пагинации: TenderLens не разбирает её смысл, а возвращает API как есть. last_sync_at показывает последнюю успешно зафиксированную порцию.

tenders

Поле Значение
source_id + external_id бизнес-идентичность закупки внутри источника
title, description, buyer_name нормализованные человекочитаемые поля
amount, currency необязательная стоимость и ISO-подобный трёхбуквенный код
published_at, deadline UTC timestamps; naive datetime нормализуется в UTC
source_url публичная карточка первоисточника
content_hash SHA-256 текущих значимых полей и списка вложений
indexed_hash hash версии, для которой chunks успешно записаны
index_status pending, processing, ready или failed
last_error диагностическое сообщение последней индексации
raw_payload исходный JSON для аудита и будущего remapping

content_hash == indexed_hash вместе с index_status=ready означает согласованный поисковый индекс.

attachments

Строка сначала регистрирует удалённый документ (pending), затем загрузчик добавляет local_path, фактический size_bytes, sha256 и переводит её в ready. failed сохраняет ошибку для повторного запуска. skipped означает, что документ исчез из новой версии upstream metadata.

chunks

Chunk — атом поиска. attachment_id=NULL означает метаданные самой закупки. position сохраняет порядок, section объясняет происхождение, content_hash отслеживает текст, chunk_key детерминированно объединяет tender/attachment/position/content/model. embedding всегда VECTOR(1024).

api_keys

Хранит только key_hash, флаг активности и состояние fixed-window limiter. window_started_at округлён до начала UTC-минуты, request_count меняется под row lock.

SQLAlchemy-описание: models.py. Фактический DDL: migration 0001.

Нормализованная закупка TenderRecordV1

Оба внешних API должны вернуть один и тот же внутренний тип:

{
  "source": "ted",
  "external_id": "584491-2026",
  "title": "...",
  "description": null,
  "buyer_name": "...",
  "amount": "100000.00",
  "currency": "EUR",
  "published_at": "2026-08-20T00:00:00Z",
  "deadline": null,
  "source_url": "https://...",
  "attachments": [],
  "raw_payload": {}
}

extra="forbid" не позволяет тихо принять случайное внутреннее поле. Обязательные строки обрезаются и не могут быть пустыми. Отрицательная сумма и неверная currency отклоняются. Реализация: TenderRecordV1.

Событие TenderChangedV1

{
  "schema_version": 1,
  "event_id": "uuid",
  "occurred_at": "2026-08-20T10:00:00Z",
  "tender_id": "uuid",
  "content_hash": "64 lowercase hex"
}

Событие содержит ссылку на состояние, а не копию закупки. Indexer всегда перечитывает row из PostgreSQL и сверяет hash. JSON Schema находится в schemas/tender-changed-v1.schema.json.

HTTP-контракты

Model Endpoint Главное ограничение
SearchRequest POST /api/v1/search query 3..1000, limit 1..10
AskRequest POST /api/v1/ask query 3..1000, limit 1..5
SearchResponse Search query + items
AskResponse Ask answer + использованные sources
TenderDetails GET tender нет raw payload, hash и local path
ErrorResponse все ошибки code, safe message, request_id, optional details

Состояния

stateDiagram-v2
    [*] --> pending: новая/изменённая закупка
    pending --> processing: indexer начал
    processing --> ready: chunks committed
    processing --> failed: ошибка
    failed --> processing: повторное событие
    ready --> pending: metadata/attachment изменились
    ready --> ready: повтор того же hash
stateDiagram-v2
    [*] --> pending: вложение зарегистрировано
    pending --> ready: atomic download
    pending --> failed: HTTP/size/write error
    failed --> ready: повторный crawl
    ready --> skipped: URL исчез upstream
    skipped --> pending: URL вернулся