CODE WALKTHROUGH · 09.09.2026

TenderLens.
От запроса до ответа.

Разбор работающего проекта без рекламного текста. Что делает интерфейс, куда идёт HTTP-запрос, как живёт очередь, где лежат vectors, когда вызывается MWS и где сгенерированный код уже перемудрил.

Runtime
FastAPI + worker
Data
PostgreSQL + pgvector
Queue
NATS JetStream
AI
MWS bge-m3 + gpt-oss-120b
Ответ MWS и пять источников в TenderLensМобильный поиск TenderLensREAL MWS
4 DOCS / 16 CHUNKS
Как читатьПереключатель скрывает один уровень, ссылки и факты остаются.

01 / Суть проекта

Поиск по смыслу и ответ по найденным документам

Что работает на опубликованном стенде

Реальные NATS JetStream, indexer, PostgreSQL/pgvector, MWS bge-m3 и MWS gpt-oss-120b. Корпус из четырёх документов синтетический. Внешний crawler TED / Contracts Finder в публичной демке выключен.

Машиночитаемый QA-снимок ↗
Объяснение для 10-летнего

Умная библиотека

Представь четыре толстые папки. Компьютер режет текст на карточки и складывает их так, чтобы карточки с похожим смыслом лежали рядом. Ты спрашиваешь про гарантию на серверы. Система находит подходящие карточки и только после этого просит второго помощника написать ответ. Рядом показывает карточки-источники.

Объяснение для программиста

Асинхронный RAG pipeline

Нормализованные tenders проходят event-driven индексацию. Текст превращается в chunks, bge-m3 выдаёт 1024-dimensional embeddings, PostgreSQL делает exact cosine retrieval. Ask строит grounded prompt из top-k и вызывает generation deployment. Пустой retrieval завершает запрос до LLM.

4синтетических документа
16проиндексированных chunks
0.40production threshold демо
1024компонента embedding

Два контура, которые нельзя смешивать

Полный продуктовый контур

TED / Contracts Finder → crawler → attachments → PostgreSQL → JetStream → indexer → AI → Search / Ask.

Crawler реализован и покрыт fixture/integration tests.
Публичный стенд

Synthetic seed / replay → JetStream → indexer → MWS → PostgreSQL → браузер.

Он показывает AI и backend без нагрузки на открытые источники.

02 / Как пользоваться

Сценарий на пять минут

Главный экран TenderLens
Опубликованный стенд. Клик открывает исходный PNG.
  1. Откройте демо.

    Верхний статус должен показать, что API доступен. В блоке индекса — 4 документа и 16 chunks.

  2. Нажмите пример про серверы.

    Форма подставит запрос. Сначала оставьте режим Search.

  3. Выполните Search.

    Смотрите на cosine score, название тендера, snippet и ссылку. Generation model на этом шаге не вызывается.

  4. Переключитесь на Ask.

    Тот же retrieval станет контекстом для gpt-oss-120b. Ответ появится отдельно, под ним останутся те же sources.

  5. Проверьте no-context.

    Спросите о выращивании помидоров. Результат должен быть пустым, а ответ — сообщить о недостатке данных.

  6. Проверьте ошибку.

    Введите два символа. Браузер остановит запрос до API. После пяти Search/Ask в минуту сессия получит 429.

Что смотреть ребёнку

Ответ должен совпадать с карточками под ним. Если карточек нет, помощник не должен выдумывать.

Что смотреть инженеру

В DevTools Network сравните `/search` и `/ask`, response headers `X-RateLimit-*`, `X-Request-ID`, payload sources и ветку 429 с `Retry-After`.

03 / Полный поток

Восемь шагов от источника до ответа

У каждого шага есть фактический статус публичной версии и ссылка на точные строки закреплённого коммита.

01

Получить закупку

Полный контур; на публичном стенде заменено синтетическим seed

Просто: Есть два почтальона. Один ходит в европейский TED, второй — в британский Contracts Finder. Они приносят карточки закупок в одном формате.

Технически: Асинхронные adapters нормализуют разные внешние API в TenderRecordV1. ResilientHttpClient ограничивает hosts, проверяет redirect, timeout, Retry-After и число попыток.

Открыть реализацию на GitHub ↗
02

Сохранить состояние

Реально работает в PostgreSQL

Просто: Карточка кладётся в шкаф. Если такая уже есть и не изменилась, вторую копию не создаём.

Технически: UPSERT-поведение построено на паре source_id + external_id и content_hash. Данные фиксируются транзакцией до публикации события; cursor двигается после всей успешно обработанной порции.

Открыть реализацию на GitHub ↗
03

Поставить задачу в очередь

NATS JetStream работает реально

Просто: Вместо крика через комнату сервис оставляет записку в надёжном почтовом ящике: «документ изменился, его надо перечитать».

Технически: Событие TenderChangedV1 публикуется в subject tender.changed.v1. Durable pull consumer получает его как минимум один раз. Nats-Msg-Id убирает повтор только при той же event_id; текущий republish создаёт новый id, поэтому окончательную идемпотентность держит indexer.

Открыть реализацию на GitHub ↗
04

Извлечь и нарезать текст

Реально работает в worker

Просто: Большой документ режется на небольшие карточки. Кусочки немного перекрываются, чтобы мысль не оборвалась на границе.

Технически: Indexer извлекает metadata, TXT, JSON, HTML, XML и PDF с текстовым слоем. Paragraph-first chunking ограничивает chunk 1500 символами и оставляет overlap 150.

Открыть реализацию на GitHub ↗
05

Превратить текст в вектор

Реальная модель MWS bge-m3

Просто: Модель превращает смысл каждого кусочка в длинный список из 1024 чисел. Похожие тексты получают похожие списки.

Технически: MwsAIProvider отправляет batch в OpenAI-compatible /embeddings, восстанавливает порядок по index и жёстко проверяет количество, numeric type и размерность 1024.

Открыть реализацию на GitHub ↗
06

Записать поисковый индекс

PostgreSQL + pgvector работают реально

Просто: Числовые карточки складываются в каталог. По нему можно искать не только одинаковые слова, но и близкий смысл.

Технически: Новый набор chunks заменяет старый внутри одной транзакции после повторной проверки content_hash под row lock. Stale event не может затереть новую версию документа.

Открыть реализацию на GitHub ↗
07

Найти релевантные фрагменты

Реальный embedding запроса и exact cosine search

Просто: Вопрос тоже превращается в числа. База сравнивает его со всеми карточками и отдаёт самые похожие.

Технически: SearchService вычисляет 1 − cosine distance оператором pgvector <=>, применяет MIN_RELEVANCE_SCORE=0.40, стабильную сортировку и LIMIT. На демо-объёме используется exact scan.

Открыть реализацию на GitHub ↗
08

Собрать проверяемый ответ

Реальная MWS gpt-oss-120b; при пустом поиске не вызывается

Просто: Если подходящие карточки есть, вторая модель читает только их и отвечает. Рядом остаются источники, чтобы ответ можно было проверить.

Технически: Ask переиспользует Search. build_rag_prompt маркирует документы как недоверенные данные, передаёт title, URL и snippet. При пустом retrieval возвращается детерминированный no-context ответ без generation cost.

Открыть реализацию на GitHub ↗

04 / Архитектура

Кто с кем разговаривает

Компоненты TenderLens
Компоненты опубликованного стенда · Mermaid source ↓
Для 10-летнего

Команда с разными ролями

Один участник принимает вопросы, другой разбирает документы, третий хранит карточки, четвёртый держит очередь, а две облачные модели считают смысл и пишут ответ. Если один занят, остальные не обязаны останавливаться.

Для программиста

Один image, несколько process roles

API и indexer используют общий typed Settings и provider protocol. NATS отделяет ingestion от indexing. PostgreSQL является source of truth; JetStream хранит команду на обработку, а не копию бизнес-сущности.

Размещение опубликованной версии

Размещение TenderLens на VPS и MWS
Nginx завершает HTTPS; два контейнера по 256 MiB; модели работают в MWS · Mermaid source ↓
КомпонентОтвечает заНе отвечает заПроверка
Browser UIВвод, состояние запроса, вывод sourcesSecrets, retrieval, generationDesktop/mobile QA
FastAPIHTTP contracts, auth, limiter, Search/AskФоновая индексация`/health/live`, `/health/ready`
IndexerExtract, chunk, embeddings, atomic replaceПубличный HTTPready marker + event tests
NATSDurable доставка события измененияХранение карточки закупкиpending / ack / redelivery
PostgreSQLSource of truth, vector search, limitsГенерация текстаmigration + SQL tests
MWSEmbeddings и chat completionХранение corpus`/models` readiness

05 / Данные

Что где хранится

Схема данных TenderLens
Логическая схема хранения · Mermaid source ↓
МестоЧто лежитЗачемСрок / очисткаКод
PostgreSQL · `sources`code, cursor, last_sync_atПродолжить обход источника без перескокаПока источник подключёнmodels.py ↗
PostgreSQL · `tenders`Карточка, raw JSON, content/indexed hash, statusSource of truth и версия индексаПо политике продуктаmodels.py ↗
PostgreSQL · `attachments`URL, local_path, SHA-256, размер, status/errorСвязать файл с tender и диагностировать загрузкуС tender удаляется CASCADEmodels.py ↗
PostgreSQL · `chunks`Текст, section, position, VECTOR(1024), modelRetrievalАтомарно заменяется при новой версииmodels.py ↗
PostgreSQL · `api_keys`Hash, enabled, minute window, countAuth и per-session limitDemo rows старше часа удаляютсяmodels.py ↗
Attachment volumeСкачанные файлыIndexer читает bytes вне БДУдаляется вместе с данными приложенияstorage.py ↗
JetStream `TENDERS`TenderChangedV1 eventsПовторная доставкаmax 8 MiB / 1000 msgs / 1 hnats.py ↗
Browser HttpOnly cookieПодписанный случайный demo tokenИзолировать лимит посетителя без общего ключа1 часportfolio.py ↗
Server environmentDB URL, NATS URL, MWS key, limitsRuntime configurationЖивёт вне Git и imageconfig.py ↗
Секреты

