- spec «Ответ из одного файла»: набор передаётся не позднее SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS после первого опроса, увидевшего окончательный набор (R-29). - design D-5: сигнатура NormalizeOutcome с warnings; воркер дописывает их в metrics.warnings. - Decisions made without discussion: №67 R-29, №68 решения исполнителя Task 2, №69–72 R-30..R-33 (предел warnings, склейка в src/gateway/merge.py, крайние случаи NullNormalizer, слияние warnings с metrics), №73 T2.1/T2.2/T2.4, №74 R-34 (адаптация test_roundtrip.py). - Отметки: Task 3 в плане, 7.2 в tasks.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
60 KiB
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 == "" (реальный успешный ответ ИС несёт 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 |