Andrey Epifancev b127c02422 Срез 0 [7.5]: финальная волна, документы — T5.1, T5.3, T6.1, T6.3, T6.6, M-3
- README: верхняя граница задержки settle, шаг 5 «Полного круга»; критерии ошибочного файла (корень не объект)
- CLAUDE.md: по одному json на каждый распознанный документ; критерии ошибочного файла
- design.md: D-5 — оговорка про нестроковый error.code отдельным предложением; №79 — источник указания, SQL уборки в порядке FK
- open-questions-proposal.md п.20: замены ../docs/dev/README.md (185–187, 224–226, 252–253) и комментарий docker-compose.yml:184
- план: Task 6 — пометка про замену down -v (R-27)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:01:13 +04:00

68 KiB
Raw Blame History

Design: Срез 0 — скелет scan-gateway

Context

См. proposal.md. Исходное состояние — копия каркаса arm-core (a9159aa). Протокол ИС Сканирование (FNR02, reference-old-arm/api/src/core/PDFWorker): файл с префиксом UUID кладётся во входящий каталог SMB-шары, ответ появляется в исходящем каталоге в подкаталоге с тем же UUID, формат ответа — Content AI (file_uid, error, data_ocr — объект, OQ-77), по одному json на распознанный документ, таймаут 5 мин без повторов (ТЗ 3, OQ-15). Старый код держал задания в in-memory кэше и опрашивал шару двумя поллерами в одном процессе с ядром — это и есть то, от чего уходит DECISION-4. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен до прода, CI в Gitea нет.

Goals / Non-Goals

Goals: конвейер «NATS → ack → S3 → горячая папка → ответ → S3 → NATS» работает против эмулятора в compose и в unit-тестах без Docker; таймаут и ошибки ИС всегда превращаются в результат; сырой ответ сохраняется для разбора инцидентов. Non-Goals: см. proposal.

Decisions

D-1. Горячие папки — протокол с двумя реализациями, имена каталогов настраиваемы