MWS API key и production connection strings не опубликованы. В репозитории есть только имена переменных и безопасные примеры. В браузер MWS key не передаётся.

06 / Code-map

Экран → API → сервис → данные → тест

28 точек входа. Ссылка ведёт на неизменяемый SHA, а не на плавающий `main`.

01

Форма Search / Ask

Вход: Кнопка или Ctrl/Cmd+Enter

Код: web/app.js · submitQuery()

Данные: query + limit=5 → JSON; ответ рендерится через textContent

Проверка: test_static_ui_and_assets_are_local

точные строки ↗
02

Карточка результата

Вход: items[] или sources[]

Код: web/app.js · createResult()

Данные: title, snippet, cosine score, source_url, attachment

Проверка: XSS не вставляется через innerHTML

точные строки ↗
03

Контракты HTTP

Вход: POST payload / response

Код: schemas.py · SearchRequest, AskRequest, SearchResult

Данные: Pydantic: query 3..1000; limit Search 1..10, Ask 1..5

Проверка: test_ask_limit_is_at_most_five…

точные строки ↗
04

Search endpoint

Вход: POST /api/v1/search

Код: api/routes.py · search()

Данные: Depends(rate_limited_key) → SearchService → JSON + rate headers

Проверка: test_search_contract_and_rate_headers

точные строки ↗
05

Ask endpoint

Вход: POST /api/v1/ask

Код: api/routes.py · ask()

Данные: Тот же limiter; answer + sources

Проверка: test_search_and_ask_share_counter…

точные строки ↗
06

Карточка закупки

Вход: GET /api/v1/tenders/{id}

Код: api/routes.py · tender_details()

Данные: Tender + Source + Attachments через selectinload

Проверка: test_tender_details_are_sanitized

точные строки ↗
07

API key

Вход: X-API-Key или signed demo cookie

Код: api/auth.py · authenticate_api_key()

Данные: В БД лежит SHA-256, открытый ключ не хранится

Проверка: missing / unknown / disabled key

точные строки ↗
08

Rate limit сессии

Вход: Dependency перед Search/Ask

Код: api/rate_limit.py · consume_rate_limit()

Данные: api_keys: window_started_at + request_count; SELECT FOR UPDATE

Проверка: atomic under concurrency

точные строки ↗
09

Глобальный лимит демо

Вход: Middleware для /search и /ask

Код: portfolio.py · PortfolioRateGate

Данные: Process-wide asyncio.Lock; 12 запросов/минуту

Проверка: portfolio guard tests

точные строки ↗
10

Демо-сессия

Вход: Первый API request посетителя

Код: portfolio.py · session_context()

Данные: Signed HttpOnly cookie на 1 час; hash становится ApiKey row

Проверка: cookie, expiry, cleanup, cap

точные строки ↗
11

Сборка приложения

Вход: uvicorn tender_lens.api.main:app

Код: api/main.py · create_app() / lifespan

Данные: Engine, session factory, AI provider и SearchService в app.state

Проверка: create_app с in-memory dependencies

точные строки ↗
12

Request ID и ошибки

Вход: Каждый HTTP request

Код: api/main.py · middleware + handlers

Данные: X-Request-ID; единый ErrorResponse; stack trace не уходит клиенту

Проверка: validation и internal error contracts

точные строки ↗
13

MWS embeddings

Вход: Indexer batch или один query

Код: ai.py · MwsAIProvider.embed()

Данные: /embeddings, Bearer server-side, bge-m3, VECTOR(1024)

Проверка: order, auth, dimensions, HTTP error

точные строки ↗
14

MWS generation

Вход: Только релевантный Ask

Код: ai.py · MwsAIProvider.generate()

Данные: /chat/completions, temperature=0, token cap

Проверка: response shape, deployment health

точные строки ↗
15

RAG prompt

Вход: Найденные SearchResult[]

Код: ai.py · build_rag_prompt()

Данные: Вопрос + title + URL + fragment; документы помечены недоверенными

Проверка: test_rag_prompt_marks_documents…

точные строки ↗
16

Векторный поиск

Вход: SearchService.search()

Код: search.py · SQL с оператором <=>

Данные: chunks JOIN tenders/sources/attachments; threshold + deterministic order

Проверка: exact search integration

точные строки ↗
17

No-context ветка

Вход: Search вернул пустой список

Код: search.py · SearchService.ask()

Данные: Статичный ответ; MWS generation не вызывается

Проверка: test_irrelevant_question_skips_generation

точные строки ↗
18

NATS stream

Вход: tender.changed.v1

Код: nats.py · ensure_stream()

Данные: FILE storage, 8 MiB, 1000 сообщений, max age 1 час

Проверка: real NATS integration

точные строки ↗
19

NATS publish

Вход: После commit закупки

Код: nats.py · publish_tender_changed()

Данные: event JSON + Nats-Msg-Id=event_id

Проверка: publish and replay tests

точные строки ↗
20

ACK / NAK / TERM

Вход: Durable INDEXER worker

Код: indexer/__main__.py · run()

Данные: успех ACK; dependency error NAK 10s; poison payload TERM

Проверка: message policy unit/integration

точные строки ↗
21

Version-safe index

Вход: TenderChangedV1

Код: indexer/service.py · process()

Данные: content_hash / indexed_hash; lock до и после AI; atomic chunk replace

Проверка: idempotency + stale event tests

точные строки ↗
22

Извлечение текста

Вход: Tender + ready attachments

Код: indexer/extract.py

Данные: metadata, PDF, XML, HTML, JSON, TXT; unknown binary пропускается

Проверка: extraction format fixtures

точные строки ↗
23

Chunking

Вход: TextUnit[]

Код: indexer/chunk.py · chunk_units()

Данные: paragraph-first, max 1500 chars, overlap 150, global position

Проверка: boundary and overlap tests

точные строки ↗
24

Crawler UPSERT

Вход: TenderRecordV1

Код: crawler/service.py · persist_record()

Данные: sources/tenders/attachments; commit перед event

Проверка: new / unchanged / changed upsert

точные строки ↗
25

Безопасное вложение

Вход: stream(source_url)

Код: storage.py · download_attachment()

Данные: .part → fsync → os.replace; size cap + SHA-256

Проверка: traversal, size, cleanup tests

точные строки ↗
26

Схема БД

Вход: alembic upgrade head

Код: migrations/versions/0001_initial_schema.py

Данные: 5 таблиц, FK, unique/check constraints, VECTOR(1024)

Проверка: upgrade, downgrade, exact tables

точные строки ↗
27

Настройки

Вход: .env / environment

Код: config.py · Settings

Данные: Pydantic types, ranges, secret type, demo guard, provider switch

Проверка: project contract + settings validation

точные строки ↗
28

CI

Вход: push / pull_request

Код: .github/workflows/ci.yml

Данные: lint, typecheck, unit/API, PG, NATS, E2E, migration, image smoke

Проверка: три независимых jobs

точные строки ↗

Как пользоваться code-map перед изменением

  1. Начать с пользовательского поведения и найти строку в колонке «Вход».
  2. Пройти route, service, хранилище и фоновые события. Не менять один слой вслепую.
  3. Проверить повтор, timeout, невалидный input и устаревшее событие.
  4. Изменить или добавить тест из последней колонки.
  5. Перегенерировать reference: `python scripts/generate_code_reference.py`.
  6. Проверить drift командой с `--check` и собрать MkDocs в strict режиме.
Инструкция в репозитории ↗

07 / Код с русскими комментариями

Ключевые места без лишней обвязки

Комментарии ниже учебные. В исходнике код короче; ссылка под блоком открывает оригинал.

01 · Browser → API

Форма выбирает `/search` или `/ask`

// Забираем и очищаем пользовательский ввод.
const query = state.query.value.trim();

// До сети отсекаем заведомо невалидный запрос.
if (query.length < 3) return showValidationError();

// mode содержит только "search" или "ask" из наших кнопок.
const response = await fetch(`/api/v1/${state.mode}`, {
  method: "POST",
  // Обычный self-hosted режим передаёт API key в header.
  // Публичный стенд дополнительно выдаёт signed HttpOnly cookie.
  headers: {"Content-Type": "application/json", "X-API-Key": apiKey},
  body: JSON.stringify({query, limit: 5}),
});

// 429, 422 и 503 превращаются в понятное сообщение.
if (!response.ok) throw new Error(await parseError(response));

// В Ask источники лежат в sources, в Search — в items.
const payload = await response.json();
const items = state.mode === "ask" ? payload.sources : payload.items;
app.js, строки 93–140 ↗
02 · FastAPI boundary

Route не содержит retrieval-логику

# Dependency сначала проверит key и атомарно спишет лимит.
@router.post(
    "/api/v1/search",
    response_model=SearchResponse,
    dependencies=[Depends(rate_limited_key)],
)
async def search(request: Request, payload: SearchRequest,
                 session=Depends(get_session)):
    # Route только передаёт typed input прикладному сервису.
    response = await request.app.state.search_service.search(
        session, payload.query, payload.limit
    )
    # Клиент видит остаток quota в стандартных headers.
    return JSONResponse(
        content=response.model_dump(mode="json"),
        headers=request.state.rate_limit.headers,
    )
