Алгоритмы TenderLens¶
1. Получение данных¶
Единый адаптер¶
class SourceAdapter(Protocol):
source_code: str
async def fetch_page(self, cursor: str | None, limit: int) -> SourcePage:
...
TED и Contracts Finder имеют разные внешние JSON-структуры, но возвращают одну Pydantic-модель TenderRecordV1.
HTTP-политика¶
Для каждого запроса:
- проверить
http/httpsи allowlist host; - дождаться базовой задержки + jitter;
- занять
BoundedSemaphore; - выполнить запрос с timeout;
- вручную проверить redirect target;
- на
429/5xx, timeout или network error выполнить ограниченный retry; - учитывать
Retry-After; - после исчерпания попыток вернуть typed error.
Contracts Finder может использовать отдельный cooldown на 403; для остальных источников 403 не ретраится.
Обработка страницы¶
- получить cursor из
sources; - запросить одну страницу;
- пропустить и залогировать отдельные malformed records;
- для каждой записи выполнить
persist_record; - скачать новые/ошибочные вложения;
- опубликовать event при изменении метаданных или готовности нового файла;
- только после всей порции обновить cursor.
2. Детерминированный hash и UPSERT¶
В hash входят нормализованные значимые поля и отсортированный список вложений. Не входят timestamps обработки и состояние индекса.
Результат:
- новая запись:
index_status=pending; - тот же hash: повторная индексация не требуется;
- новый hash: поля обновляются,
index_status=pending, старыйindexed_hashсохраняется до успешного reindex.
3. Скачивание вложения¶
- очистить имя и построить путь внутри root;
- открыть HTTP stream;
- проверить
Content-Length, если он есть; - писать в
.partнебольшими chunks; - одновременно считать SHA-256 и фактический размер;
- при превышении лимита удалить временный файл;
- после полного успеха выполнить atomic
replace; - сохранить path, hash, size и content type.
Ошибка одного вложения не прекращает обработку остальных закупок.
4. Событие NATS¶
{
"schema_version": 1,
"event_id": "uuid",
"occurred_at": "2026-08-20T10:00:00Z",
"tender_id": "uuid",
"content_hash": "64 hex"
}
Crawler публикует событие после DB commit. JetStream использует Nats-Msg-Id=event_id и durable consumer.
5. Извлечение текста¶
Indexer формирует TextUnit из:
- метаданных закупки с понятными label;
- PDF с текстовым слоем;
- XML;
- HTML без
script/style; - JSON;
- TXT.
Unsupported binary сохраняется как вложение, но возвращает пустой список текста и не ломает весь tender.
6. Чанкинг¶
- сохранить границу
TextUnitи section; - разделить текст по пустым строкам;
- собирать chunk до
1500символов; - переносить хвост не более
150символов в следующий chunk; - не сохранять пустые chunks;
- вычислить детерминированный content hash и chunk key.
7. Embeddings и атомарная индексация¶
- проверить, что event не stale и ещё не обработан;
- собрать все drafts;
- вызвать
AIProvider.embed()одним batch; - проверить count и размерность каждого вектора (
1024); - повторно заблокировать tender и сверить hash;
- в одной транзакции удалить старые chunks, добавить новые и поставить
ready; - после commit ACK event.
При ошибке embeddings/insert старые chunks остаются доступными.
8. Exact semantic search¶
- проверить query и limit;
- получить один query embedding той же моделью;
- выполнить SQL:
- преобразовать distance в cosine similarity
1 - distance; - вернуть sanitized metadata и snippet, не раскрывая raw payload, hashes и local paths.
9. Grounded RAG¶
- выполнить тот же retrieval;
- если список пуст, не вызывать generation и вернуть детерминированный ответ о недостатке данных;
- передать максимум top-k fragments;
- system prompt объявляет документы недоверенными данными;
- модель должна отвечать только по контексту;
- API возвращает текст и ровно те sources, которые пришли из retrieval.
Название и описание индексируются отдельными chunks, чтобы длинные метаданные не размывали их embeddings.
Retrieval отбрасывает результаты ниже MIN_RELEVANCE_SCORE (по умолчанию 0.20).
Если после фильтра список пуст, generation provider не вызывается.
10. API-key и rate limit¶
Аутентификация¶
Открытый key не сохраняется.
Fixed UTC-minute¶
Для /search и /ask используется общий счётчик одного ключа:
SELECT ... FOR UPDATEпо UUID ключа;- определить начало текущей UTC-минуты;
- при новом окне сбросить count;
- если count достиг limit, rollback и
429; - иначе увеличить count и commit.
Row lock обеспечивает атомарность между конкурентными API-processes, работающими с одной БД.
Успешный ответ содержит X-RateLimit-*; Retry-After добавляется только к HTTP 429.