HotFolder.put(name, data), poll(job_id) -> list[AnswerFile] | None (AnswerFile(name, data)), healthy() -> bool. poll возвращает None, пока ответ не готов, и все *.json каталога <output>/<job_id>/ по возрастанию имени, когда готов (OQ-78: ИС кладёт все распознанные документы задания одновременно, по одному json на документ; другие файлы каталога игнорируются). Готовность — по стабилизации: есть хотя бы один *.json, а набор имён и размеров *.json не менялся SCAN_SETTLE_SECONDS (2) — защита от недописанного набора. LocalHotFolder(root) работает с обычным каталогом (dev, compose-том hotfolder, unit-тесты через tmp_path); SmbHotFolder(host, path, login, password) — через smbclient шаблона (UNC-пути). Подкаталоги SCAN_INPUT_DIR/SCAN_OUTPUT_DIR по умолчанию Input/Output (как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Временный файл атомарной записи (.part) лежит не в Input, а в служебном каталоге SCAN_TMP_DIR (Tmp) рядом с Input/Output на той же шаре/ФС, чтобы rename в Input оставался атомарным, а ИС без маски не забрала недописанный файл (OQ-79); каталог создаётся при первой записи. Режим — SCAN_HOTFOLDER_MODE = local | smb.

D-2. Без реестра заданий на диске: состояние — в процессе и в горячей папке, ack — сразу

NATS без JetStream: сообщение доставляется один раз, повторы делает ядро, не получив ack за 30 с. Поэтому по получении запроса шлюз сразу публикует ack processing и держит process-local множество job_id «в работе/обработано» (LRU, DONE_CACHE_SIZE=1000): повторный запрос (ядро не дождалось ack) → повторный ack, второй файл в ИС не уходит. Падение инстанции после ack → ядро закроет задание по reply_deadline (timeout); повторной отправки в ИС не будет (OQ-15, 0 повторов). Альтернатива — хранить задания в Redis/файле: лишняя инфраструктура ради ситуации, которую ядро уже обрабатывает.

D-3. Параллелизм — SCAN_MAX_INFLIGHT задач, каждая ждёт свой ответ

Подписка queue="scan-gateway"; on_request валидирует, публикует ack и кладёт задание во внутреннюю очередь; семафор SCAN_MAX_INFLIGHT (4) ограничивает одновременные обработки; ожидание — опрос poll(job_id) каждые SCAN_POLL_SECONDS (5) до deadline = min(request.reply_deadline, start + SCAN_TIMEOUT_SECONDS); ответ принимается, когда poll вернул готовый набор файлов (settle, D-1). При таймауте (в т.ч. если набор так и не стабилизировался) — результат TIMEOUT, файл во входящем каталоге не удаляется (ИС может ответить позже; «поздний» ответ никто не опрашивает; уборка каталога — задача эксплуатации, вне среза). Очередь заданий, ждущих слот, не ограничена — ядро не публикует больше, чем queued заданий.

D-4. Сырой ответ — склейка всех файлов, в S3 raw/{job_id}.json, в конверте raw_s3_key

По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов (контракт v1.0 не меняется: raw_s3_key — по-прежнему одна строка raw/{job_id}.json). Объект — склеенный ответ ИС: {"job_id": "<job_id>", "files": [{"name": "<имя файла>", "response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}, files — все *.json каталога ответа по возрастанию имени. Каждый файл разбирается json.loads независимо: не JSON → response = null, parse_error заполнен (задание не падает и файл не перечитывается — к моменту склейки набор уже стабилен). Сохраняется до нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3.

D-5. Нормализатор — интерфейс; в Срезе 0 сводит ошибки по файлам и отвечает NOT_IMPLEMENTED

Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics, warnings) (raw — склеенный ответ, разобранная структура D-4; JSON каждого файла разобран при склейке, не-объект на входе -> INVALID_FORMAT). NullNormalizer относит файл к «ошибочным», если parse_error не null либо response.error.code — непустая строка. Кроме того, error.code не строка и не null/"" (число, список, bool) — тоже ошибка файла, код UNKNOWN_ERROR (R-32, design #71); успех файла — error.code == "" (реальный успешный ответ ИС несёт error = {"code": "", "message": ""}, поэтому проверка «error не null» неверна; отсутствующий или null error ошибкой не считается). Если ошибочны все файлы — status = error, error.code — код первого ошибочного файла по имени (коды Content AI совпадают с перечнем контракта; для parse_error — INVALID_FORMAT; код вне перечня — UNKNOWN_ERROR с исходным кодом в сообщении; корень ответа файла не объект JSON — ошибка этого файла INVALID_FORMAT), error.message — его сообщение; metrics.warnings — по строке на каждый ошибочный файл вида <имя>: <код>: <сообщение>, где <код> — исходный код ИС как получен (для parse_error и корня ответа, не являющегося объектом, — INVALID_FORMAT); если ошибочных файлов нет, поле warnings опускается. NormalizeOutcome.warnings: list[str] (по умолчанию пуст, R-25) — внутреннее поле: воркер дописывает его в конец metrics.warnings конверта и файла результата (R-30, R-33). Если есть хотя бы один успешный файл — status = error, error.code = NOT_IMPLEMENTED с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3», ошибочные файлы — в metrics.warnings так же. Срез 3 подменяет реализацию (§11, OQ-13/14), транспорт не меняется.

D-6. Эмулятор — тот же репозиторий и образ, другой entrypoint

python -m src.scan_emulator: цикл по Input/ каждую секунду; новый файл <uuid>_<name>.<ext> → набор фикстур из SCAN_FIXTURES_DIR: все <name>.json и <name>_*.json (не префикс <name>*: aep_3 не должен забирать aep_30*) → через SCAN_EMULATOR_DELAY запись набора в Output/<uuid>/ под именами <uuid>.json для <name>.json и <uuid>_<suffix>.json для <name>_<suffix>.json, с подстановкой file_uid = <uuid>. Набор появляется в Output/<uuid>/ атомарно целиком: пишется во временный каталог эмулятора, затем каталог переименовывается. Нет пары → один ответ Content AI {"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": {...}}, где data_ocr — объект с пустыми полями, как у реальных ответов-ошибок aep_5.json и aep_8_form4.json (label, organization, inn, units, date — {"form_name": "", "confidence": 0}, table_structure: []; R-28). Обработанные входящие файлы переименовываются в <uuid>_<name>.<ext>.done, чтобы не обрабатываться повторно. Эмулятор использует LocalHotFolder — тем самым тестируя и его.

D-7. Ошибки транспорта — результат, не молчание

Запрос не по схеме / MAJOR ≠ 1 → SCHEMA_MISMATCH (без ack); S3 get упал → FILE_READ_ERROR; put в горячую папку упал → RECOGNITION_FAILED (сообщение: «горячая папка недоступна»); файл ответа не JSON → parse_error файла (D-4), а если ошибочны все файлы — INVALID_FORMAT (D-5); прочее → UNKNOWN_ERROR. Конверт публикуется всегда с повторами публикации до PUBLISH_RETRIES=3; не удалось — лог error, ядро закроет по reply_deadline.

D-8. NATS — как у ядра и парсера, без JetStream

Без учётных данных при пустых NATS_SEED/NATS_JWT, иначе nkeys/JWT шаблона; автопереподключение nats-py; подписка queue group восстанавливается клиентом; /healthcheck отдаёт nats и hotfolder.

D-9. Тесты

Unit: LocalHotFolder на tmp_path (в Input нет .part; poll — нет каталога, один файл, два файла, дописываемый файл, посторонний файл); склейка и NullNormalizer — на реальных aep_3.json, aep_3_ofr.json, aep_5.json, aep_6.json; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end «put → эмулятор → poll (с settle) → склейка» без Docker; обработчик воркера с фейками S3/NATS — ack до обработки, повторный запрос, все коды D-7 и таймаут (с укороченными настройками). SmbHotFolder — unit с подменённым smbclient. Интеграционный (маркер integration): настоящий lifespan create_app и эмулятор в процессе теста против compose NATS/MinIO (R-7; контейнера scan-emulator в compose пока нет) — request-image.json для aep_3.pdf → ack, затем NOT_IMPLEMENTED с metrics.warnings про <job_id>_ofr.json и raw/{job_id}.json с двумя файлами; aep_6.pdf → NOT_IMPLEMENTED без warnings; aep_5.PDF → INVALID_DOCUMENT с raw_s3_key; объект raw/ в MinIO. Синтетический успешный ответ (R-10) не нужен: реальные успешные образцы aep_3.json, aep_6.json добавлены человеком 04.10.2026 (OQ-76).

D-10. Dev без Nexus, Python 3.11

Dockerfile — python:3.11-slim; make venv ставит requirements.txt без строк raisa_*. Конфигураций CI нет: make check / make check-full.

Risks / Trade-offs

  • [SMB в проде ведёт себя иначе, чем локальный каталог (права, кодировки имён)] → SmbHotFolder минимален и повторяет вызовы старого PDFWorker; первый прогон на реальной шаре — отдельная задача Среза 3 с доступом к стенду.
  • [Имена каталогов ИС отличаются от Input/Output] → настройки, не код.
  • [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре.
  • [Падение шлюза после ack теряет задание до reply_deadline] → осознанно: без JetStream иначе нельзя без своей очереди; ядро переводит в timeout, replay — Срез 3+.
  • [Эмулятор знает только пары из fixtures/ocr] → ошибка INVALID_DOCUMENT для остальных — тоже тестовый сценарий; новые пары и новые файлы набора (<name>_*.json) добавляются в fixtures/ocr без изменения кода.
  • [ИС пишет набор с паузой больше SCAN_SETTLE_SECONDS между файлами] → шлюз склеит неполный набор, недостающие документы потеряны (повторов нет, OQ-15) → значение настраивается; проверить на стенде в Срезе 3.
  • [Tmp и Input на разных ФС/шарах] → rename перестаёт быть атомарным → SCAN_TMP_DIR обязан лежать на той же шаре.

Migration Plan

Нет данных. Удаление каталогов — отдельным первым коммитом. Откат — revert.

Open Questions

Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего/служебного каталогов, путь SMB и подходящее SCAN_SETTLE_SECONDS на стенде ИС Сканирование — нужны к Срезу 3. OQ-76/77/78/79 решены человеком 04.10.2026 (см. open-questions-proposal.md).

Decisions made without discussion

№ Дата Решение Обоснование Цена ошибки
1 2026-10-03 D-1: протокол HotFolder, режимы local/smb, имена каталогов Input/Output по умолчанию dev без SMB; имена из старого кода смена настройки
2 2026-10-03 D-2: queue group scan-gateway, ack сразу, process-local LRU job_id, без реестра на диске NATS без JetStream (ответ 03.10); OQ-15 добавить хранилище состояния, если понадобятся повторы OCR
3 2026-10-03 D-3: SCAN_MAX_INFLIGHT = 4, опрос 5 с, таймаут 300 с ТЗ 3 (5 мин) смена настроек
4 2026-10-03 D-4: сырой ответ сохраняется до нормализации данные для инцидентов даже при падении нормализатора нет
5 2026-10-03 D-5: NullNormalizer пробрасывает ошибки ИС, успех → NOT_IMPLEMENTED [частично заменено №57–58] честный статус; коды Content AI совпадают с контрактом нет
6 2026-10-03 D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, .done после обработки [частично заменено №61–62] общие соглашения горячих папок; aep_8.pdf ↔ aep_8_form4.json нет
7 2026-10-03 D-7: таблица транспортных ошибок, публикация с 3 повторами ядро не ждёт таймаута, если шлюз жив нет
8 2026-10-03 external_task_id в ядро не передаётся (идентификатор ИС = job_id в имени файла) у протокола горячих папок нет отдельного id задания поле DBML остаётся NULL
9 2026-10-03 D-10: python:3.11-slim, make check/check-full вместо CI ответы пользователя смена тега
10 2026-10-04 R-1: .env.example создаёт задача 1.2 (минимальный рабочий local-режим), задача 2.2 дополняет и добавляет .env.test проверка 1.2 требует .env.example, а план создаёт его в 2.2 переделка файла
11 2026-10-04 R-2: из app.py убирается модульный код nkeys/NATS (переезжает в src/core/nats.py); /healthcheck в 1.2 отдаёт uptime+version, поля nats/hotfolder добавляет 5.3 пустые NATS_SEED/NATS_JWT не должны ломать импорт (spec «NATS без JetStream») нет
12 2026-10-04 R-3: задача 1.3 (README/CLAUDE.md) выполняется после 6.1, до 6.2 проверка «упомянутые файлы существуют» невыполнима до появления файлов нет
13 2026-10-04 R-4: LocalHotFolder открывает root, input_dir, output_dir, answer_path(job_id); эмулятор работает с ними обычными операциями ФС, протокол HotFolder остаётся put/poll/healthy у протокола нет листинга входящих и записи ответа, нужных эмулятору (D-6) рефакторинг эмулятора
14 2026-10-04 R-5: put(name, data) пишет файл name как есть, имя <job_id>_<original_name> собирает вызывающий (inbox_name); запись атомарна (.part + rename), Input/ создаётся, корень — нет; healthy() = корень существует; эмулятор игнорирует dot-файлы и .done [частично заменено №59: .part — в SCAN_TMP_DIR] неоднозначность плана («put пишет _»); спека требует RECOGNITION_FAILED при отсутствии корня нет
15 2026-10-04 R-6: фабрика create_hotfolder(settings) в src/gateway/__init__.py (задача 4.2), smb импортируется лениво ни одна задача не владела фабрикой; в local smb-модуль не должен импортироваться нет
16 2026-10-04 R-7: интеграционный тест 6.1 запускает воркер шлюза и эмулятор в процессе теста против compose NATS/MinIO с tmp-каталогом; точный фрагмент compose для scan-emulator — в open-questions-proposal.md контейнера scan-emulator в compose пока нет (его добавит docs в фазе 2) контейнерный путь не проверен
17 2026-10-04 R-8: argon2-cffi==23.1.0 и argon2-cffi-bindings==25.1.0 остаются в requirements.txt (транзитивные зависимости miniopy-async) спека запрещает только SQLAlchemy/asyncpg/alembic/raisa_fastapi_protected_api; формулировка плана «без argon2-cffi» убрала бы фиксацию версий в freeze-файле (T2.1) две лишние строки в requirements
18 2026-10-04 Задача 1.2: вложенные модели OPENBAO остаются BaseSettings, блок SMB — SMBSettings() с пустыми значениями по умолчанию, полноту проверяет Settings в режиме smb иначе pydantic-settings не разбирает вложенные ENV-ключи нет
19 2026-10-04 Задача 1.2: добавлено MINIO_SECURE (в .env.example false); 5.2 передаёт secure= в Minio(...) MinIO в compose работает по http нет
20 2026-10-04 Задача 1.2: SCAN_POLL_SECONDS/SCAN_TIMEOUT_SECONDS — PositiveFloat укороченные таймауты в тестах (дробные секунды) нет
21 2026-10-04 Задача 1.2: из lifespan убраны S3 и NATS (вернутся в 5.2/5.3) подключение переезжает в src/core/nats.py/воркер (R-2) нет
22 2026-10-04 Задача 1.2: app.py — фабрика create_app(settings), модульный settings убран; конфиг читается лениво (get_settings() с lru_cache) тесты без побочных эффектов при импорте нет
23 2026-10-04 Задача 2.1: Makefile в корне репозитория (рецепты cd api && …, как в arm-core); строгие флаги mypy заданы заранее для src.contract, src.gateway, src.scan_emulator, код шаблона — нестрогий; httpx не дублируется в requirements-dev.txt (приходит из requirements.txt) единообразие с arm-core; новые пакеты пишутся строго с первого дня нет
24 2026-10-04 Задача 2.2: строка .dockerignore убрана из .gitignore (как в arm-core), файл закоммичен; conftest.py берёт окружение из .env.test (ENV_FILE=.env.test в pytest.ini), setdefault убран; тесты запускаются из api/ шаблонная строка в .gitignore не давала закоммитить .dockerignore; единый источник окружения тестов тесты зависят от cwd=api/
25 2026-10-04 Задача 3.1: модели src/contract скопированы из arm-core (включая модели ОСВ и tabular-подписки, не используемые шлюзом); extra="ignore" вместо forbid схемы не содержат additionalProperties: false, MINOR добавляет необязательные поля; спека требует валидировать все примеры docs; близость к arm-core упрощает синхронизацию при расхождении arm-core копию синхронизируют вручную
26 2026-10-04 Задача 3.1: конструкторы конвертов build_ack_message/build_error_message/build_done_message принимают ServiceInfo параметром (ServiceInfo(name=settings.SERVICE_NAME, version=settings.VERSION)); is_supported_major — fullmatch по шаблону схемы без int() конструкторы не зависят от окружения; int() падает на сверхдлинной строке (T6.1) нет
27 2026-10-04 R-9: методы протокола HotFolder синхронные; воркер вызывает их через asyncio.to_thread блокирующие вызовы ФС/smbclient; общий контракт для двух реализаций обернуть в async позже
28 2026-10-04 Задача 4.1: answer_path бросает ValueError для небезопасного job_id; общие validate_name/validate_job_id в hotfolder.py; put отвергает имена с разделителями, NUL, точкой в начале job_id и original_name приходят из запроса NATS воркер не должен принимать ValueError за недоступность ИС
29 2026-10-04 Задача 4.2: inbox_name заменяет символы, недопустимые в Windows/SMB (: < > " | ? *, управляющие), на _ и отбрасывает завершающие точки/пробелы; put остаётся строгим (T8.1) допустимое имя пользователя (Отчёт: 2024.pdf) не должно проваливать задание; для ИС имя ничего не значит имя во входящем каталоге отличается от загруженного
30 2026-10-04 Задача 4.2: SMB_PATH — относительный (share/dir, склеивается с хостом) или полный UNC; учётные данные передаются в каждый вызов smbclient, если задан логин; poll возвращает None на STATUS_SHARING_VIOLATION/STATUS_DELETE_PENDING; монопольное открытие ответа сохранено; mkdir существующего Input с ACCESS_DENIED не роняет put, если каталог существует повторяет вызовы старого PDFWorker; ИС может ещё писать ответ; урезанные ACL шары проверить на реальной шаре в Срезе 3
31 2026-10-04 Задача 4.2: временный файл .part лежит прямо в Input; читается один <job_id>.json (старый код читал все файлы Output/<id>/) [заменено №54–55 и №59] спека; реальная ИС может забирать файлы без маски — риск вынесен в open-questions-proposal.md запись .part в служебный каталог, чтение всех файлов подкаталога
32 2026-10-04 R-10 (наблюдение O-1): оба реальных ответа fixtures/ocr/aep_5.json и aep_8_form4.json сами являются ответами-ошибками ИС (error = INVALID_DOCUMENT; data_ocr — объект с пустыми полями (form_name пусто, confidence 0.0) и table_structure: []), поэтому путь «успех -> NOT_IMPLEMENTED» проверяется на синтетическом успешном ответе (копия aep_5.json с error = null и одной строкой таблицы) — в unit-тестах 5.1 и интеграционном тесте 6.1 (временный каталог фикстур с ok_doc.json); реальные фикстуры используются для сценария «ошибка ИС проброшена» [заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76] невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется если владелец ждёт, что aep_5.json — успешный образец, нужны реальные успешные ответы (открытый вопрос Среза 3)
33 2026-10-04 R-11: эмулятор читает обычные переменные окружения через собственные настройки (SCAN_HOTFOLDER_PATH, SCAN_INPUT_DIR, SCAN_OUTPUT_DIR, SCAN_FIXTURES_DIR, SCAN_EMULATOR_DELAY, SCAN_EMULATOR_POLL_SECONDS), а не Settings шлюза; process_once синхронный, delay спится один раз за проход, ответ пишется атомарно (.part + rename), затем входящий файл переименовывается в .done [запись ответа — частично заменено №62: набор целиком через временный каталог] контейнеру эмулятора не нужны переменные MinIO/NATS нет
34 2026-10-04 Задача 4.3: без аргументов from_env подмешивает .env/ENV_FILE под os.environ (чтобы make emulator работал с api/.env); сообщение «нет пары» — <имя без uuid-префикса, с расширением>: нет эталонного ответа в фикстурах; битая фикстура -> ответ INVALID_DOCUMENT с сообщением о повреждённой фикстуре, вход получает .done; нет Input/ — не ошибка, нет корня при старте — код выхода 2, нет каталога фикстур — warning удобство dev; защита от зацикливания нет
35 2026-10-04 R-12: Normalizer.normalize(raw: object) получает уже разобранный JSON ответа ИС; json.loads и отображение ошибки разбора в INVALID_FORMAT — на стороне воркера (D-7); NullNormalizer возвращает INVALID_FORMAT для не-объекта JSON, пробрасывает код ошибки ИС, если он входит в ErrorCode контракта (регистр значим), иначе UNKNOWN_ERROR с исходным кодом в сообщении; пустой message = отсутствует (текст по умолчанию) [частично заменено №57, №60] D-5 писал raw: dict, а «не JSON» из плана 5.1 нельзя разобрать, не имея байтов если воркер захочет отдать декодирование нормализатору — перенести json.loads
36 2026-10-04 Задача 5.1: NormalizeOutcome (pydantic, frozen) — инвариант: done/needs_check требуют непустой documents, error запрещает documents, error обязателен тогда и только тогда, когда status = error повторяет minItems: 1 схемы recognition-result для успешных статусов; ошибка конструктора раньше, чем ошибка сборки result.json нет
37 2026-10-04 R-13: PUBLISH_RETRIES=3 — число повторов после первой попытки (до 4 попыток всего); PublishFailed.attempts — фактическое число попыток; tabular-parser должен толковать так же буквальный текст спеки «повторить до трёх раз» и D-7 одна попытка больше/меньше
38 2026-10-04 Задача 5.2: если задан только один из NATS_SEED/NATS_JWT — NatsConfigError (как в arm-core); шаблонный nats_callback.py удалён; ошибки S3: S3ObjectNotFoundError ⊂ S3ReadError ⊂ S3StorageError, S3WriteError; после publish/subscribe вызывается flush; connect() блокирует до доступности NATS — вызывать в фоновой задаче fail-fast на неполные учётные данные; без flush nats-py буферизует публикации при переподключении нет
39 2026-10-04 R-14: исключения hotfolder.poll логируются и опрос продолжается до дедлайна; если все попытки опроса до дедлайна завершились исключением — RECOGNITION_FAILED («горячая папка недоступна»), иначе TIMEOUT ошибки SMB (в т.ч. не-ENOENT) переходящие выбор кода при постоянной ошибке
40 2026-10-04 R-15: ValueError из inbox_name (непригодное original_name) -> RECOGNITION_FAILED «недопустимое имя файла» в D-7 нет кода для этого случая выбор кода
41 2026-10-04 R-16: запрос не по схеме / MAJOR ≠ 1 / pipeline ≠ image -> SCHEMA_MISMATCH без ack; конверт ошибки публикуется только если из сырого JSON извлекаются валидные job_id и file_id (attempt из тела, если целое ≥ 1, иначе 1), иначе — лог и отбрасывание схема конверта требует job_id/file_id; ядро закроет задание по reply_deadline ядро не узнает об ошибке неразбираемого тела
42 2026-10-04 R-17: ключ дедупликации — job_id (по спеке); повторный запрос публикует ack с attempt повторного запроса, второй файл не пишется; LRU размером DONE_CACHE_SIZE спека, D-2, OQ-15 более высокий attempt того же job_id не перезапускает обработку
43 2026-10-04 R-18: ошибка записи raw/{job_id}.json -> UNKNOWN_ERROR без raw_s3_key; ответ не JSON — сырые байты сохраняются как есть, затем INVALID_FORMAT с raw_s3_key; исключение нормализатора -> UNKNOWN_ERROR с raw_s3_key [частично заменено №55–56: файл ответа не JSON даёт parse_error в склейке, INVALID_FORMAT — только если ошибочны все файлы] D-4, D-7, сценарий «Нормализатор упал» нет
44 2026-10-04 R-19: один бакет (глобальное ограничение): request.s3.bucket ≠ настроенного -> FILE_READ_ERROR после ack; raw/ и results/ — в настроенном бакете «один бакет S3» развёртывания с несколькими бакетами отвергаются
45 2026-10-04 R-20: дедлайн = min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS), старт — момент записи файла в горячую папку (после получения слота); on_request не блокируется: валидирует, регистрирует job_id и запускает задачу задания (ack -> семафор -> обработка) ожидание слота входит в reply_deadline через min; колбэк NATS должен возвращаться быстро (T11.4) нет
46 2026-10-04 Задача 5.3 (подробно — 15 решений в отчёте исполнителя): задание, получившее слот после reply_deadline, отвечает TIMEOUT без записи файла и чтения S3 (ack уже опубликован); ответ ИС, не являющийся JSON, перечитывается через SCAN_POLL_SECONDS и принимается, когда стал JSON или перестал меняться (на дедлайне — последний прочитанный; затем INVALID_FORMAT); дедупликация в двух структурах (_active не вытесняется, _done — LRU DONE_CACHE_SIZE); SCHEMA_MISMATCH не регистрирует job_id; error.message ≤ 4000 символов, суррогаты заменяются; raw/ пишется с application/json (не-JSON — application/octet-stream); нормализатор вызывается через to_thread; stop() не публикует конверты отменённых заданий (D-2); сбой SMB-сессии виден через healthy() (listdir с теми же учётными данными), без флага; проба hotfolder: таймаут 2 с, кэш 5 с, одна проба за раз; NATS подключается в фоновой задаче lifespan, ошибка connect/subscribe -> nats = down до перезапуска; порядок остановки: отмена подключения -> worker.stop() -> drain NATS -> S3 -> SMB; /healthcheck всегда 200 [в части перечитывания не-JSON ответа заменено №56] экономия ИС; недописанный ответ на шаре без атомарного rename; ограничения размера публикации поздний результат отброшен; неподвижная ошибка NATS требует перезапуска пода; зависший poll по SMB (до 60 с) не прерывается дедлайном (T12.2)
47 2026-10-04 Задача 5.4: общая обвязка тестов воркера — api/tests/worker_helpers.py и api/tests/unit/conftest.py; ответ ИС для освобождения одного слота пишет тест (не process_once); отсутствие утечки слотов проверяется поведением (повторный start()); отмена воркера во время to_thread(hotfolder.put) не прерывает запись в потоке (файл уже в ИС, конверта нет, D-2) эмулятор ответил бы на все четыре файла сразу; образец arm-core возможна повторная отправка в ИС после рестарта (OQ-15)
48 2026-10-04 Задача 6.1: интеграционный тест поднимает настоящий lifespan create_app и эмулятор в процессе теста против compose NATS/MinIO (tmp-каталог горячей папки); синтетический успешный ответ ok_doc.json строится по форме §11 05-domain-rules.md; сценарии 1–2 пропускаются без ../fixtures, недоступная инфраструктура — падение; тест ждёт подписку шлюза по фоновой задаче nats-connect; ResourceWarning aiohttp о теле > 1 МиБ отфильтрован в одном модуле (загрузка скана 1,29 МБ) [заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76] контейнера scan-emulator ещё нет (R-7); fixtures не содержат успешных ответов (R-10) интеграционный тест может забрать чужие запросы из общей queue group scan-gateway на общем NATS (T14.1)
49 2026-10-04 R-21: финальная волна исправлений = ровно список финального ревью (C-1, I-1, T12.1, T12.4, T7.3+T7.5, T2.2, неточности документов, строки в предложении для docs); I-2 (compose-фрагмент не применён) и I-3 (replay с attempt+1 для job_id из LRU) оставлены человеку второй волны нет; I-2 и I-3 меняют спеку/другой репозиторий остальные находки — follow-up в open-questions-proposal.md
50 2026-10-04 C-1: пакет src не создаёт приложение при импорте (api/src/__init__.py без импортов; run_server.py импортирует src.app); эмулятор стартует без OPENBAO__* и завершается кодом 2 при отсутствии корня горячей папки R-11: контейнеру эмулятора не нужны настройки шлюза; обнаружено финальным ревью, тесты работали с полным .env.test любой код, импортировавший src:app, должен перейти на src.app:app
51 2026-10-04 I-1: make up после up -d --wait postgres nats minio выполняет run --rm minio-init (идемпотентно) на свежем томе бакет arm-ka иначе не создаётся и make check-full падает на NoSuchBucket tabular-parser — тот же пробел (чужой репозиторий, в предложении)
52 2026-10-04 T12.1: SCHEMA_MISMATCH не публикуется для job_id (нормализованного через uuid.UUID), который в работе или в LRU тот же Nats-Msg-Id, что у настоящего результата, привёл бы к потере настоящего результата при дедупликации ядром повторный битый запрос того же задания остаётся без ответа (лог warning)
53 2026-10-04 T7.3: inbox_name укорачивает основу имени так, чтобы .<job_id>_<имя>.part ≤ 240 байт UTF-8 (по code points, расширение ≤ 16 символов сохраняется, surrogatepass); put подавляет OSError при удалении .part ENAMETOOLONG на ext4/NTFS давал ложный RECOGNITION_FAILED на допустимое имя; имя для ИС значения не имеет эмулятор не найдёт фикстуру по усечённому имени (> ~200 байт — нереалистично)
54 2026-10-04 Готовность набора ответов: poll отдаёт список, когда есть хотя бы один *.json и набор имён и размеров *.json не менялся SCAN_SETTLE_SECONDS (2); таймаут задания по-прежнему min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS), ненастоявшийся набор на дедлайне — TIMEOUT OQ-78 (решение человека: читать все json, готовность — по стабилизации) не задаёт момента готовности; ИС кладёт файлы без общего маркера завершения ИС пишет набор с паузой > 2 с — часть документов потеряна, повторов нет (OQ-15); значение настраивается
55 2026-10-04 Склеенный ответ {"job_id", "files": [{"name", "response", "parse_error"}]}, файлы по возрастанию имени, в files — все *.json каталога (другие игнорируются); raw/{job_id}.json = склеенный ответ; raw_s3_key остаётся одной строкой, контракт v1.0 не меняется OQ-78 требует «склеить в один ответ»; структура — наша; сохранение имён файлов нужно для разбора инцидентов и metrics.warnings Срез 3 нормализатор читает склеенную структуру, а не одиночный ответ ИС — единственный вход, переделка локальна
56 2026-10-04 R-24 (design #56): файл, не разобранный json.loads после настаивания набора, не перечитывается и не роняет задание: response = null, parse_error заполнен; при ошибочности всех файлов — INVALID_FORMAT набор уже стабилен — повторное чтение не изменит файл; перечитывание «не-JSON» из решения №46 имело смысл для единственного файла без атомарного rename и заменено settle недописанный, но стабильный 2 с файл будет принят как parse_error, а не дождётся конца записи
57 2026-10-04 Успех файла — error.code == ""; ошибочный — непустой error.code либо parse_error; отсутствующий/null error ошибкой не считается; не-объект на корне склейки → INVALID_FORMAT; код вне перечня контракта → UNKNOWN_ERROR с исходным кодом в сообщении (R-12 сохранён) образцы aep_3.json, aep_6.json (OQ-77): у успеха error = {"code": "", "message": ""}; проверка error != null принимала успех за ошибку ИС, возвращающая error строкой/другого типа, будет трактоваться по правилу «нет error.code — успех»
58 2026-10-04 Сводка по файлам в NullNormalizer: все файлы ошибочны → status = error, error.code/message первого ошибочного файла по имени (для parse_error — INVALID_FORMAT); есть хотя бы один успешный → error.code = NOT_IMPLEMENTED; каждый ошибочный файл — строка <имя>: <код>: <сообщение> в metrics.warnings в обоих случаях OQ-78: ошибка одного документа не отменяет остальные; NOT_IMPLEMENTED — единственный честный статус, пока нормализации нет (Срез 3); metrics.warnings уже есть в контракте (array<string>), расширять контракт не нужно потребитель, ждущий error.code ошибочного файла при частичном успехе, увидит NOT_IMPLEMENTED — до Среза 3 различия не нужны
59 2026-10-04 SCAN_TMP_DIR (Tmp) — служебный каталог на корне горячей папки; .part создаётся в <корень>/Tmp/, каталог создаётся при записи, rename в Input/<name>; обе реализации (LocalHotFolder, SmbHotFolder) OQ-79 (решение человека: временный файл — вне Input); корень остаётся единственной шарой, чтобы rename был атомарным Tmp на другой ФС/шаре — rename не атомарен; ИС с маской, захватывающей Tmp, — неизвестно, проверить на стенде
60 2026-10-04 Normalizer.normalize(raw) получает склеенный ответ (D-4), а json.loads каждого файла выполняет функция склейки merge_answers (на стороне воркера); воркер больше не отображает «не JSON» в INVALID_FORMAT сам — это делает нормализатор по parse_error R-12 писал вход как «ответ ИС» (один файл); с несколькими файлами решение «ошибка или нет» принимается по набору нет
61 2026-10-04 Правило выбора фикстур эмулятора: <name>.json и <name>_*.json (не префикс <name>*); имена в каталоге ответа — <uuid>.json и <uuid>_<suffix>.json; aep_8.pdf → aep_8_form4.json покрывается <name>_* <name>* забрал бы aep_30* при запросе aep_3; набор из нескольких файлов нужен для aep_3.json + aep_3_ofr.json фикстура с именем вида <name>-x.json не будет найдена
62 2026-10-04 R-26 (design #62): набор ответов эмулятор записывает во временный каталог и переименовывает каталог в Output/<uuid>/ целиком; «готовность по settle» в шлюзе проверяется отдельным тестом (файл дописывается во время опроса) шлюз не должен видеть частичный набор от эмулятора; поведение settle всё равно нужно тестировать на подмене rename каталога на нестандартной ФС (compose-том) может быть неатомарным — тогда набор виден по частям, settle закрывает
63 2026-10-04 R-22 (design #63): задача 7.1 переходно адаптирует вызов poll в воркере и фейки в тестах к list[AnswerFile]; вся склейка и нормализация — задача 7.2 poll меняет тип результата, воркер не должен ломаться между задачами небольшой переходный код, удаляемый в 7.2
64 2026-10-04 R-23 (design #64): существующие тесты воркера, таймаута и параллелизма ставят SCAN_SETTLE_SECONDS=0; settle тестируется отдельно с внедряемыми часами не удлинять тесты на 2 с каждый и не делать их зависимыми от реального времени если 0 не отключает проверку стабилизации полностью, тесты таймаута флапают
65 2026-10-04 R-25 (design #65): NormalizeOutcome получает необязательное поле warnings, которое воркер кладёт в metrics.warnings конверта; поле опускается, когда ошибочных файлов нет metrics.warnings в контракте есть (array<string>), а нормализатор — единственное место, знающее об ошибках по файлам внутренний тип, контракт v1.0 не меняется; Срез 3 может заменить
66 2026-10-04 R-28 (design #66): ответ эмулятора «нет пары» — error.code = INVALID_DOCUMENT и data_ocr — объект с пустыми полями (label/organization/inn/units/date — {form_name: "", confidence: 0}, table_structure: []) как у реальных ответов-ошибок aep_5.json, aep_8_form4.json, а не [] OQ-77: data_ocr — объект; эмулятор должен воспроизводить реальную форму нет
67 2026-10-04 R-29 (design #67): готовность набора по settle отсчитывается от первого опроса, при котором наблюдается окончательный набор (не по mtime); сценарий «Ответ из одного файла»: набор передаётся не позднее SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS после этого опроса часы SMB-сервера и шлюза могут расходиться, mtime ненадёжен; внедряемые монотонные часы делают поведение тестируемым; прежняя формулировка «не позднее SCAN_POLL_SECONDS» недостижима без mtime (Concern Task 2) ответ приходит до settle + poll позже стабилизации набора (боевые 2 + 5 с)
68 2026-10-04 Решения исполнителя Task 2 (7.1): состояние settle ограничено 1024 заданиями с вытеснением самых давних и сбрасывается при выдаче набора или пустом каталоге, forget в протокол HotFolder не входит; *.json сравнивается без учёта регистра; dot-файлы с расширением .json входят в набор; settle — от первого наблюдения в этом процессе; файл изменился между листингом и чтением (размер) или исчез → None и набор настаивается заново; в put Tmp создаётся раньше Input; сигнатуры: tmp_dir — четвёртый позиционный, settle_seconds/clock — только именованные, публичные answer_dir, settle_seconds, pending_jobs, answer_path сохранён ИС на Windows/SMB (маски регистронезависимы); память не растёт от заданий, ушедших по таймауту; не трогать протокол и фейки ради редкого случая; защита от гонки «листинг → чтение» вытесненное активное задание настаивается заново (ответ позже на settle); служебный X.JSON или .tmp.json ИС попадёт в набор
69 2026-10-04 R-30 (design #69): metrics.warnings конверта ограничено: не больше 100 строк (MAX_WARNINGS) по 1000 символов (MAX_WARNING_CHARS, clean_text: суррогаты заменяются, длинная строка обрезается с «…»); при избытке первые 99 строк + сводная «ещё предупреждений: N; все ошибки файлов — в raw/{job_id}.json» конверт NATS ≤ 1 МБ при любом числе файлов и длине сообщений ИС (как T10.1 для error.message); полные данные остаются в сыром ответе потребитель не увидит в конверте ошибки файлов сверх 99-го и хвост длинного сообщения — только в raw/
70 2026-10-04 R-31 (design #70): склейка — src/gateway/merge.py (merge_answers, MergedResponse/MergedFile — TypedDict, т. е. обычный dict); тот же объект пишется в raw/{job_id}.json (dump_merged: UTF-8 с читаемой кириллицей, при одиночном суррогате — ASCII-экранирование, всегда application/json) и передаётся нормализатору; parse_error — <тип исключения>: <текст> ≤ 300 символов; merge_answers сам сортирует файлы по имени склейка — транспортная часть (вход — файлы горячей папки), не зависит от реализации нормализатора, которую подменит Срез 3; нормализатор видит ровно то, что сохранено в raw/ Срез 3 может захотеть типизированную модель вместо dict — правка локальна
71 2026-10-04 R-32 (design #71): крайние случаи NullNormalizer: error.code не строка и не null/"" (число, список, true, 0) — ошибка UNKNOWN_ERROR, в warnings — repr кода; пустое/отсутствующее сообщение ошибки ИС — «ИС Сканирование вернула ошибку <код> без сообщения» (и в error.message, и в warnings); parse_error — сообщение «ответ ИС не разбирается как JSON: <parse_error>»; вход не dict или files не список — INVALID_FORMAT «склеенный ответ ИС не соответствует структуре…»; files пуст — INVALID_FORMAT «в ответе ИС нет ни одного файла»; элемент files не объект или без непустого name — ошибочный файл files[<i>] с INVALID_FORMAT; «первый по имени» — первый в порядке files (склейка уже отсортировала) спека задаёт только «непустой error.code»; нормализатор не бросает ни на каком входе; Срез 3 заменит реализацию ИС, кодирующая успех как 0/false, будет понята как ошибка UNKNOWN_ERROR (реальные образцы — строка "")
72 2026-10-04 R-33 (design #72): warnings исхода дописываются в конец metrics.warnings исхода; итоговые metrics одинаковы в конверте и в файле результата (done/needs_check); пустой список не порождает metrics одно место правды для предупреждений; файл результата и конверт не расходятся нет
73 2026-10-04 T2.1, T2.2, T2.4 (design #73): поправки по ревью Task 2 — T2.1: после чтения набора каталог ответа листается повторно (Local и SMB), снимок отличается от настоявшегося → None (набор настаивается заново); T2.2: SmbHotFolder — ENOTDIR из листинга Output/<job_id> → None; T2.4: тест сброса состояния settle при ENOENT/SHARING_VIOLATION/ENOTDIR файл, появившийся или пропавший за время чтения, иначе дал бы неполный или устаревший набор; поведение SMB = Local один лишний листинг на каждый выданный набор
74 2026-10-04 R-34 (design #74): интеграционный tests/integration/test_roundtrip.py минимально переведён на формат склейки raw/ и успех error = {"code": "", "message": ""} (без новых сценариев — они в 7.4) иначе он проверял бы старый формат raw/; полноценная переработка — задача 7.4 не прогнан: в поднятом compose нет бакета arm-ka (NoSuchBucket), проверка — 7.4 после make up
75 2026-10-04 R-35 (design #75): решения исполнителя Task 4 (7.3): эмулятор читает SCAN_TMP_DIR (по умолчанию Tmp) через EmulatorSettings.tmp_dir, как шлюз, не завися от настроек шлюза; набор собирается в <root>/<SCAN_TMP_DIR>/.<uuid>.answer и целиком переименовывается в Output/<uuid>/; остаток прерванной сборки удаляется, ответ с тем же uuid, уже лежащий в Output, заменяется (rmtree + rename); повреждённая фикстура набора даёт ответ-ошибку INVALID_DOCUMENT на своём месте, остальные файлы набора остаются; фикстуры с одним suffix (a.JSON/a.json) — берётся первая по имени; aep_8.pdf теперь даёт <uuid>_form4.json, а не <uuid>.json (прежнее правило <name>*) spec scan-emulator: набор появляется атомарно, rename каталога возможен только в пределах одной ФС, служебная папка корня уже есть у шлюза (Tmp) повторная публикация uuid на миг убирает каталог; потребители, ждавшие aep_8 как <uuid>.json, должны брать набор целиком
76 2026-10-04 R-36 (design #76): решения исполнителя Task 5 (7.4): интеграционный test_roundtrip.py с SCAN_SETTLE_SECONDS=0.2 и SCAN_POLL_SECONDS=0.1 (.env.test держит settle = 0 для остальных тестов); эмулятору теста отдаются копии только aep_3.json, aep_3_ofr.json, aep_5.json, aep_6.json и синтетический ok_doc.json (не весь fixtures/ocr); сценарий aep_5 берёт реальное имя aep_5.PDF (расширение в верхнем регистре) и проверяет, что единственный ошибочный файл попадает и в итоговую ошибку, и в metrics.warnings (D-5); проверка raw/ общая для всех сценариев: имена файлов склейки = все *.json каталога ответа по возрастанию, тела совпадают с файлами; в сценарии unknown устаревшее ожидание data_ocr = [] заменено на объект с table_structure: [] (R-28) — расхождение обнаружено первым прогоном (в Task 4 тест не запускался, №74); T3.3: D-5 уточнён — не строковый, не null/"" error.code (число, список, true) — тоже ошибка файла с UNKNOWN_ERROR (R-32); T4.1: сквозной тест TestAnswerSet на реальных фикстурах помечен fixtures тест должен проверять то, что есть в коде и спеке, а не прежнюю форму ответа; settle 0.2 с не удлиняет тест заметно; полная копия fixtures/ocr подтянула бы лишние наборы тесты fixtures пропускаются без репозитория ../fixtures; интеграционные тесты делят NATS и queue group scan-gateway с другими экземплярами шлюза
77 2026-10-04 R-27 (design #77): уборка после сквозной проверки Task 6 (7.5) — docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka --profile services rm -sfv только своих сервисов (scan-gateway, scan-emulator, arm-core, arm-core-migrate, одноразовый minio-init) и удаление тома arm-ka_hotfolder после проверки, что его не использует ни один контейнер, вместо down -v из плана; postgres/nats/minio и их тома не тронуты проект compose arm-ka общий для всех worktree: down -v удалил бы и тома postgres-data/minio-data с данными другой работы человек хотел полный сброс — одна команда docker compose --profile services down -v из docs/dev
78 2026-10-04 R-37 (design #78): состав и сборка сервисов в Task 6 (7.5): up -d --build arm-core scan-gateway scan-emulator, затем up -d --wait по тем же именам; tabular-parser не поднимался (конвейер image его не использует); контекст сборки scan-gateway в compose — ../../scan-gateway от docs/dev worktree, т. е. сам worktree ветки (.wt/slice-0/scan-gateway), поэтому отдельный make docker-build не нужен; arm-core:dev пересобран из worktree arm-core (кэш, ID образа не изменился) проверяется шлюз; лишний сервис только добавил бы шум на шине; правка compose не требовалась если tabular-parser нужен для проверки чего-то ещё — отдельный прогон
79 2026-10-04 R-38 (design #79): после Task 6 тестовые строки БД оставлены (legal_entity ИНН 7700000001, четыре s3_object/stored_file/recognition_job — id в отчёте Task 6), а объекты uploads/… и raw/… в MinIO удалены (mc rm поимённо); строки s3_object поэтому указывают на несуществующие ключи по указанию контроллера (dispatch Task 6): строки БД — оставить, объекты MinIO — удалить; бакет до прогона был пуст чтение этих s3_object даст NoSuchKey; убрать — SQL в порядке FK (подставить id из отчёта Task 6: <job_id> — 015ea48c-d91f-4d90-a7d8-0d559a8739a3, 1bc7743c-d902-40c5-9009-09bda2de5f72, 0ea16933-4cc1-4b50-bda9-a97089a918c5, 9942727c-7f7d-43e8-a82d-925e0140c2e7; <file_id> — 92b342b2-2d65-489e-b252-e26569780373, 072e922f-b550-461c-9bfc-7cbb1961a46b, 61a53aaf-af6c-4b30-8651-97c03bd0ba8e, e2d4f412-ecd7-4fd3-b2e3-91693432a186; <s3_object_id> — d569c917-c764-4591-be16-8058a688abf7, 2ef2d832-d4f1-4819-9467-36bd24bf4c93, cd61b5f0-9582-4da5-bc55-77e505a5b32e, c81c8d72-9c6f-48f8-bbbb-3c6ce7a43e4f): begin; delete from dkb.recognition_job where id in (<job_id>, …); delete from dkb.stored_file where id in (<file_id>, …); delete from dkb.s3_object where id in (<s3_object_id>, …); delete from dkb.legal_entity where inn = '7700000001'; commit; (порядок: recognition_job.file_id → stored_file, stored_file.s3_object_id → s3_object, stored_file.legal_entity_id → legal_entity; stored_file.current_recognition_job_id у этих строк пуст, document/osv_entry не создавались) либо полный сброс