routes.py, строки 95–104 ↗
03 · Retrieval

Один embedding и SQL cosine search

# Вопрос превращается ровно в один vector.
vectors = await self._ai.embed([query])

# <=> — cosine distance из pgvector.
# Поэтому similarity = 1 - distance.
SELECT c.tender_id, t.title, c.content AS snippet,
       1 - (c.embedding <=> CAST(:embedding AS vector)) AS score
FROM chunks c
JOIN tenders t ON t.id = c.tender_id
WHERE t.index_status = 'ready'
  # Порог отсекает слабый context до generation.
  AND (1 - (c.embedding <=> CAST(:embedding AS vector))) >= :min_score
# id делает результат стабильным при одинаковой distance.
ORDER BY c.embedding <=> CAST(:embedding AS vector), c.id
LIMIT :limit
search.py, строки 23–81 ↗
04 · No-context gate

LLM не вызывается без источников

async def ask(self, session, query, limit):
    # Ask всегда начинает с того же Search.
    search = await self.search(session, query, limit)

    # Пустой retrieval — конечный результат, а не повод фантазировать.
    if not search.items:
        return AskResponse(
            answer="Недостаточно данных в базе знаний.",
            sources=[],
        )

    # Только здесь появляется платный generation request.
    system, prompt = build_rag_prompt(query, search.items)
    answer = await self._ai.generate(system=system, prompt=prompt)
    return AskResponse(answer=answer, sources=search.items)
search.py, строки 83–92 ↗
05 · MWS adapter

Provider проверяет внешний ответ

# API key остаётся в backend process.
response = await self._client.post(
    f"{self._base_url}/embeddings",
    headers=self._headers,
    json={"model": self._embedding_model, "input": texts},
)
response.raise_for_status()
data = response.json().get("data")

# Проверяем не только HTTP 200, но и бизнес-контракт provider.
if not isinstance(data, list) or len(data) != len(texts):
    raise InvalidAIResponseError("Неверное число embeddings")

for item in data:
    vector = item.get("embedding")
    # PostgreSQL ожидает ровно VECTOR(1024).
    if not isinstance(vector, list) or len(vector) != self._dimensions:
        raise InvalidAIResponseError("Неверная размерность")
ai.py, строки 175–208 ↗
06 · Version safety

Старое событие не затрёт новый документ

# Быстрые выходы до extraction и AI.
if tender.content_hash != event.content_hash:
    return IndexResult(tender.id, "stale", 0, [])
if tender.indexed_hash == event.content_hash and tender.index_status == "ready":
    return IndexResult(tender.id, "unchanged", 0, [])

# Embeddings считаются вне длинной DB-транзакции.
embeddings = await self._ai.embed(chunk_texts)

# После внешнего вызова снова читаем строку под lock.
locked = await session.get(Tender, tender.id, with_for_update=True)
if locked.content_hash != event.content_hash:
    await session.rollback()
    return IndexResult(tender.id, "stale", 0, warnings)

# Старый и новый индекс меняются атомарно.
await session.execute(delete(Chunk).where(Chunk.tender_id == tender.id))
session.add_all(new_chunks)
locked.indexed_hash = event.content_hash
locked.index_status = "ready"
await session.commit()
indexer/service.py, строки 70–162 ↗
07 · Concurrent limiter

Параллельные запросы считают одну строку

# FOR UPDATE не даёт двум requests одновременно увидеть старый count.
api_key = await session.get(ApiKey, api_key_id, with_for_update=True)

# Новая UTC-минута начинает новое окно.
if minute_start(api_key.window_started_at) != window:
    api_key.window_started_at = window
    api_key.request_count = 0

if api_key.request_count >= api_key.limit_per_minute:
    await session.rollback()
    # Retry-After говорит клиенту, когда повторить.
    raise AppError("rate_limit_exceeded", "Превышен лимит", 429)

api_key.request_count += 1
await session.commit()
rate_limit.py, строки 43–75 ↗
08 · Delivery policy

ACK, NAK и TERM означают разные исходы

try:
    result = await service.process(event)
    # Готово, stale или уже обработано: повтор не нужен.
    await message.ack()
except (DependencyUnavailableError, SQLAlchemyError, OSError):
    # Временный сбой зависимости: попросить повтор через 10 секунд.
    await message.nak(delay=10)
except Exception:
    # Неизвестная постоянная ошибка: остановить poison message.
    # В зрелом production рядом нужен DLQ/incident workflow.
    await message.term()
indexer/__main__.py, строки 61–91 ↗

08 / Разбор без лака

Где код слабый, лишний или слишком уверенный в себе

Исходники TenderLens не менялись. Это review закреплённого 6c005b6e. Здесь нет замечаний уровня «я бы назвал переменную иначе». Каждый пункт привязан к строкам и к сценарию, в котором он реально начинает мешать.

20проверяемых замечаний
5P1: сначала correctness
10P2: до роста нагрузки
5P3: убрать лишний код
129 tests зелёные. Противоречия тут нет.

Тесты подтверждают то, что в них проверили: обычный pipeline, последовательный повтор, stale event, API contracts, PostgreSQL и NATS. Они не моделируют две одновременные обработки одного события, смену embedding space, новый файл по старому URL, смерть cleanup loop и исчерпание max_deliver.

Открыть suite на закреплённом SHA ↗
P1 · может испортить результат или потерять работуP2 · стрельнет при сбое, deploy или ростеP3 · работает, но код уже дороже задачи
01P1 · корректность

Версия индекса равна версии карточки. Этого мало.

Смена embedding-модели, extractor или chunking сама по себе не запускает reindex.

Если без терминов

Библиотекарь сменил язык каталога, но на коробке осталась старая наклейка. Компьютер видит ту же наклейку и решает, что перекладывать карточки не надо.

Что именно в коде

Ранний выход смотрит только на indexed_hash == event.content_hash и status=ready. Этот hash собран из полей TenderRecordV1. В нём нет embedding model, версии chunker/extractor и SHA-256 скачанных файлов. Поле embedding_model записывается в chunk уже после этой проверки.

Почему так получилось

content_hash сначала был защитой от дубля одной закупки. Потом тем же значением начали решать другую задачу — определять свежесть всего поискового индекса. Смысл ключа расширился, сам ключ нет.

Как сделать проще

Завести index_signature: hash карточки + hashes файлов + id модели + версия extractor/chunker. Сравнивать именно его. Для смены модели иметь явную reindex-команду, а не надеяться на обычный replay.

Граница вывода

Текущий публичный корпус после перехода на MWS был заново заseedен, поэтому сейчас vectors согласованы. Проблема вылезет на следующей смене модели или правил разбиения.

02P1 · деньги / concurrency

Два worker могут одновременно заплатить MWS за один документ.

Row lock защищает короткую запись статуса, но не выдаёт одному worker право на всю работу.

Если без терминов

Два человека одновременно взяли одинаковое задание. Каждый отметил «делаю», отпустил журнал и пошёл выполнять дорогую работу. В конце оба принесли один и тот же результат.

Что именно в коде

Оба consumer могут прочитать pending до первого commit. Затем по очереди взять FOR UPDATE, поставить processing и отпустить lock. Проверки processing/lease внутри claim нет. Оба извлекут текст и вызовут embeddings. Финальная транзакция сохранит корректный набор chunks, но второй вызов модели и CPU уже потрачены.

Почему так получилось

Тест на идемпотентность вызывает process два раза последовательно. Тест на stale меняет версию во время AI. Сценарий «две одинаковые доставки уже в работе» между ними не попал.

Как сделать проще

Сделать атомарный claim через UPDATE ... WHERE status != processing AND index_signature != indexed_signature RETURNING id. Добавить processing_started_at и lease, чтобы умерший worker можно было безопасно подобрать.

Граница вывода

При одном consumer и коротком batch почти не видно. Стрельнет при нескольких replicas или когда обработка дольше ack_wait и JetStream отдаст сообщение повторно.

03P1 · доставка

После max_deliver запись может навсегда остаться failed.

Временная ошибка помечает tender как failed, а восстановитель ищет только pending.

Если без терминов

Курьер пять раз не смог открыть дверь и положил заказ в коробку «сломано». Робот, который подбирает забытые заказы, проверяет только коробку «ждёт». Этот заказ он больше не увидит.

Что именно в коде

IndexerService ловит любое исключение и ставит failed. Worker для dependency error делает NAK, но JetStream ограничен max_deliver. Когда попытки кончатся, crawler.republish_pending не поднимет запись: его WHERE содержит только index_status == pending. Неизменившийся source record тоже не вернёт status в pending.

Почему так получилось

Статус ошибки, retry policy и sweep реализованы в трёх разных местах. Каждый кусок локально выглядит разумно, но общего state machine нет.

Как сделать проще

Хранить retryable/permanent отдельно. Для временного сбоя оставлять retry_wait с next_attempt_at; failed ставить после исчерпания политики и включать его в управляемый replay. Лучше — outbox/job table с attempt и lease.

Граница вывода

Кнопка replay публичной демки отправляет все документы и может вручную оживить запись. У обычного crawler такой автоматической ветки для failed нет.

04P1 · диагностика

TERM удаляет сообщение из работы, но не оставляет разборный артефакт.

Poison payload и «постоянная» ошибка остаются только в логах.

Если без терминов

Плохое письмо выбросили и записали в общий журнал, что письмо было плохим. Само письмо для разбора не сохранили.

Что именно в коде

