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
01 / Суть проекта
Поиск по смыслу и ответ по найденным документам
Реальные NATS JetStream, indexer, PostgreSQL/pgvector, MWS bge-m3 и MWS gpt-oss-120b. Корпус из четырёх документов синтетический. Внешний crawler TED / Contracts Finder в публичной демке выключен.
Машиночитаемый QA-снимок ↗Умная библиотека
Представь четыре толстые папки. Компьютер режет текст на карточки и складывает их так, чтобы карточки с похожим смыслом лежали рядом. Ты спрашиваешь про гарантию на серверы. Система находит подходящие карточки и только после этого просит второго помощника написать ответ. Рядом показывает карточки-источники.
Асинхронный RAG pipeline
Нормализованные tenders проходят event-driven индексацию. Текст превращается в chunks, bge-m3 выдаёт 1024-dimensional embeddings, PostgreSQL делает exact cosine retrieval. Ask строит grounded prompt из top-k и вызывает generation deployment. Пустой retrieval завершает запрос до LLM.
Два контура, которые нельзя смешивать
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 / Как пользоваться
Сценарий на пять минут

- Откройте демо.
Верхний статус должен показать, что API доступен. В блоке индекса — 4 документа и 16 chunks.
- Нажмите пример про серверы.
Форма подставит запрос. Сначала оставьте режим Search.
- Выполните Search.
Смотрите на cosine score, название тендера, snippet и ссылку. Generation model на этом шаге не вызывается.
- Переключитесь на Ask.
Тот же retrieval станет контекстом для gpt-oss-120b. Ответ появится отдельно, под ним останутся те же sources.
- Проверьте no-context.
Спросите о выращивании помидоров. Результат должен быть пустым, а ответ — сообщить о недостатке данных.
- Проверьте ошибку.
Введите два символа. Браузер остановит запрос до API. После пяти Search/Ask в минуту сессия получит 429.
Ответ должен совпадать с карточками под ним. Если карточек нет, помощник не должен выдумывать.
В DevTools Network сравните `/search` и `/ask`, response headers `X-RateLimit-*`, `X-Request-ID`, payload sources и ветку 429 с `Retry-After`.
03 / Полный поток
Восемь шагов от источника до ответа
У каждого шага есть фактический статус публичной версии и ссылка на точные строки закреплённого коммита.
Получить закупку
Полный контур; на публичном стенде заменено синтетическим seedПросто: Есть два почтальона. Один ходит в европейский TED, второй — в британский Contracts Finder. Они приносят карточки закупок в одном формате.
Технически: Асинхронные adapters нормализуют разные внешние API в TenderRecordV1. ResilientHttpClient ограничивает hosts, проверяет redirect, timeout, Retry-After и число попыток.
Открыть реализацию на GitHub ↗Сохранить состояние
Реально работает в PostgreSQLПросто: Карточка кладётся в шкаф. Если такая уже есть и не изменилась, вторую копию не создаём.
Технически: UPSERT-поведение построено на паре source_id + external_id и content_hash. Данные фиксируются транзакцией до публикации события; cursor двигается после всей успешно обработанной порции.
Открыть реализацию на GitHub ↗Поставить задачу в очередь
NATS JetStream работает реальноПросто: Вместо крика через комнату сервис оставляет записку в надёжном почтовом ящике: «документ изменился, его надо перечитать».
Технически: Событие TenderChangedV1 публикуется в subject tender.changed.v1. Durable pull consumer получает его как минимум один раз. Nats-Msg-Id убирает повтор только при той же event_id; текущий republish создаёт новый id, поэтому окончательную идемпотентность держит indexer.
Открыть реализацию на GitHub ↗Извлечь и нарезать текст
Реально работает в workerПросто: Большой документ режется на небольшие карточки. Кусочки немного перекрываются, чтобы мысль не оборвалась на границе.
Технически: Indexer извлекает metadata, TXT, JSON, HTML, XML и PDF с текстовым слоем. Paragraph-first chunking ограничивает chunk 1500 символами и оставляет overlap 150.
Открыть реализацию на GitHub ↗Превратить текст в вектор
Реальная модель MWS bge-m3Просто: Модель превращает смысл каждого кусочка в длинный список из 1024 чисел. Похожие тексты получают похожие списки.
Технически: MwsAIProvider отправляет batch в OpenAI-compatible /embeddings, восстанавливает порядок по index и жёстко проверяет количество, numeric type и размерность 1024.
Открыть реализацию на GitHub ↗Записать поисковый индекс
PostgreSQL + pgvector работают реальноПросто: Числовые карточки складываются в каталог. По нему можно искать не только одинаковые слова, но и близкий смысл.
Технически: Новый набор chunks заменяет старый внутри одной транзакции после повторной проверки content_hash под row lock. Stale event не может затереть новую версию документа.
Открыть реализацию на GitHub ↗Найти релевантные фрагменты
Реальный embedding запроса и exact cosine searchПросто: Вопрос тоже превращается в числа. База сравнивает его со всеми карточками и отдаёт самые похожие.
Технически: SearchService вычисляет 1 − cosine distance оператором pgvector <=>, применяет MIN_RELEVANCE_SCORE=0.40, стабильную сортировку и LIMIT. На демо-объёме используется exact scan.
Открыть реализацию на GitHub ↗Собрать проверяемый ответ
Реальная MWS gpt-oss-120b; при пустом поиске не вызываетсяПросто: Если подходящие карточки есть, вторая модель читает только их и отвечает. Рядом остаются источники, чтобы ответ можно было проверить.
Технически: Ask переиспользует Search. build_rag_prompt маркирует документы как недоверенные данные, передаёт title, URL и snippet. При пустом retrieval возвращается детерминированный no-context ответ без generation cost.
Открыть реализацию на GitHub ↗04 / Архитектура
Кто с кем разговаривает
Команда с разными ролями
Один участник принимает вопросы, другой разбирает документы, третий хранит карточки, четвёртый держит очередь, а две облачные модели считают смысл и пишут ответ. Если один занят, остальные не обязаны останавливаться.
Один image, несколько process roles
API и indexer используют общий typed Settings и provider protocol. NATS отделяет ingestion от indexing. PostgreSQL является source of truth; JetStream хранит команду на обработку, а не копию бизнес-сущности.
Размещение опубликованной версии
| Компонент | Отвечает за | Не отвечает за | Проверка |
|---|---|---|---|
| Browser UI | Ввод, состояние запроса, вывод sources | Secrets, retrieval, generation | Desktop/mobile QA |
| FastAPI | HTTP contracts, auth, limiter, Search/Ask | Фоновая индексация | `/health/live`, `/health/ready` |
| Indexer | Extract, chunk, embeddings, atomic replace | Публичный HTTP | ready marker + event tests |
| NATS | Durable доставка события изменения | Хранение карточки закупки | pending / ack / redelivery |
| PostgreSQL | Source of truth, vector search, limits | Генерация текста | migration + SQL tests |
| MWS | Embeddings и chat completion | Хранение corpus | `/models` readiness |
05 / Данные
Что где хранится
| Место | Что лежит | Зачем | Срок / очистка | Код |
|---|---|---|---|---|
| PostgreSQL · `sources` | code, cursor, last_sync_at | Продолжить обход источника без перескока | Пока источник подключён | models.py ↗ |
| PostgreSQL · `tenders` | Карточка, raw JSON, content/indexed hash, status | Source of truth и версия индекса | По политике продукта | models.py ↗ |
| PostgreSQL · `attachments` | URL, local_path, SHA-256, размер, status/error | Связать файл с tender и диагностировать загрузку | С tender удаляется CASCADE | models.py ↗ |
| PostgreSQL · `chunks` | Текст, section, position, VECTOR(1024), model | Retrieval | Атомарно заменяется при новой версии | models.py ↗ |
| PostgreSQL · `api_keys` | Hash, enabled, minute window, count | Auth и per-session limit | Demo rows старше часа удаляются | models.py ↗ |
| Attachment volume | Скачанные файлы | Indexer читает bytes вне БД | Удаляется вместе с данными приложения | storage.py ↗ |
| JetStream `TENDERS` | TenderChangedV1 events | Повторная доставка | max 8 MiB / 1000 msgs / 1 h | nats.py ↗ |
| Browser HttpOnly cookie | Подписанный случайный demo token | Изолировать лимит посетителя без общего ключа | 1 час | portfolio.py ↗ |
| Server environment | DB URL, NATS URL, MWS key, limits | Runtime configuration | Живёт вне Git и image | config.py ↗ |
MWS API key и production connection strings не опубликованы. В репозитории есть только имена переменных и безопасные примеры. В браузер MWS key не передаётся.
06 / Code-map
Экран → API → сервис → данные → тест
28 точек входа. Ссылка ведёт на неизменяемый SHA, а не на плавающий `main`.
Форма Search / Ask
Вход: Кнопка или Ctrl/Cmd+Enter
Код: web/app.js · submitQuery()
Данные: query + limit=5 → JSON; ответ рендерится через textContent
Проверка: test_static_ui_and_assets_are_local
точные строки ↗Карточка результата
Вход: items[] или sources[]
Код: web/app.js · createResult()
Данные: title, snippet, cosine score, source_url, attachment
Проверка: XSS не вставляется через innerHTML
точные строки ↗Контракты 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…
точные строки ↗Search endpoint
Вход: POST /api/v1/search
Код: api/routes.py · search()
Данные: Depends(rate_limited_key) → SearchService → JSON + rate headers
Проверка: test_search_contract_and_rate_headers
точные строки ↗Ask endpoint
Вход: POST /api/v1/ask
Код: api/routes.py · ask()
Данные: Тот же limiter; answer + sources
Проверка: test_search_and_ask_share_counter…
точные строки ↗Карточка закупки
Вход: GET /api/v1/tenders/{id}
Код: api/routes.py · tender_details()
Данные: Tender + Source + Attachments через selectinload
Проверка: test_tender_details_are_sanitized
точные строки ↗API key
Вход: X-API-Key или signed demo cookie
Код: api/auth.py · authenticate_api_key()
Данные: В БД лежит SHA-256, открытый ключ не хранится
Проверка: missing / unknown / disabled key
точные строки ↗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
точные строки ↗Глобальный лимит демо
Вход: Middleware для /search и /ask
Код: portfolio.py · PortfolioRateGate
Данные: Process-wide asyncio.Lock; 12 запросов/минуту
Проверка: portfolio guard tests
точные строки ↗Демо-сессия
Вход: Первый API request посетителя
Код: portfolio.py · session_context()
Данные: Signed HttpOnly cookie на 1 час; hash становится ApiKey row
Проверка: cookie, expiry, cleanup, cap
точные строки ↗Сборка приложения
Вход: 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
точные строки ↗Request ID и ошибки
Вход: Каждый HTTP request
Код: api/main.py · middleware + handlers
Данные: X-Request-ID; единый ErrorResponse; stack trace не уходит клиенту
Проверка: validation и internal error contracts
точные строки ↗MWS embeddings
Вход: Indexer batch или один query
Код: ai.py · MwsAIProvider.embed()
Данные: /embeddings, Bearer server-side, bge-m3, VECTOR(1024)
Проверка: order, auth, dimensions, HTTP error
точные строки ↗MWS generation
Вход: Только релевантный Ask
Код: ai.py · MwsAIProvider.generate()
Данные: /chat/completions, temperature=0, token cap
Проверка: response shape, deployment health
точные строки ↗RAG prompt
Вход: Найденные SearchResult[]
Код: ai.py · build_rag_prompt()
Данные: Вопрос + title + URL + fragment; документы помечены недоверенными
Проверка: test_rag_prompt_marks_documents…
точные строки ↗Векторный поиск
Вход: SearchService.search()
Код: search.py · SQL с оператором <=>
Данные: chunks JOIN tenders/sources/attachments; threshold + deterministic order
Проверка: exact search integration
точные строки ↗No-context ветка
Вход: Search вернул пустой список
Код: search.py · SearchService.ask()
Данные: Статичный ответ; MWS generation не вызывается
Проверка: test_irrelevant_question_skips_generation
точные строки ↗NATS stream
Вход: tender.changed.v1
Код: nats.py · ensure_stream()
Данные: FILE storage, 8 MiB, 1000 сообщений, max age 1 час
Проверка: real NATS integration
точные строки ↗NATS publish
Вход: После commit закупки
Код: nats.py · publish_tender_changed()
Данные: event JSON + Nats-Msg-Id=event_id
Проверка: publish and replay tests
точные строки ↗ACK / NAK / TERM
Вход: Durable INDEXER worker
Код: indexer/__main__.py · run()
Данные: успех ACK; dependency error NAK 10s; poison payload TERM
Проверка: message policy unit/integration
точные строки ↗Version-safe index
Вход: TenderChangedV1
Код: indexer/service.py · process()
Данные: content_hash / indexed_hash; lock до и после AI; atomic chunk replace
Проверка: idempotency + stale event tests
точные строки ↗Извлечение текста
Вход: Tender + ready attachments
Код: indexer/extract.py
Данные: metadata, PDF, XML, HTML, JSON, TXT; unknown binary пропускается
Проверка: extraction format fixtures
точные строки ↗Chunking
Вход: TextUnit[]
Код: indexer/chunk.py · chunk_units()
Данные: paragraph-first, max 1500 chars, overlap 150, global position
Проверка: boundary and overlap tests
точные строки ↗Crawler UPSERT
Вход: TenderRecordV1
Код: crawler/service.py · persist_record()
Данные: sources/tenders/attachments; commit перед event
Проверка: new / unchanged / changed upsert
точные строки ↗Безопасное вложение
Вход: stream(source_url)
Код: storage.py · download_attachment()
Данные: .part → fsync → os.replace; size cap + SHA-256
Проверка: traversal, size, cleanup tests
точные строки ↗Схема БД
Вход: alembic upgrade head
Код: migrations/versions/0001_initial_schema.py
Данные: 5 таблиц, FK, unique/check constraints, VECTOR(1024)
Проверка: upgrade, downgrade, exact tables
точные строки ↗Настройки
Вход: .env / environment
Код: config.py · Settings
Данные: Pydantic types, ranges, secret type, demo guard, provider switch
Проверка: project contract + settings validation
точные строки ↗CI
Вход: push / pull_request
Код: .github/workflows/ci.yml
Данные: lint, typecheck, unit/API, PG, NATS, E2E, migration, image smoke
Проверка: три независимых jobs
точные строки ↗Как пользоваться code-map перед изменением
- Начать с пользовательского поведения и найти строку в колонке «Вход».
- Пройти route, service, хранилище и фоновые события. Не менять один слой вслепую.
- Проверить повтор, timeout, невалидный input и устаревшее событие.
- Изменить или добавить тест из последней колонки.
- Перегенерировать reference: `python scripts/generate_code_reference.py`.
- Проверить drift командой с `--check` и собрать MkDocs в strict режиме.
07 / Код с русскими комментариями
Ключевые места без лишней обвязки
Комментарии ниже учебные. В исходнике код короче; ссылка под блоком открывает оригинал.
Форма выбирает `/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 ↗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 ↗Один 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 :limitsearch.py, строки 23–81 ↗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 ↗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 ↗Старое событие не затрёт новый документ
# Быстрые выходы до 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 ↗Параллельные запросы считают одну строку
# 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 ↗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. Здесь нет замечаний уровня «я бы назвал переменную иначе». Каждый пункт привязан к строкам и к сценарию, в котором он реально начинает мешать.
Тесты подтверждают то, что в них проверили: обычный pipeline, последовательный повтор, stale event, API contracts, PostgreSQL и NATS. Они не моделируют две одновременные обработки одного события, смену embedding space, новый файл по старому URL, смерть cleanup loop и исчерпание max_deliver.
Открыть suite на закреплённом SHA ↗Версия индекса равна версии карточки. Этого мало.
Смена 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 согласованы. Проблема вылезет на следующей смене модели или правил разбиения.
Два 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 отдаст сообщение повторно.
После 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 нет.
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 теряется объяснимость конкретной доставки.
Файл с тем же 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. Код эту зависимость сейчас не проверяет и не документирует.
Каждый 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-вызов всё равно возможны.
Метод называется 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 с этим не сталкивается. Это ограничение масштабирования и ручного одновременного запуска.
«Глобальный» 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 действует. Любое горизонтальное масштабирование меняет фактический бюджет.
Ошибки 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.
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 и новые посетители получают «демо занято».
Три места по-разному понимают слово «здоров».
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.
Локальный 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 для следующего ручного запуска.
Один 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 и более сложная трассировка.
Публичная демка импортирует 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 или с несколькими файлами показывает, насколько контракт уже уже основной модели.
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 почти наверняка разъедется.
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.
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.
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 полезны; их убирать нельзя. Замечание только про дублированное управление состоянием.
У 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.
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–51 | Provider, deployments, timeout, reasoning, token cap | Да; текущего replay для смены embedding недостаточно — см. замечание 01 |
| Retrieval | `MIN_RELEVANCE_SCORE`, `EMBEDDING_BATCH_SIZE` | config.py 41–42 | Порог выдачи и размер batch | API / worker соответственно |
| Database / queue | `DATABASE_URL`, `NATS_URL`, `NATS_*` | config.py | Подключения, stream, subject, consumer, delivery policy | Да |
| Crawler | `CRAWL_*`, `SOURCE_MAX_ITEMS`, source URLs | config.py 56–77 | Период, concurrency, page size, endpoints | Crawler |
| HTTP safety | `HTTP_TIMEOUT_SECONDS`, attempts, delay, jitter | config.py 61–65 | Retry policy и User-Agent | Crawler |
| Files | `ATTACHMENTS_DIR`, `MAX_ATTACHMENT_BYTES` | config.py 53–54 | Volume path и лимит скачивания/extraction | Crawler + worker |
| Public demo | `PORTFOLIO_DEMO`, secret, global limit | config.py 24–26 | Signed sessions, cleanup и защита MWS | API |
| Schema | Alembic revision `0001` | migration ↗ | Таблицы, indexes, constraints, VECTOR(1024) | `alembic upgrade head` до app |
| Containers | roles, healthchecks, volumes | docker-compose.yml ↗ | Локальный полный стек и optional Ollama profile | Compose recreate |
`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 / Проверка и деплой
Как понять, что цепочка не развалилась
Unit
Hashing, parsers, chunking, schemas, AI response validation, prompt, auth helpers.
tests/unit ↗API
Status codes, headers, rate sharing, OpenAPI, static UI, hidden internal errors.
test_api.py ↗Integration
Настоящий PostgreSQL/pgvector и NATS, транзакции, concurrency, stale events.
database pipeline ↗E2E
Fixture source → DB → event → indexer → protected Search/Ask.
fixture pipeline ↗CI на закреплённом исходнике
Репозиторий использует GitHub Actions, а не GitLab CI. Принципы jobs, artifacts, service containers и merge gate переносятся, но это не одно и то же средство.
Проверяем не одну кнопку
Сначала отдельно проверяем детали. Потом соединяем базу и почтовый ящик. В конце прогоняем путь целиком. Если сломалось, понятно, на каком этаже искать.
Contract + state + delivery
Главные проверки проекта — не snapshot markup, а инварианты: cursor не перепрыгивает сбой, limiter атомарен, stale hash не заменяет chunks, no-context не делает generation call, migration обратима.
11 / Скриншоты и измерения
Опубликованная версия, а не макет