ValidationError и любой exception вне трёх retryable типов заканчиваются term(). Отдельного dead-letter subject, failed_events table, payload hash или команды replay нет. Ошибка классифицируется по Python type, хотя часть 4xx/invalid response от внешнего provider после исправления конфигурации могла бы стать восстановимой.

Почему так получилось

Нужно было не дать poison message крутиться бесконечно. TERM решает это одной строкой, а второй половины процесса — сохранить, показать, починить, повторить — в MVP не появилось.

Как сделать проще

Перед TERM писать компактную запись в failed_events или публиковать в TENDERS_DLQ: event, причина, attempt, время и code version. Из админской команды разрешить replay после исправления.

Граница вывода

На стенде четыре синтетических документа и логи доступны владельцу. В B2B-потоке без DLQ теряется объяснимость конкретной доставки.

05P1 · свежесть данных

Файл с тем же URL считается неизменным навсегда.

Готовое вложение не скачивается повторно, даже если по старому адресу уже лежат новые bytes.

Если без терминов

На полке заменили содержимое книги, но номер полки не поменяли. Система смотрит только на номер и продолжает читать старую копию.

Что именно в коде

persist_record обновляет metadata существующего attachment, но переводит в pending только skipped. _download_one сразу выходит для ready + local_path. При этом SHA файла не входит в tender content_hash. Нет ETag, Last-Modified или source revision, которые подтвердили бы неизменяемость URL.

Почему так получилось

Для fixture и большинства публикационных ссылок URL выглядит как стабильный идентификатор. Это предположение осталось неявным и стало частью алгоритма.

Как сделать проще

Либо явно зафиксировать контракт «attachment URL immutable», либо делать conditional GET по ETag/Last-Modified. При изменении metadata можно сбрасывать attachment в pending и сравнивать новый SHA до reindex.

Граница вывода

Это баг-кандидат, а не доказанная порча текущего corpus: он зависит от поведения TED/Contracts Finder. Код эту зависимость сейчас не проверяет и не документирует.

06P2 · идемпотентность

Каждый republish получает новый Nats-Msg-Id.

Заголовок дедупликации есть, но обычный повтор создаёт новый event_id и проходит как новое сообщение.

Если без терминов

На повторную посылку наклеивают новый номер. Почта честно считает её другой посылкой.

Что именно в коде

publish() каждый раз конструирует TenderChangedV1, а event_id имеет default_factory uuid4. Nats-Msg-Id равен этому UUID. Поэтому broker dedupe работает для повторной отправки того же объекта, но не для republish_pending или кнопки replay. Итоговую целостность держит content_hash в indexer.

Почему так получилось

Создание domain event и транспортная отправка склеены в один метод. Хранить id между попытками негде, потому что outbox нет.

Как сделать проще

Сохранять event_id вместе с outbox row. Для этого события допустим и детерминированный id от tender_id + content_hash + index_signature.

Граница вывода

Данные не дублируются после последовательной обработки. Лишние сообщения и параллельный AI-вызов всё равно возможны.

07P2 · PostgreSQL

Метод называется UPSERT, но внутри SELECT, потом INSERT.

Два crawler на одной новой закупке могут одновременно решить, что строки ещё нет.

Если без терминов

Два кассира посмотрели на пустую полку и оба понесли туда одну коробку. Второму база скажет, что место уже занято.

Что именно в коде

Сначала отдельно ищутся Source и Tender, затем создаются ORM objects. Unique constraints спасут данные от дубля, но один transaction получит IntegrityError и оборвёт страницу. Retry конфликта нет. Та же схема повторяется для attachment.

Почему так получилось

ORM-ветка if none / else проще читается и отлично проходит однопоточный тест. Название UPSERT появилось раньше, чем реальный INSERT ... ON CONFLICT.

Как сделать проще

PostgreSQL upsert с ON CONFLICT ... DO UPDATE/NOTHING и RETURNING. Если ORM-ветку оставить, ловить unique violation, rollback и перечитывать запись.

Граница вывода

Один crawler на VPS с этим не сталкивается. Это ограничение масштабирования и ручного одновременного запуска.

08P2 · rate limit

«Глобальный» limiter глобален только внутри одного Python process.

Второй Uvicorn worker или replica получает свой отдельный счётчик.

Если без терминов

У каждой двери свой охранник и свой блокнот. Если открыть вторую дверь, общий лимит внезапно удвоится.

Что именно в коде

PortfolioRateGate хранит window и requests в памяти объекта под asyncio.Lock. Это корректно для конкурентных coroutines одного event loop, но не координирует процессы и hosts. Fixed minute window ещё и разрешает burst на границе минут. Счётчик расходуется middleware до валидации payload.

Почему так получилось

Лимит добавлен вместе с реальной платной MWS как быстрый предохранитель текущего single-process стенда. Название оказалось шире фактической гарантии.

Как сделать проще

Одна PostgreSQL row с атомарным UPDATE или Redis token bucket. Считать запрос после валидации и непосредственно перед платным provider call. Метрику расхода держать рядом.

Граница вывода

На текущем стенде один API process, поэтому установленный cap действует. Любое горизонтальное масштабирование меняет фактический бюджет.

09P2 · HTTP contract

Ошибки demo middleware живут не по общему контракту.

У части 429 нет request_id, details и того же ErrorResponse, который обещает остальной API.

Если без терминов

Все кассы выдают чек одного вида, кроме охранника у входа. Если он не пустил, номер обращения на бумаге не появится.

Что именно в коде

session_context может вернуть сырой JSON до call_next. Этот middleware зарегистрирован снаружи request_context, поэтому ранний 429 обходит установку request.state.request_id. Вторая ветка ограничения возвращает только error.message. Общий _error_response при этом уже умеет единый envelope и headers.

Почему так получилось

Защиту публичного стенда добавили сбоку как middleware и собрали ответ прямо там. Бизнес-ошибка не прошла через уже существующий AppError path.

Как сделать проще

Middleware только кладёт session identity в state. Limiter dependency бросает AppError, а единый handler формирует JSON. Если нужен ранний gate, сначала создавать request_id и всё равно вызывать общий response builder.

Граница вывода

Обычные Search/Ask errors остаются нормальными. Расхождение видно именно при глобальном cap или переполнении demo sessions.

10P2 · background task

Cleanup loop умирает от первой ошибки БД.

После одного transient exception часовые demo keys больше не чистятся до перезапуска API.

Если без терминов

Уборщик один раз не смог открыть кладовку и после этого молча ушёл навсегда.

Что именно в коде

Бесконечный loop не ловит исключения вокруг cleanup. Task создаётся через asyncio.create_task и не мониторится во время жизни приложения. Ошибка всплывёт только при shutdown, когда lifespan await-ит уже завершившийся task.

Почему так получилось

Типичный сгенерированный happy-path loop: действие, sleep, repeat. Отмена обработана, сбой самой работы — нет.

Как сделать проще

Ловить ожидаемые DB errors внутри цикла, логировать, делать bounded backoff и продолжать. Ещё проще для одной VPS — системный job/cron или очистка по TTL при выдаче новой сессии.

Граница вывода

Это не роняет Search сразу. Постепенно растёт api_keys, а потом срабатывает cap 1000 и новые посетители получают «демо занято».

11P2 · healthchecks

Три места по-разному понимают слово «здоров».

UI и Compose проверяют liveness, хотя пользователю нужны PostgreSQL и MWS; readiness ходит в control endpoint MWS на каждый вызов.

Если без терминов

Лампочка говорит «магазин работает», потому что свет включён. Касса и склад при этом могут лежать.

Что именно в коде

Browser вызывает /health/live. Compose healthcheck тоже вызывает /health/live. Оба получают 200 при мёртвой БД или AI. Отдельный /health/ready проверяет зависимости, но MWS health делает GET /models каждый раз: сбой control plane может дать 503 даже при рабочем inference endpoint.

Почему так получилось

Liveness было безопасно использовать для restart, а readiness добавили отдельно. После этого вызовы не развели по назначению: container, traffic и пользовательский status.

Как сделать проще

Liveness оставить только для решения «перезапускать процесс или нет». Для UI показывать cached readiness и состояния зависимостей. Для routing использовать readiness с коротким cache/timeout и метрикой последнего успешного inference.

Граница вывода

Production Nginx сейчас может всё равно проксировать живой process. Пользователь узнает о проблеме только после Search/Ask.

12P2 · deploy footgun

Локальный Compose слишком легко случайно вынести наружу.

PostgreSQL, NATS, NATS monitoring и Ollama публикуются на всех host interfaces.

Если без терминов

Для домашней проверки открыли четыре служебные двери. Если этот же файл запустить на сервере, двери тоже откроются.

Что именно в коде

Короткая запись 5432:5432 и аналогичные ports означает bind 0.0.0.0. У NATS в этом Compose нет credentials, у PostgreSQL демонстрационный password. README ведёт через localhost, то есть файл явно локальный, но технического предохранителя от server deploy нет.

Почему так получилось

Один Compose должен был дать удобный local bootstrap и доступ к psql/monitoring с host. Dev и deploy concerns остались в одном файле.

Как сделать проще

Для dev bind 127.0.0.1:5432:5432 и так же для служебных портов. Для production отдельный compose override: наружу только API, остальное через internal network и secrets.

Граница вывода

Опубликованный стенд развёрнут другой конфигурацией и эти порты снаружи не доказываются открытыми. Это опасный default для следующего ручного запуска.

13P3 · лишняя работа

Один API key читается дважды. Потом его hash сравнивается сам с собой.

Auth делает SELECT, limiter открывает вторую session и повторно читает ту же row под lock.

Если без терминов

Сначала охранник нашёл билет по номеру. Потом сравнил найденный номер с тем же номером «для безопасности». После этого второй охранник снова пошёл искать билет.

Что именно в коде

SQL WHERE key_hash == computed_hash уже выбрал равное значение. compare_digest после этого не добавляет проверку секрета. ORM object detach нужен только потому, что следующий dependency откроет другую session и снова сделает get(..., with_for_update=True). Корректность есть, путь длиннее нужного.

Почему так получилось

Auth и limiter писались как независимые красивые dependencies. Security-паттерн compare_digest добавлен механически после hash lookup.

Как сделать проще

Одна dependency делает атомарный UPDATE/RETURNING по key_hash, enabled и окну лимита. Она сразу возвращает identity и rate headers. compare_digest нужен при сравнении с секретом в памяти, здесь его уже нет.

Граница вывода

На маленьком стенде две дешёвые DB операции не проблема. На высоком RPS это лишняя latency и более сложная трассировка.

14P2 · границы модулей

Публичная демка импортирует private helper из CLI.

Runtime seed зависит от _upsert_demo_record, который одновременно копирует файл, пишет БД и знает глобальные settings.

Если без терминов

Чтобы наполнить витрину, магазин вызывает внутреннюю функцию из кассового скрипта. Она умеет работать только с первым товаром в коробке.

Что именно в коде

portfolio.seed импортирует underscored function из cli.py. Helper дублирует большую часть CrawlerService.persist_record, без проверки берёт record.attachments[0], повторно вызывает get_settings(), читает весь скопированный файл в RAM ради SHA и поддерживает только одно attachment. Это обратная зависимость: feature runtime тянет presentation/maintenance layer.

Почему так получилось

Seed появился как отдельная команда ещё в первой версии. Публичный portfolio layer позже переиспользовал готовый код, потому что он уже решал нужный happy path.

Как сделать проще

Вынести DemoSeeder и общий TenderRepository. Реальную запись карточки выполнять тем же persistence service, а локальный fixture передавать через отдельный AttachmentStore без HTTP.

Граница вывода

На четырёх контролируемых fixtures helper работает. Любой demo record без attachment или с несколькими файлами показывает, насколько контракт уже уже основной модели.

15P3 · дублирование

AI provider собирается копипастой в API и indexer.

Новый provider или параметр надо синхронно добавить минимум в два entrypoint.

Если без терминов

Два повара готовят один соус по двум копиям рецепта. В одну копию уже легко дописать соль и забыть про вторую.

Что именно в коде

Одинаковый if fake / elif mws / else ollama и длинный конструктор MwsAIProvider находятся в create_app и indexer.run. Именно MWS commit менял оба места. Тип AIProvider есть, factory для него нет; lifecycle закрытия тоже проверяет concrete classes в двух местах.

Почему так получилось

Каждая process role собиралась самостоятельно. Пока providers было мало, копипаста казалась дешевле ещё одного модуля.

Как сделать проще

create_ai_provider(settings) плюс async context manager/close protocol. API и worker получают готовую зависимость, тесты проверяют mapping mode → class один раз.

Граница вывода

С тремя providers код ещё читается. Следующий provider, telemetry wrapper или общий retry почти наверняка разъедется.

16P2 · configuration drift

ensure_stream проверяет subject и верит всему остальному.

Старый stream с другим max_age, max_bytes или storage будет принят как нормальный.

Если без терминов

Проверили только название ящика. Размер, срок хранения и материал ящика решили не смотреть.

Что именно в коде

Если stream найден, код сравнивает только наличие subject. FILE storage, 8 MiB, 1000 messages и 1 hour применяются исключительно при первом создании. Документация после этого может обещать одни пределы, а NATS реально держать другие.

Почему так получилось

Метод решал bootstrap: «создай, если нет». Комментарий и название постепенно стали звучать как reconciliation желаемой конфигурации.

Как сделать проще

Собрать один desired StreamConfig, сравнить управляемые поля с stream_info и вызвать update_stream либо завершить startup с точным diff. Не трогать поля, которыми владеет админ, без явной политики.

Граница вывода

На чистом volume всё создаётся правильно. Риск появляется после изменения env/config или восстановления старого NATS state.

17P3 · сгенерированный ритуал

NATS timeout сначала ловится нормально, потом — по имени класса.

Для закреплённого nats-py 2.15.0 вторая проверка лишняя и хрупкая.

Если без терминов

Сначала ловим всех людей с нужным пропуском. Потом на всякий случай ловим любого, кого зовут «Пропуск».

Что именно в коде

nats.errors.TimeoutError наследует asyncio.TimeoutError, поэтому первая except-ветка его уже принимает. Сравнение exc.__class__.__name__ == "TimeoutError" либо мёртвое, либо случайно проглотит чужой exception с тем же названием. Тип checker здесь помочь не может.

Почему так получилось

Похоже на страховку при неуверенности в exception hierarchy библиотеки. Вместо проверки установленной версии добавлена строковая эвристика.

Как сделать проще

Явно импортировать NATS TimeoutError и ловить документированный tuple типов. Если нужна совместимость версий, закрыть её одним adapter и тестом на pinned dependency.

Граница вывода

Сейчас поведение fetch timeout правильное благодаря первой ветке. Замечание про читаемость и будущую диагностику, а не про текущий outage.

18P3 · переусложнение

HTTP client содержит два почти одинаковых автомата retry/redirect.

Обычный request и streaming download отдельно ведут attempts, redirects, delay и host validation.

Если без терминов

Для писем и посылок написали две отдельные инструкции курьеру. Они почти одинаковые, поэтому со временем одну поправят, вторую забудут.

Что именно в коде

request занимает примерно 56 строк state machine, stream — ещё около 70. Streaming действительно требует держать semaphore до чтения body, но политика redirect/retry/status повторяется. Уже сейчас timeout передаётся разными путями: request явно, stream полагается на client defaults.

Почему так получилось

Сначала нужен был JSON request, потом безопасный stream. Копирование существующего цикла было быстрее, чем выделение общей transport policy.

Как сделать проще

Общий приватный метод должен решать host, redirect, attempts и delay и возвращать открытый response. Две тонкие оболочки управляют только временем закрытия body/semaphore. Либо использовать проверенную retry-библиотеку с собственным redirect validator.

Граница вывода

Безопасность host allowlist и size cap полезны; их убирать нельзя. Замечание только про дублированное управление состоянием.

19P2 · внешняя AI

У crawler есть подробный retry, у MWS — один выстрел.

Один 429/502 или короткий network flap сразу роняет пользовательский Search/Ask.

Если без терминов

Почтальон умеет подождать и повторить звонок. AI-клиент звонит один раз и сразу пишет «никого нет».

Что именно в коде

MwsAIProvider делает один POST и сворачивает HTTP status, timeout и bad JSON в два общих exception type. Retry-After не читается, bounded retry/circuit breaker нет. Worker получит retry через NATS целиком; синхронный API request просто завершится ошибкой.

Почему так получилось

Provider добавлялся последним тонким OpenAI-compatible adapter. Надёжность уже существовала на уровне worker delivery, и её по инерции посчитали достаточной для HTTP path.

Как сделать проще

Общий AI transport: максимум 2–3 попытки только для безопасных transient кодов, Retry-After, jitter, отдельные метрики status/latency и circuit breaker. Для generation учитывать риск повторной оплаты после потерянного ответа.

Граница вывода

Длинный бесконтрольный retry в HTTP тоже плох. Нужен короткий budget, который помещается в общий request timeout.

20P3 · parser heuristic

TED parser старается угадать любой JSON и может тихо угадать не то.

_first рекурсивно берёт первое непустое значение из неизвестного dict.

Если без терминов

Если на коробке нет знакомой наклейки, программа берёт первую попавшуюся бумажку и считает её названием.

Что именно в коде

После известных keys value/text/eng/en функция обходит dict.values() в порядке входного JSON. Ошибочная или новая форма TED не обязательно упадёт: она может дать валидную, но семантически чужую строку. Такая ошибка хуже явной validation failure, потому что попадёт в индекс.

Почему так получилось

Адаптер пытались сделать терпимым к нескольким историческим формам и локализациям без полной typed schema внешнего ответа.

Как сделать проще

Описать поддержанные shapes явно и выбирать язык по фиксированному приоритету. Неизвестную форму логировать как contract drift и сохранять raw payload для разбора, но не угадывать значение рекурсией.

Граница вывода

Это замечание зависит от фактических ответов TED. Текущие fixtures проходят; они не доказывают корректность для новой схемы API.

Что я специально не записал в косяки

Exact scan на 16 chunks — нормальный выбор для этого объёма. Vanilla JS вместо React/Vue — нормальный выбор для одной формы. VECTOR(1024) — честное ограничение текущей модели. Проблема начинается там, где код обещает более широкую гарантию, чем реально держит.

09 / Настройки

Что меняется без правки кода