- 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 ↗TypeScript / Node.js
В TenderLens этого backend-стека нет. NestJS/TypeScript/PostgreSQL показаны в Fullstack Test Shop.
Открыть кейс →Vue / realtime
Vue 3, TypeScript, WebSocket/SSE, NATS projection и восстановление по cursor показаны в Codex Monitor.
Открыть кейс →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 минут
Текст рассчитан на спокойную речь с показом схем и кода. Не надо читать дословно: опорная подсказка справа говорит, что открыть.
Задача и граница демо
TenderLens — сервис поиска по открытым закупкам и документам. Исходная задача была про асинхронный сбор данных из TED и Contracts Finder. Я довёл её до полного контура: нормализация, PostgreSQL, очередь, отдельный индексатор, векторный поиск и grounded-ответ по найденным фрагментам. На публичном стенде внешний crawler специально выключен: он не ходит в чужие API при каждом визите. Вместо этого там лежат четыре синтетических документа и шестнадцать chunks. При этом NATS, worker, PostgreSQL, pgvector и обе модели MWS работают реально. Это важно проговорить сразу: демо показывает рабочую техническую трассу, но не изображает внешний сбор, которого сейчас нет.
Компоненты
Приложение собрано одним Python-пакетом, но запускается в трёх ролях. Crawler отвечает только за внешние источники, файлы и запись нормализованных сущностей. Indexer — долгоживущий worker: он читает JetStream, извлекает текст, режет его и строит embeddings. API обслуживает браузер, авторизацию, лимит, поиск и Ask. Такое разделение делает отказ локальным. Медленная модель не держит транзакцию crawler, недоступный внешний источник не ломает уже проиндексированный поиск, а HTTP-процесс не обязан выполнять тяжёлую индексацию. Для небольшой системы это всё ещё один репозиторий и один image, поэтому не появляется лишняя распределённая разработка.
Сбор и транзакции
Каждый adapter приводит внешний ответ к TenderRecordV1. Pydantic отбрасывает лишние поля, нормализует строки, валюту и время. Crawler считает content_hash и ищет запись по паре источник плюс внешний id. Если содержимое не менялось, новый event не нужен. Если менялось, статус снова становится pending. Сначала транзакция сохраняет tender и attachments, потом сервис скачивает файлы, и только после commit публикует событие. Cursor источника двигается после успешно обработанной страницы. Если запись упала, cursor остаётся на прежнем месте, поэтому данные не перескакиваются. Между commit и publish есть окно сбоя; его закрывает republish_pending. В production я бы следующим шагом сделал transactional outbox.
Очередь и идемпотентность
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 не держал очередь бесконечно.
Извлечение, chunks и embeddings
Indexer отдельно индексирует название, описание и агрегированные метаданные, затем читает поддержанные вложения. Для PDF нужен текстовый слой; OCR в текущей версии нет. HTML разбирается стандартным parser без выполнения script, XML — через defusedxml. Текст режется по абзацам до 1500 символов с overlap 150. Это простое правило, которое легко тестировать. Batch отправляется в MWS bge-m3. Provider проверяет, что вернулось ровно столько vectors, сколько текстов, что каждый элемент числовой и имеет 1024 компонента. Размерность зафиксирована и в настройках, и в PostgreSQL VECTOR(1024), поэтому случайно смешать несовместимые embeddings нельзя.
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 импровизировать без основания.
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 остаётся в логах.
Интерфейс и 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, а не как доказательство всего стека одной кодовой базой.
Тесты и 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 после деплоя.
Деплой и ресурсы
Образы собираются заранее. На 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-моделей.
Что я нашёл на 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 и восстановление, потом скорость и косметика.
Связь с вакансией
Для Obuchat этот кейс релевантен там, где нужно самостоятельно пройти от существующего кода до работающего результата: API, PostgreSQL, migrations, внешняя AI-интеграция, очередь, обработка повторов, Docker и проверка после запуска. Он также показывает, как я отношусь к AI-агентам: задачу можно исследовать и реализовывать с их помощью, но contracts, diff, secrets, tests и production evidence остаются ответственностью инженера. Пробелы тоже понятны: здесь нет React/Vue UI, Socket.IO, n8n и Keycloak. На собеседовании я не смешиваю их с реализованным кодом, а показываю соседние проекты и объясняю, что именно готов перенести в продуктовую задачу.
Перед собеседованием
14 / Подготовка к собеседованию
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-транзакции.
Добивающий вопрос. Почему нельзя держать row lock во время запроса MWS?
АрхитектураЧто означает идемпотентность в этом проекте?
Подготовленный ответ. Повтор той же закупки не создаёт дубль, повтор события не создаёт второй набор chunks, а stale event не перезаписывает новую версию. Основание — unique constraints, content_hash, indexed_hash и повторная проверка под lock.
Добивающий вопрос. Какой idempotency key вы выбрали бы для внешнего webhook?
АрхитектураКак обрабатывается stale event?
Подготовленный ответ. Indexer сравнивает event.content_hash с текущим tender.content_hash до работы и после embeddings. Если версия изменилась, возвращает stale и подтверждает сообщение, не меняя индекс.
Добивающий вопрос. Почему нужна вторая проверка после модели?
АрхитектураКакой главный архитектурный долг вы видите?
Подготовленный ответ. Между DB commit и NATS publish остаётся окно сбоя, которое сейчас закрывается переотправкой pending записей. Для более строгой гарантии я добавил бы transactional outbox с отдельным publisher.
Добивающий вопрос. Как очистить outbox и не потерять событие?
APIПочему FastAPI?
Подготовленный ответ. Нужны async HTTP, строгие Pydantic contracts, OpenAPI и лёгкая dependency injection. Для Python-сервиса с asyncpg/httpx это даёт короткий путь без собственной инфраструктурной обвязки.
Добивающий вопрос. Какие минусы FastAPI проявятся в большом проекте?
APIЧто даёт dependency injection в routes?
Подготовленный ответ. Route объявляет auth, limiter и session как зависимости, а сам только передаёт payload сервису. В тестах production зависимости заменяются in-memory реализациями без monkey patch глобального состояния.
Добивающий вопрос. Чем это отличается от service locator?
APIКак хранится API key?
Подготовленный ответ. Клиент получает открытый секрет один раз, а в PostgreSQL сохраняется SHA-256. При запросе сервер хэширует header и сравнивает найденное значение через compare_digest; disabled key даёт 403.
Добивающий вопрос. Почему простой SHA-256 допустим для случайного длинного токена, но плох для пароля?
APIКак устроен rate limiter?
Подготовленный ответ. Один key имеет UTC-minute window и request_count. SELECT FOR UPDATE сериализует параллельные изменения, success делает commit, превышение возвращает 429 и Retry-After.
Добивающий вопрос. Как fixed window допускает burst на границе минут?
APIПочему Search и Ask делят лимит?
Подготовленный ответ. Оба вызывают embedding и PostgreSQL, а Ask дополнительно вызывает generation. Общий budget мешает обойти ограничение чередованием endpoints.
Добивающий вопрос. Как сделать разные веса для Search и Ask?
APIДля чего X-Request-ID?
Подготовленный ответ. Он связывает ответ клиента с записью в логах и помогает пройти цепочку ошибки. Клиентский id принимается, иначе создаётся UUID; он возвращается даже в error envelope.
Добивающий вопрос. Какие ещё correlation ids нужны в очереди?
APIКак проект скрывает внутренние ошибки?
Подготовленный ответ. Typed AppError преобразуется в стабильный JSON. Неожиданное исключение логируется со stack trace и request_id, но клиент получает общий internal_error без путей, SQL и secrets.
Добивающий вопрос. Что вы вернёте при недоступности MWS?
PostgreSQLКакие таблицы являются основными?
Подготовленный ответ. sources хранит cursor, tenders — нормализованную карточку и версии hash, attachments — файлы и download state, chunks — текст с VECTOR(1024), api_keys — auth и rate window.
Добивающий вопрос. Почему Source вынесен отдельно?
PostgreSQLКакие ограничения защищают данные?
Подготовленный ответ. Есть unique на source+external_id, tender+source_url и chunk_key; FK с CASCADE/RESTRICT; CHECK для статусов, размера, суммы, позиции и лимита. Эти инварианты остаются в БД даже при ошибке приложения.
Добивающий вопрос. Какие ограничения нельзя выразить простым CHECK?
PostgreSQLКак предотвращается lost update при лимите?
Подготовленный ответ. Строка ApiKey читается with_for_update, затем счётчик меняется и транзакция коммитится. Параллельный request ждёт блокировку и видит уже обновлённое значение.
Добивающий вопрос. Что изменится при нескольких регионах БД?
PostgreSQLПочему chunks заменяются атомарно?
Подготовленный ответ. Пользователь не должен увидеть смесь старой и новой версии. DELETE старых, INSERT новых и update indexed_hash проходят в одной транзакции после version recheck.
Добивающий вопрос. Как сократить период блокировки при миллионах chunks?
PostgreSQLКак работает cosine search?
Подготовленный ответ. Оператор pgvector <=> возвращает cosine distance. Код превращает его в similarity как 1-distance, фильтрует threshold, сортирует по distance и id, затем ограничивает количество.
Добивающий вопрос. Что произойдёт с нулевым vector?
PostgreSQLПочему exact scan, а не HNSW?
Подготовленный ответ. В демо шестнадцать chunks, поэтому exact scan проще, детерминированнее и не требует tuning recall. HNSW нужен после измерения на большом corpus и сравнения recall/latency.
Добивающий вопрос. Какие параметры HNSW вы бы измеряли?
PostgreSQLПочему размерность 1024 зафиксирована в migration?
Подготовленный ответ. Тип VECTOR требует конкретной размерности, а bge-m3 отдаёт 1024 компонента. Валидация provider и Settings быстро ловит несовместимую модель до записи.
Добивающий вопрос. Как безопасно перейти на модель другой размерности?
PostgreSQLПочему vectors разных providers нельзя смешивать?
Подготовленный ответ. Координаты имеют смысл только внутри одного embedding space. После смены модели весь corpus переиндексируется, а embedding_model сохраняется рядом с каждым chunk.
Добивающий вопрос. Как провести zero-downtime reindex?
ОчередьЧто означает at-least-once delivery?
Подготовленный ответ. Сообщение будет доставлено не меньше одного раза, но может прийти повторно, например если worker умер после commit и до ACK. Поэтому обработчик обязан быть идемпотентным.
Добивающий вопрос. Чем это отличается от exactly-once marketing claim?
ОчередьКогда worker делает ACK, NAK и TERM?
Подготовленный ответ. ACK — успешная, stale или unchanged обработка. NAK с задержкой — временная ошибка MWS, PostgreSQL или файла. TERM — невалидный payload или постоянная ошибка, которую повтор не исправит.
Добивающий вопрос. Где хранить TERM-сообщения для разбора?
ОчередьДля чего ack_wait и max_deliver?
Подготовленный ответ. ack_wait задаёт срок, после которого неподтверждённое сообщение можно доставить повторно. max_deliver ограничивает бесконечные повторы одного сбойного события.
Добивающий вопрос. Как подобрать ack_wait для длинного AI batch?
ОчередьКак решается сбой publish после commit?
Подготовленный ответ. Tender остаётся pending. Следующий цикл вызывает republish_pending и заново отправляет событие с актуальным content_hash.
Добивающий вопрос. Как transactional outbox улучшит эту схему?
ОчередьНужен ли строгий порядок событий?
Подготовленный ответ. Глобальный порядок не нужен: content_hash делает каждое событие version-aware. Для одной закупки позднее старое событие распознаётся как stale и не портит состояние.
Добивающий вопрос. Когда всё же потребуется per-key ordering?
ОчередьКак масштабировать indexer?
Подготовленный ответ. Можно увеличить число pull consumers одного durable с согласованным распределением сообщений, но надо контролировать MWS quota и DB pool. Row locks и version checks сохраняют корректность для одной закупки.
Добивающий вопрос. Где поставить backpressure?
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?
Подготовленный ответ. Без релевантного источника модель скорее будет отвечать из общих знаний. Я возвращаю явный отказ, экономлю запрос и делаю поведение проверяемым.
Добивающий вопрос. Как отличить отсутствие данных от слишком высокого threshold?
AI / RAGКак выбран threshold 0.40?
Подготовленный ответ. На небольшом проверочном наборе релевантные top scores были 0.604–0.927, посторонние — 0.278 и 0.370. 0.40 — настройка демо, а не универсальная метрика качества; для production нужен размеченный eval set.
Добивающий вопрос. Какие precision/recall метрики вы соберёте?
AI / RAGЗачем temperature=0?
Подготовленный ответ. Для технического ответа важнее повторяемость и следование context, чем разнообразие. Нулевая температура не делает модель детерминированной на всех providers, но сокращает вариативность.
Добивающий вопрос. Что ещё влияет на воспроизводимость?
AI / RAGКак проверяется ответ MWS?
Подготовленный ответ. Provider валидирует HTTP status, JSON shape, наличие choices/message/content и непустой текст. Семантическую правильность provider не может доказать; её проверяют citations и отдельные evals.
Добивающий вопрос. Как автоматизировать groundedness evaluation?
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 и получает повторную доставку; число повторов ограничено брокером.
Добивающий вопрос. Когда retry может удвоить стоимость?
Тесты / OpsКак устроена пирамида тестов?
Подготовленный ответ. Unit закрывают чистую логику и providers через mock transport, API — HTTP contracts с подменой dependencies, integration — настоящий PostgreSQL/NATS, E2E — полный fixture pipeline. Живой MWS остаётся отдельным smoke.
Добивающий вопрос. Какой тест поймает stale overwrite?
Тесты / OpsЧто запускает CI?
Подготовленный ответ. Black, Flake8, MyPy, unit/API, migration на clean PostgreSQL, integration, E2E, downgrade/upgrade, Docker build, Compose config и smoke трёх ролей под non-root user.
Добивающий вопрос. Что блокирует merge, а что запускается после deploy?
Тесты / OpsПочему проверяется downgrade миграции?
Подготовленный ответ. Это подтверждает, что схема хотя бы технически обратима на тестовой БД и migration не содержит очевидного одностороннего шага. Для production откат данных всё равно требует отдельного плана.
Добивающий вопрос. Когда downgrade делать опасно?
Тесты / OpsЧем liveness отличается от readiness?
Подготовленный ответ. Liveness отвечает, жив ли процесс. Readiness проверяет PostgreSQL и AI deployments; при их недоступности возвращает 503, чтобы трафик не шёл в неготовый instance.
Добивающий вопрос. Нужно ли readiness проверять NATS у API?
Тесты / 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.
Добивающий вопрос. Какие alert thresholds задать сначала?
Тесты / OpsХватило ли ресурсов VPS?
Подготовленный ответ. Один idle-снимок показал около 62 MiB API и 59 MiB worker при лимите 256 MiB на контейнер. Модели выполняются в MWS, поэтому GPU и model RAM на VPS не нужны; эти цифры не заменяют нагрузочный тест.
Добивающий вопрос. Как провести честный capacity test?
Тесты / 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.
Добивающий вопрос. Какой модуль вы бы первым перенесли на NestJS и зачем?
ВакансияГде React или Vue?
Подготовленный ответ. В TenderLens их нет, потому что интерфейс мал и не требует сложного client state. Практику Vue показываю в Codex Monitor; к React готовлюсь через сравнение component/state/router/testing моделей, но не приписываю себе код, которого здесь нет.
Добивающий вопрос. Чем Vue reactivity отличается от Angular signals?
ВакансияЕсть ли WebSocket / Socket.IO?
Подготовленный ответ. В TenderLens запросы конечные и работают по HTTP, поэтому realtime transport не нужен. Реальный WebSocket/SSE и восстановление по cursor показаны в Agents; для TenderLens realtime был бы уместен для progress долгого импорта.
Добивающий вопрос. Как спроектировать progress channel с reconnect?
ВакансияКак вы используете AI-агентов в разработке?
Подготовленный ответ. Даю ограниченную задачу и контекст, прошу сначала исследовать зависимости, затем проверяю diff, запускаю typecheck/lint/tests/build и воспроизвожу реальный сценарий. Секреты и production actions не делегирую без явной границы; неверные предположения агента считаю обычным инженерным риском.
Добивающий вопрос. Пример ошибки агента, которую должен поймать review?
ВакансияКак этот проект связан с продуктами анализа коммуникаций?
Подготовленный ответ. Техническая схема похожа: 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-статусе.
Добивающий вопрос. Как спроектировать index_signature и zero-downtime reindex?
Code reviewПочему row lock не запрещает двойной вызов MWS?
Подготовленный ответ. Lock держится только пока status меняется на processing. После commit он отпущен, а право на работу нигде не записано как уникальный lease. Второй worker тоже может поставить processing и уйти считать тот же batch.
Добивающий вопрос. Как вернуть задание после смерти владельца lease?
Code reviewЧто случится после пяти временных ошибок indexer?
Подготовленный ответ. Каждая ошибка ставит tender в failed, worker делает NAK, затем JetStream исчерпывает max_deliver. Sweep выбирает только pending, поэтому неизменившаяся запись может застрять до ручного replay или изменения source payload.
Добивающий вопрос. Где должна жить retry state machine?
Code reviewПочему Nats-Msg-Id не решает все дубли?
Подготовленный ответ. Id равен случайному event_id. republish_pending конструирует новое событие и новый UUID, поэтому broker видит новую публикацию. Данные спасает content_hash в indexer, но очередь и MWS от параллельной работы защищены не полностью.
Добивающий вопрос. Когда нужен outbox с сохранённым event_id?
Code reviewЧто не так с методом, который назван UPSERT?
Подготовленный ответ. Он сначала делает SELECT, затем обычный INSERT. При двух crawler оба могут увидеть пусто, а unique constraint уронит одного из них. Это защита целостности, но не конкурентный upsert.
Добивающий вопрос. Покажите вариант INSERT ON CONFLICT RETURNING.
Code reviewКакая проверка безопасности в auth лишняя?
Подготовленный ответ. compare_digest выполняется уже после SELECT WHERE key_hash == computed_hash. Если row найдена, сравниваются значения, равенство которых обеспечило условие SQL. Полезнее объединить auth и атомарное списание лимита в одну DB operation.
Добивающий вопрос. Когда compare_digest действительно нужен?
Code reviewПочему зелёные 129 tests не отменяют эти замечания?
Подготовленный ответ. Suite хорошо проверяет happy path, sequential idempotency, stale version и отдельные ошибки. Он не моделирует два одновременных worker, смену embedding space, неизменный URL с новыми bytes, исчерпание max_deliver и падение cleanup task.
Добивающий вопрос. Какие четыре regression tests вы добавите первыми?
Code reviewЧто вы бы упростили первым, если дали один день?
Подготовленный ответ. Сначала ввёл бы index_signature и единый job state machine, потому что это correctness. Затем вынес бы AI factory и demo seeder, объединил auth+limit, добавил DLQ. Косметическое сокращение HTTP client оставил бы после поведения и тестов.
Добивающий вопрос. Почему порядок важнее количества отрефакторенных строк?
15 / Ссылки и первоисточники
Код, документация, технологии
Версия учебника привязана к исходнику 6c005b6ef86329e7d74e9620058f55b5c6e7d4ec и evidence от 9 сентября 2026.