ГруппаПеременныеГде объявленыЧто меняютНужен restart
AI provider`AI_MODE`, `MWS_*`, `EMBEDDING_*`config.py 35–51Provider, deployments, timeout, reasoning, token capДа; текущего replay для смены embedding недостаточно — см. замечание 01
Retrieval`MIN_RELEVANCE_SCORE`, `EMBEDDING_BATCH_SIZE`config.py 41–42Порог выдачи и размер batchAPI / worker соответственно
Database / queue`DATABASE_URL`, `NATS_URL`, `NATS_*`config.pyПодключения, stream, subject, consumer, delivery policyДа
Crawler`CRAWL_*`, `SOURCE_MAX_ITEMS`, source URLsconfig.py 56–77Период, concurrency, page size, endpointsCrawler
HTTP safety`HTTP_TIMEOUT_SECONDS`, attempts, delay, jitterconfig.py 61–65Retry policy и User-AgentCrawler
Files`ATTACHMENTS_DIR`, `MAX_ATTACHMENT_BYTES`config.py 53–54Volume path и лимит скачивания/extractionCrawler + worker
Public demo`PORTFOLIO_DEMO`, secret, global limitconfig.py 24–26Signed sessions, cleanup и защита MWSAPI
SchemaAlembic revision `0001`migration ↗Таблицы, indexes, constraints, VECTOR(1024)`alembic upgrade head` до app
Containersroles, healthchecks, volumesdocker-compose.yml ↗Локальный полный стек и optional Ollama profileCompose recreate
.env / secret storePydantic Settingsapp / worker statetyped provider
Локальный запуск без платной модели

`Copy-Item .env.example .env`, затем `docker compose up --build -d`. По умолчанию `AI_MODE=fake`: это детерминированный test provider. Для MWS нужны `AI_MODE=mws`, project, API key и ids deployments; key в Git не добавляется.

Команды запуска ↗

10 / Проверка и деплой

Как понять, что цепочка не развалилась

01

Unit

Hashing, parsers, chunking, schemas, AI response validation, prompt, auth helpers.

tests/unit ↗
02

API

Status codes, headers, rate sharing, OpenAPI, static UI, hidden internal errors.

test_api.py ↗
03

Integration

Настоящий PostgreSQL/pgvector и NATS, транзакции, concurrency, stale events.

database pipeline ↗

CI на закреплённом исходнике

BlackFlake8MyPy129 unit/APIPostgreSQLNATSE2EMigration ↕Docker smoke

Репозиторий использует GitHub Actions, а не GitLab CI. Принципы jobs, artifacts, service containers и merge gate переносятся, но это не одно и то же средство.

Для 10-летнего

Проверяем не одну кнопку

Сначала отдельно проверяем детали. Потом соединяем базу и почтовый ящик. В конце прогоняем путь целиком. Если сломалось, понятно, на каком этаже искать.

Для программиста

Contract + state + delivery

Главные проверки проекта — не snapshot markup, а инварианты: cursor не перепрыгивает сбой, limiter атомарен, stale hash не заменяет chunks, no-context не делает generation call, migration обратима.

11 / Скриншоты и измерения

Опубликованная версия, а не макет

Память контейнеров TenderLens
Один idle-снимок, 09.09.2026 · JSON ↗
Время запросов TenderLens
Одиночные сценарии, не benchmark · JSON ↗
  • API: 62.07 MiB, worker: 59.37 MiB в одном idle-снимке; лимит каждого — 256 MiB.
  • Desktop Search: 298 ms; Ask: 1145 ms; no-context Ask: 115 ms.
  • Mobile Search: 137 ms; Ask: 1936 ms. Это по одному запуску, без percentile.
  • Во время browser QA: нет console errors, failed requests и horizontal overflow на 390 px.

12 / Соответствие вакансии Obuchat

Что этот кейс подтверждает, а что нет

Прямое попадание

Python / FastAPI

Async API, typed contracts, dependency injection, внешние HTTP providers.

Routes и services ↑
Прямое попадание

PostgreSQL / SQL

Transactions, row locks, constraints, migrations, exact vector query.

Migration ↗
Прямое попадание

Очереди и ошибки

JetStream, durable consumer, ACK/NAK/TERM, stale events, replay.

Worker ↗
Прямое попадание

AI integration

Embeddings, generation, grounded prompt, provider validation, no-context gate.

MWS provider ↗
Прямое попадание

Docker / тесты

Non-root image, healthchecks, unit/API/integration/E2E, migration cycle.

Workflow ↗
Не подтверждается этим кодом

React, n8n, Keycloak

В проекте их нет. На собеседовании лучше обсуждать базовые модели и план интеграции, не заявляя production-опыт из TenderLens.

Аналог, но другой инструмент

GitLab CI/CD

Здесь GitHub Actions. Можно разбирать jobs/services/gates и отдельно сказать, что синтаксис и runner model GitLab отличаются.

Рабочая формулировка на собеседовании

«TenderLens я использую как пример Python backend, PostgreSQL, очереди и AI. TypeScript/Node/Vue показываю в других репозиториях. Не пытаюсь одним проектом закрыть весь список технологий вакансии».

13 / Готовый рассказ

Версия примерно на 12 минут

Текст рассчитан на спокойную речь с показом схем и кода. Не надо читать дословно: опорная подсказка справа говорит, что открыть.

00:00–01:00

Задача и граница демо

TenderLens — сервис поиска по открытым закупкам и документам. Исходная задача была про асинхронный сбор данных из TED и Contracts Finder. Я довёл её до полного контура: нормализация, PostgreSQL, очередь, отдельный индексатор, векторный поиск и grounded-ответ по найденным фрагментам. На публичном стенде внешний crawler специально выключен: он не ходит в чужие API при каждом визите. Вместо этого там лежат четыре синтетических документа и шестнадцать chunks. При этом NATS, worker, PostgreSQL, pgvector и обе модели MWS работают реально. Это важно проговорить сразу: демо показывает рабочую техническую трассу, но не изображает внешний сбор, которого сейчас нет.

01:00–02:05

Компоненты

Приложение собрано одним Python-пакетом, но запускается в трёх ролях. Crawler отвечает только за внешние источники, файлы и запись нормализованных сущностей. Indexer — долгоживущий worker: он читает JetStream, извлекает текст, режет его и строит embeddings. API обслуживает браузер, авторизацию, лимит, поиск и Ask. Такое разделение делает отказ локальным. Медленная модель не держит транзакцию crawler, недоступный внешний источник не ломает уже проиндексированный поиск, а HTTP-процесс не обязан выполнять тяжёлую индексацию. Для небольшой системы это всё ещё один репозиторий и один image, поэтому не появляется лишняя распределённая разработка.

02:05–03:15

Сбор и транзакции

Каждый adapter приводит внешний ответ к TenderRecordV1. Pydantic отбрасывает лишние поля, нормализует строки, валюту и время. Crawler считает content_hash и ищет запись по паре источник плюс внешний id. Если содержимое не менялось, новый event не нужен. Если менялось, статус снова становится pending. Сначала транзакция сохраняет tender и attachments, потом сервис скачивает файлы, и только после commit публикует событие. Cursor источника двигается после успешно обработанной страницы. Если запись упала, cursor остаётся на прежнем месте, поэтому данные не перескакиваются. Между commit и publish есть окно сбоя; его закрывает republish_pending. В production я бы следующим шагом сделал transactional outbox.

03:15–04:30

Очередь и идемпотентность

JetStream здесь нужен не ради модного брокера, а ради повторной доставки и развязки скоростей. Событие содержит tender_id, content_hash, event_id и версию схемы. Durable consumer работает в at-least-once режиме: одно событие может прийти повторно. Поэтому Indexer сначала сравнивает hash события с текущим hash записи. Старое событие получает статус stale, уже обработанное — unchanged. После дорогого вызова модели hash проверяется ещё раз под блокировкой. Старые chunks удаляются, новые вставляются и indexed_hash обновляется в одной транзакции. Если зависимость временно недоступна, worker делает NAK с задержкой. Невалидный payload и постоянная ошибка получают TERM, чтобы poison message не держал очередь бесконечно.

04:30–05:45

Извлечение, chunks и embeddings

Indexer отдельно индексирует название, описание и агрегированные метаданные, затем читает поддержанные вложения. Для PDF нужен текстовый слой; OCR в текущей версии нет. HTML разбирается стандартным parser без выполнения script, XML — через defusedxml. Текст режется по абзацам до 1500 символов с overlap 150. Это простое правило, которое легко тестировать. Batch отправляется в MWS bge-m3. Provider проверяет, что вернулось ровно столько vectors, сколько текстов, что каждый элемент числовой и имеет 1024 компонента. Размерность зафиксирована и в настройках, и в PostgreSQL VECTOR(1024), поэтому случайно смешать несовместимые embeddings нельзя.

05:45–06:55

Search и Ask

Search и Ask начинаются одинаково: вопрос превращается в embedding bge-m3. PostgreSQL сравнивает его со всеми ready chunks оператором cosine distance. В коде score считается как один минус distance. Сейчас индекс маленький, поэтому выбран exact scan: результат воспроизводим, а настройка HNSW была бы преждевременной. Порог на стенде 0,40. Search возвращает fragments, score и источник без генерации. Ask берёт не больше пяти найденных fragments, строит prompt и вызывает gpt-oss-120b. Если ничего не прошло порог, модель вообще не вызывается и API отвечает «Недостаточно данных». Это одновременно дешевле и безопаснее, чем просить LLM импровизировать без основания.

06:55–07:55

HTTP, auth и ошибки

FastAPI routes оставлены тонкими: Pydantic валидирует payload, dependencies выполняют auth и limiter, а бизнес-логика остаётся в SearchService. Обычный режим требует X-API-Key. В базе хранится только SHA-256 ключа. Для публичного стенда middleware выпускает подписанную HttpOnly cookie на час и использует её hash как временный API key, поэтому посетителю не показывается общий секрет. Search и Ask делят PostgreSQL fixed-window counter; строка блокируется SELECT FOR UPDATE, так что параллельные запросы не превышают лимит из-за race condition. Ещё есть process-wide cap для защиты платного MWS. Каждая ошибка имеет code, message и request_id, а внутренний stack trace остаётся в логах.

07:55–08:45

Интерфейс и fullstack-трасса

Интерфейс намеренно небольшой и написан без SPA-фреймворка: один HTML, CSS и JavaScript. Пользователь выбирает Search или Ask, вводит вопрос, видит loading, ошибки, остаток лимита, fragments и ссылки на источники. DOM заполняется через textContent, а не через innerHTML. Это снижает риск вставки HTML из внешнего документа. Для этой вакансии нужно честно добавить: React или Vue в TenderLens нет. Vue и TypeScript я показываю отдельным кейсом Codex Monitor, Node/NestJS и транзакционный fullstack — магазином. TenderLens нужен как глубокий пример Python backend, данных, очереди и AI, а не как доказательство всего стека одной кодовой базой.

08:45–09:45

Тесты и CI

Проверки разбиты по уровню. Unit-тесты проверяют парсеры, hashing, chunking, MWS response validation, prompt и edge cases без сети. API-тесты создают FastAPI с подменёнными dependencies и проверяют status codes, rate headers, schema и скрытие внутренних ошибок. Integration поднимает настоящий PostgreSQL с pgvector и NATS, проверяет конкурентный limiter, повторные события, stale version и exact search. E2E проводит fixture от crawler до защищённого API. Workflow отдельно запускает quality, integration и container jobs, проверяет downgrade и upgrade миграции, Compose config, non-root user и старт всех ролей. Живой MWS не входит в обязательный CI: он внешний и платный, поэтому проверяется отдельным smoke после деплоя.

09:45–10:40

Деплой и ресурсы

Образы собираются заранее. На VPS Nginx завершает HTTPS и проксирует API. API и worker имеют лимит 256 MiB каждый, используют общий PostgreSQL с pgvector и общий NATS с отдельным контуром. MWS вызывается по HTTPS серверным ключом; ключ не попадает в браузер и image. Зафиксированный снимок после деплоя показал около 62 MiB у API и 59 MiB у worker в простое. Это один снимок, а не benchmark. Внешнее выполнение модели экономит RAM и GPU VPS, но добавляет сетевую задержку и зависимость от провайдера. Readiness отдельно проверяет PostgreSQL и доступность обеих deployment-моделей.

10:40–11:35

Что я нашёл на review

После генерации я отдельно прошёл код как reviewer, а не как автор презентации. Самый важный долг — слишком узкая версия индекса: content_hash не учитывает embedding model, extractor, chunker и bytes вложений. Второй риск — два worker могут параллельно оплатить один embedding batch, потому что короткий row lock не является lease на работу. Третий — после исчерпания max_deliver запись остаётся failed, а автоматический sweep берёт только pending. TERM-сообщения при этом не попадают в DLQ. Это не ломает текущий маленький стенд прямо сейчас, но именно эти места я бы исправлял до HNSW, нового UI и прочего тюнинга. Сначала correctness и восстановление, потом скорость и косметика.

11:35–12:30

Связь с вакансией

Для Obuchat этот кейс релевантен там, где нужно самостоятельно пройти от существующего кода до работающего результата: API, PostgreSQL, migrations, внешняя AI-интеграция, очередь, обработка повторов, Docker и проверка после запуска. Он также показывает, как я отношусь к AI-агентам: задачу можно исследовать и реализовывать с их помощью, но contracts, diff, secrets, tests и production evidence остаются ответственностью инженера. Пробелы тоже понятны: здесь нет React/Vue UI, Socket.IO, n8n и Keycloak. На собеседовании я не смешиваю их с реализованным кодом, а показываю соседние проекты и объясняю, что именно готов перенести в продуктовую задачу.

Перед собеседованием

14 / Подготовка к собеседованию

57 вопросов с ответами и добивками

Показано: 57
АрхитектураОбъясните TenderLens за 30 секунд.

Подготовленный ответ. Это асинхронный сервис сбора и поиска по закупкам. Crawler нормализует источники и пишет PostgreSQL, JetStream передаёт событие indexer, bge-m3 строит vectors, pgvector ищет chunks, а gpt-oss-120b отвечает только по найденному контексту. Публичный corpus синтетический; инфраструктурная и AI-трасса реальная.

АрхитектураПочему crawler, indexer и API разделены?

Подготовленный ответ. У них разные нагрузки и режимы отказа. Сетевой сбор, долгий AI batch и короткий HTTP request не должны держать ресурсы и транзакции друг друга; роли можно перезапускать и масштабировать отдельно, сохранив один image и репозиторий.

АрхитектураЗачем здесь очередь, если можно вызвать indexer напрямую?

Подготовленный ответ. Очередь сохраняет работу при временном падении worker и развязывает скорость crawler от AI. JetStream даёт durable consumer, redelivery и явное подтверждение после обработки.

АрхитектураГде проходит граница транзакции?

Подготовленный ответ. Tender и attachments фиксируются до publish. Замена chunks, indexed_hash и ready-status выполняется одной отдельной транзакцией. Внешний HTTP и AI не держатся внутри долгой DB-транзакции.

АрхитектураЧто означает идемпотентность в этом проекте?

Подготовленный ответ. Повтор той же закупки не создаёт дубль, повтор события не создаёт второй набор chunks, а stale event не перезаписывает новую версию. Основание — unique constraints, content_hash, indexed_hash и повторная проверка под lock.

АрхитектураКак обрабатывается stale event?

Подготовленный ответ. Indexer сравнивает event.content_hash с текущим tender.content_hash до работы и после embeddings. Если версия изменилась, возвращает stale и подтверждает сообщение, не меняя индекс.

АрхитектураКакой главный архитектурный долг вы видите?

Подготовленный ответ. Между DB commit и NATS publish остаётся окно сбоя, которое сейчас закрывается переотправкой pending записей. Для более строгой гарантии я добавил бы transactional outbox с отдельным publisher.

APIПочему FastAPI?

Подготовленный ответ. Нужны async HTTP, строгие Pydantic contracts, OpenAPI и лёгкая dependency injection. Для Python-сервиса с asyncpg/httpx это даёт короткий путь без собственной инфраструктурной обвязки.

APIЧто даёт dependency injection в routes?

Подготовленный ответ. Route объявляет auth, limiter и session как зависимости, а сам только передаёт payload сервису. В тестах production зависимости заменяются in-memory реализациями без monkey patch глобального состояния.

APIКак хранится API key?

Подготовленный ответ. Клиент получает открытый секрет один раз, а в PostgreSQL сохраняется SHA-256. При запросе сервер хэширует header и сравнивает найденное значение через compare_digest; disabled key даёт 403.

APIКак устроен rate limiter?

Подготовленный ответ. Один key имеет UTC-minute window и request_count. SELECT FOR UPDATE сериализует параллельные изменения, success делает commit, превышение возвращает 429 и Retry-After.

APIПочему Search и Ask делят лимит?

Подготовленный ответ. Оба вызывают embedding и PostgreSQL, а Ask дополнительно вызывает generation. Общий budget мешает обойти ограничение чередованием endpoints.

APIДля чего X-Request-ID?

Подготовленный ответ. Он связывает ответ клиента с записью в логах и помогает пройти цепочку ошибки. Клиентский id принимается, иначе создаётся UUID; он возвращается даже в error envelope.

APIКак проект скрывает внутренние ошибки?

Подготовленный ответ. Typed AppError преобразуется в стабильный JSON. Неожиданное исключение логируется со stack trace и request_id, но клиент получает общий internal_error без путей, SQL и secrets.

PostgreSQLКакие таблицы являются основными?

Подготовленный ответ. sources хранит cursor, tenders — нормализованную карточку и версии hash, attachments — файлы и download state, chunks — текст с VECTOR(1024), api_keys — auth и rate window.

PostgreSQLКакие ограничения защищают данные?

Подготовленный ответ. Есть unique на source+external_id, tender+source_url и chunk_key; FK с CASCADE/RESTRICT; CHECK для статусов, размера, суммы, позиции и лимита. Эти инварианты остаются в БД даже при ошибке приложения.

PostgreSQLКак предотвращается lost update при лимите?

Подготовленный ответ. Строка ApiKey читается with_for_update, затем счётчик меняется и транзакция коммитится. Параллельный request ждёт блокировку и видит уже обновлённое значение.

PostgreSQLПочему chunks заменяются атомарно?

Подготовленный ответ. Пользователь не должен увидеть смесь старой и новой версии. DELETE старых, INSERT новых и update indexed_hash проходят в одной транзакции после version recheck.

PostgreSQLКак работает cosine search?

Подготовленный ответ. Оператор pgvector <=> возвращает cosine distance. Код превращает его в similarity как 1-distance, фильтрует threshold, сортирует по distance и id, затем ограничивает количество.

PostgreSQLПочему exact scan, а не HNSW?

Подготовленный ответ. В демо шестнадцать chunks, поэтому exact scan проще, детерминированнее и не требует tuning recall. HNSW нужен после измерения на большом corpus и сравнения recall/latency.

PostgreSQLПочему размерность 1024 зафиксирована в migration?

Подготовленный ответ. Тип VECTOR требует конкретной размерности, а bge-m3 отдаёт 1024 компонента. Валидация provider и Settings быстро ловит несовместимую модель до записи.

PostgreSQLПочему vectors разных providers нельзя смешивать?

Подготовленный ответ. Координаты имеют смысл только внутри одного embedding space. После смены модели весь corpus переиндексируется, а embedding_model сохраняется рядом с каждым chunk.

ОчередьЧто означает at-least-once delivery?

Подготовленный ответ. Сообщение будет доставлено не меньше одного раза, но может прийти повторно, например если worker умер после commit и до ACK. Поэтому обработчик обязан быть идемпотентным.

ОчередьКогда worker делает ACK, NAK и TERM?

Подготовленный ответ. ACK — успешная, stale или unchanged обработка. NAK с задержкой — временная ошибка MWS, PostgreSQL или файла. TERM — невалидный payload или постоянная ошибка, которую повтор не исправит.

ОчередьДля чего ack_wait и max_deliver?

Подготовленный ответ. ack_wait задаёт срок, после которого неподтверждённое сообщение можно доставить повторно. max_deliver ограничивает бесконечные повторы одного сбойного события.

ОчередьКак решается сбой publish после commit?

Подготовленный ответ. Tender остаётся pending. Следующий цикл вызывает republish_pending и заново отправляет событие с актуальным content_hash.

ОчередьНужен ли строгий порядок событий?

Подготовленный ответ. Глобальный порядок не нужен: content_hash делает каждое событие version-aware. Для одной закупки позднее старое событие распознаётся как stale и не портит состояние.

ОчередьКак масштабировать indexer?

Подготовленный ответ. Можно увеличить число pull consumers одного durable с согласованным распределением сообщений, но надо контролировать MWS quota и DB pool. Row locks и version checks сохраняют корректность для одной закупки.

AI / RAGЧем embedding model отличается от generation model?

Подготовленный ответ. Embedding превращает текст в vector для поиска и не пишет ответ. Generation получает вопрос и выбранный context и формирует текст; Search использует только первую модель, Ask — обе.

AI / RAGЧто такое RAG в TenderLens?

Подготовленный ответ. Это retrieval augmented generation: сначала deterministic retrieval из своей базы, затем LLM получает только найденные fragments. API возвращает те же sources рядом с ответом.

AI / RAGКак защищаетесь от prompt injection в документах?

Подготовленный ответ. System prompt прямо объявляет документы недоверенными данными, а fragments передаются как context с URL. Это уменьшает риск, но не является полной sandbox-защитой; нужны evals и, при рисковых действиях, policy gate.

AI / RAGПочему no-context не вызывает LLM?

Подготовленный ответ. Без релевантного источника модель скорее будет отвечать из общих знаний. Я возвращаю явный отказ, экономлю запрос и делаю поведение проверяемым.

AI / RAGКак выбран threshold 0.40?

Подготовленный ответ. На небольшом проверочном наборе релевантные top scores были 0.604–0.927, посторонние — 0.278 и 0.370. 0.40 — настройка демо, а не универсальная метрика качества; для production нужен размеченный eval set.

AI / RAGЗачем temperature=0?

Подготовленный ответ. Для технического ответа важнее повторяемость и следование context, чем разнообразие. Нулевая температура не делает модель детерминированной на всех providers, но сокращает вариативность.

AI / RAGКак проверяется ответ MWS?

Подготовленный ответ. Provider валидирует HTTP status, JSON shape, наличие choices/message/content и непустой текст. Семантическую правильность provider не может доказать; её проверяют citations и отдельные evals.

AI / RAGГде хранится MWS key?

Подготовленный ответ. В server-side environment/secret storage. Pydantic SecretStr не показывает его случайно в repr; key передаётся только Authorization header и не попадает в JS или Docker image.

AI / RAGЧто делать при timeout модели?

Подготовленный ответ. Provider переводит transport/HTTP failure в DependencyUnavailableError. В API это контролируемая ошибка, а indexer делает NAK и получает повторную доставку; число повторов ограничено брокером.

Тесты / OpsКак устроена пирамида тестов?

Подготовленный ответ. Unit закрывают чистую логику и providers через mock transport, API — HTTP contracts с подменой dependencies, integration — настоящий PostgreSQL/NATS, E2E — полный fixture pipeline. Живой MWS остаётся отдельным smoke.

Тесты / OpsЧто запускает CI?

Подготовленный ответ. Black, Flake8, MyPy, unit/API, migration на clean PostgreSQL, integration, E2E, downgrade/upgrade, Docker build, Compose config и smoke трёх ролей под non-root user.

Тесты / OpsПочему проверяется downgrade миграции?

Подготовленный ответ. Это подтверждает, что схема хотя бы технически обратима на тестовой БД и migration не содержит очевидного одностороннего шага. Для production откат данных всё равно требует отдельного плана.

Тесты / OpsЧем liveness отличается от readiness?

Подготовленный ответ. Liveness отвечает, жив ли процесс. Readiness проверяет PostgreSQL и AI deployments; при их недоступности возвращает 503, чтобы трафик не шёл в неготовый instance.

Тесты / OpsКакие метрики добавить в production?

Подготовленный ответ. HTTP rate/error/latency по endpoint, MWS latency/tokens/cost, retrieval scores, queue pending/redelivery, indexing duration, chunk count, DB pool и disk growth. Логи нужно связывать request_id, event_id и tender_id.

Тесты / OpsХватило ли ресурсов VPS?

Подготовленный ответ. Один idle-снимок показал около 62 MiB API и 59 MiB worker при лимите 256 MiB на контейнер. Модели выполняются в MWS, поэтому GPU и model RAM на VPS не нужны; эти цифры не заменяют нагрузочный тест.

Тесты / OpsКак разбирать production-инцидент «Ask стал медленным»?

Подготовленный ответ. Сначала отделю browser/Nginx/API/DB/MWS по request_id и timing. Сравню Search и Ask: если Search быстрый, проверю generation deployment, network и quota; если оба медленные — query embedding, DB plan, pool и host resources.

ВакансияГде в TenderLens TypeScript и Node.js?

Подготовленный ответ. В ядре их нет: UI — небольшой vanilla JavaScript, backend — Python. Я не выдаю TenderLens за Node-проект; TypeScript/NestJS показываю в Fullstack Test Shop, Vue/TypeScript/NATS — в Agents.

ВакансияГде React или Vue?

Подготовленный ответ. В TenderLens их нет, потому что интерфейс мал и не требует сложного client state. Практику Vue показываю в Codex Monitor; к React готовлюсь через сравнение component/state/router/testing моделей, но не приписываю себе код, которого здесь нет.

ВакансияЕсть ли WebSocket / Socket.IO?

Подготовленный ответ. В TenderLens запросы конечные и работают по HTTP, поэтому realtime transport не нужен. Реальный WebSocket/SSE и восстановление по cursor показаны в Agents; для TenderLens realtime был бы уместен для progress долгого импорта.

ВакансияКак вы используете AI-агентов в разработке?

Подготовленный ответ. Даю ограниченную задачу и контекст, прошу сначала исследовать зависимости, затем проверяю diff, запускаю typecheck/lint/tests/build и воспроизвожу реальный сценарий. Секреты и production actions не делегирую без явной границы; неверные предположения агента считаю обычным инженерным риском.

ВакансияКак этот проект связан с продуктами анализа коммуникаций?

Подготовленный ответ. Техническая схема похожа: ingestion → нормализация → очередь → извлечение/обогащение → индекс → API аналитики. Для звонков поменяются domain contracts, object storage, ASR и tenant access, но сохранятся идемпотентность, retries, observability и grounded AI.

Code reviewКакой самый опасный дефект вы нашли после генерации TenderLens?

Подготовленный ответ. Свежесть индекса определяется только content_hash карточки. Смена embedding-модели, extractor/chunker или bytes вложения может не вызвать reindex. В результате query vector и corpus vectors способны оказаться из разных пространств при формально ready-статусе.

Code reviewПочему row lock не запрещает двойной вызов MWS?

Подготовленный ответ. Lock держится только пока status меняется на processing. После commit он отпущен, а право на работу нигде не записано как уникальный lease. Второй worker тоже может поставить processing и уйти считать тот же batch.

Code reviewЧто случится после пяти временных ошибок indexer?

Подготовленный ответ. Каждая ошибка ставит tender в failed, worker делает NAK, затем JetStream исчерпывает max_deliver. Sweep выбирает только pending, поэтому неизменившаяся запись может застрять до ручного replay или изменения source payload.

Code reviewПочему Nats-Msg-Id не решает все дубли?

Подготовленный ответ. Id равен случайному event_id. republish_pending конструирует новое событие и новый UUID, поэтому broker видит новую публикацию. Данные спасает content_hash в indexer, но очередь и MWS от параллельной работы защищены не полностью.

Code reviewЧто не так с методом, который назван UPSERT?

Подготовленный ответ. Он сначала делает SELECT, затем обычный INSERT. При двух crawler оба могут увидеть пусто, а unique constraint уронит одного из них. Это защита целостности, но не конкурентный upsert.

Code reviewКакая проверка безопасности в auth лишняя?

Подготовленный ответ. compare_digest выполняется уже после SELECT WHERE key_hash == computed_hash. Если row найдена, сравниваются значения, равенство которых обеспечило условие SQL. Полезнее объединить auth и атомарное списание лимита в одну DB operation.

Code reviewПочему зелёные 129 tests не отменяют эти замечания?

Подготовленный ответ. Suite хорошо проверяет happy path, sequential idempotency, stale version и отдельные ошибки. Он не моделирует два одновременных worker, смену embedding space, неизменный URL с новыми bytes, исчерпание max_deliver и падение cleanup task.

Code reviewЧто вы бы упростили первым, если дали один день?

Подготовленный ответ. Сначала ввёл бы index_signature и единый job state machine, потому что это correctness. Затем вынес бы AI factory и demo seeder, объединил auth+limit, добавил DLQ. Косметическое сокращение HTTP client оставил бы после поведения и тестов.

15 / Ссылки и первоисточники

Код, документация, технологии

END OF FILE

Версия учебника привязана к исходнику 6c005b6ef86329e7d74e9620058f55b5c6e7d4ec и evidence от 9 сентября 2026.

Вернуться к странице проекта ↑