Срез 0 реализован и влит (PR #3), change перенесён в openspec/changes/archive, спеки — в openspec/specs. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
30 KiB
Предложения по итогам Среза 0 scan-gateway: compose, контракт, открытые вопросы
Передача для владельца docs и человека. Файлы в ../docs этим change'ом не менялись; всё ниже — предложения.
Источник: прогон DoD (задача 6.3), progress.md контроллера, ревью задач 1-15. Маркеры (T12.5, O-1, R-13) — из ledger.
Фрагмент compose для scan-emulator и scan-gateway
Вставить в ../docs/dev/docker-compose.yml в раздел services: (после minio-init) и добавить в раздел volumes: том
hotfolder:. Профиль services: docker compose --profile services up -d --wait scan-gateway scan-emulator (в
up -d --wait указывать сервисы явно, как для инфраструктуры). Образ собирается из корня репозитория scan-gateway
(Dockerfile), эмулятор использует тот же образ (команда python -m src.scan_emulator). Эмулятору переменные
MinIO/NATS не нужны (R-11). Контекст ../../scan-gateway верен и для workspace, и для worktree .wt/<срез>/docs/dev.
# Эмулятор ИС Сканирование: тот же образ, что у шлюза; общий том hotfolder с шлюзом. Переменные MinIO/NATS не нужны.
scan-emulator:
profiles: ["services"]
build:
context: ../../scan-gateway
image: scan-gateway:dev
command: ["python", "-m", "src.scan_emulator"]
restart: unless-stopped
environment:
SCAN_HOTFOLDER_PATH: /data/hotfolder
SCAN_FIXTURES_DIR: /fixtures/ocr
SCAN_EMULATOR_DELAY: "2"
volumes:
- hotfolder:/data/hotfolder
- ../../fixtures/ocr:/fixtures/ocr:ro
scan-gateway:
profiles: ["services"]
build:
context: ../../scan-gateway
image: scan-gateway:dev
restart: unless-stopped
depends_on:
nats:
condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
environment:
OPENBAO__MINIO__MINIO_URL: minio:9000
OPENBAO__MINIO__MINIO_ADMIN_LOGIN: ${MINIO_ROOT_USER:-minioadmin}
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
OPENBAO__MINIO__MINIO_BUCKET: ${MINIO_BUCKET:-arm-ka}
OPENBAO__MINIO__MINIO_SECURE: "false"
OPENBAO__NATS__NATS_URL: nats://nats:4222
NATS_ENABLED: "true"
SCAN_HOTFOLDER_MODE: local
SCAN_HOTFOLDER_PATH: /data/hotfolder
volumes:
- hotfolder:/data/hotfolder
ports:
- "${BIND_ADDR:-127.0.0.1}:${SCAN_GATEWAY_PORT:-8002}:8000"
healthcheck:
# curl в python:3.11-slim нет; /healthcheck всегда отвечает 200, поэтому читаем поля nats и hotfolder
test:
- CMD
- python
- -c
- "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' and d['hotfolder']=='ok' else 1)"
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
И в конец файла:
volumes:
postgres-data:
minio-data:
hotfolder:
Проверено: YAML разбирается (yaml.safe_load); docker compose -f <копия> --project-directory docs/dev --profile services config -q на временной копии файла с этими сервисами — код 0 (реальный проект arm-ka не затрагивался). Образ
scan-gateway:dev запущен отдельным контейнером в сети arm-ka_default с теми же переменными окружения:
/healthcheck -> nats: ok, hotfolder: ok, команда healthcheck из фрагмента возвращает 0 (контейнер удалён).
Что искать в логах: у эмулятора при старте — Эмулятор ИС Сканирование: корень /data/hotfolder, входящий каталог /data/hotfolder/Input, исходящий каталог /data/hotfolder/Output, задержка 2.0 с, опрос 1.0 с, затем
Фикстуры: /fixtures/ocr, фикстур: 2 (при 0/«не найден» эмулятор отвечает INVALID_DOCUMENT на всё). У шлюза —
Подписка NATS активна. subject=recognition.image.request, queue=scan-gateway и Воркер scan-gateway запущен.
Имена переменных сверены с api/src/core/config.py и api/src/scan_emulator/__init__.py.
Фрагмент рабочий только после исправления C-1 (ветка feat/1-slice-0-skeleton, коммит ad3fb34): раньше
python -m src.scan_emulator импортировал пакет src, а тот создавал приложение шлюза и требовал OPENBAO__*, поэтому
контейнер эмулятора без переменных MinIO/NATS падал с ValidationError. Теперь импорт пакета src настроек шлюза не
требует; проверено на пересобранном образе: docker run --rm -e SCAN_HOTFOLDER_PATH=/nonexistent scan-gateway:dev python -m src.scan_emulator завершается кодом 2 с сообщением Эмулятор не запущен: корень горячей папки /nonexistent не существует или не каталог (не ValidationError); шлюз из того же образа стартует, /healthcheck отвечает 200.
Проверить после применения фрагмента:
docker compose --profile services up -d --wait scan-gateway scan-emulatorвозвращает код 0:scan-gatewayзависит отminio-init(service_completed_successfully), а в списке--waitодноразовогоminio-initнет (по../docs/dev/README.mdголыйdocker compose up -d --waitвозвращает 1 из-за завершившегосяminio-init);- в логе эмулятора при старте — строка о каталогах и строка о числе фикстур (формат
run()вapi/src/scan_emulator/__init__.py:Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} сиФикстуры: {}, фикстур: {}), то естьЭмулятор ИС Сканирование: корень /data/hotfolder, входящий каталог /data/hotfolder/Input, исходящий каталог /data/hotfolder/Output, задержка 2.0 с, опрос 1.0 сиФикстуры: /fixtures/ocr, фикстур: 2; scan-gateway—healthy,/healthcheckна127.0.0.1:8002даётnats: ok,hotfolder: ok.
Примечания: (а) порт хоста 8002 — по spec docs, у сервиса порт по умолчанию 8000 (SERVER__PORT), поэтому маппинг
8002:8000; (б) /healthcheck всегда отвечает 200 (пункт 9 таблицы), поэтому healthcheck читает поля nats/hotfolder;
(в) контейнеры работают от root, права на том hotfolder не настраиваются; (г) build задан у обоих сервисов с одним
image: — compose соберёт образ один раз за счёт кэша.
Предложения по контракту, OQ и документам
| # | Что | Откуда | Предложение |
|---|---|---|---|
| 1 | PUBLISH_RETRIES = число повторов после первой попытки (1+3 = 4 попытки) |
R-13 (design #37), T11.1; api/src/core/config.py |
Согласовать с tabular-parser (там может быть «3 попытки всего»); зафиксировать в 03-architecture.md одной фразой |
| 2 | 03-architecture.md («Схема schema_version»): «неизвестный MAJOR -> SCHEMA_MISMATCH и ack»; спека change'а и воркер — без ack |
R-16 (design #41), T12.13 | Привести документ в соответствие (ack не публикуется, конверт error — если из тела извлекаются job_id и file_id) либо пересмотреть правило и воркер: ack на отвергнутый запрос защищает ядро от зацикливания повторов доставки, цена — лишнее сообщение и расхождение с R-16 |
| 3 | Решено человеком 04.10.2026 (OQ-76, частично): исходно — реальные фикстуры fixtures/ocr/aep_5.json, aep_8_form4.json были ответами-ошибками ИС (error.code = INVALID_DOCUMENT, data_ocr — объект с пустыми полями и table_structure: []). Добавлены реальные успешные образцы: aep_3.pdf + aep_3.json (успех: «Бухгалтерский баланс» 42 строки + «Отчет о финансовых результатах» 18 строк) + aep_3_ofr.json (второй ответ ИС по тому же файлу, INVALID_DOCUMENT), aep_6.pdf + aep_6.json (успех: баланс 122 строки с шапкой дат + ОФР 49 строк); aep_5.json, aep_8_form4.json — ответы-ошибки |
O-1, R-10 (design #32); OQ-76 | Транспорт Среза 0 проверяется на реальных образцах (склейка, NOT_IMPLEMENTED, metrics.warnings), синтетический успешный ответ не нужен; разбор успешных ответов — Срез 3 (нормализатор, OQ-13/14) |
| 4 | Решено человеком 04.10.2026 (OQ-77): исходно — §11 05-domain-rules.md описывал data_ocr[] (список), а в фикстурах data_ocr — объект. Факт по aep_3.json, aep_6.json: data_ocr — объект, один на json-файл (label, organization, inn, units, date — {form_name, confidence} с confidence 0–100; table_structure — список таблиц {label, rows[]}; в одном json может быть несколько таблиц разных форм (в aep_3.json и aep_6.json — баланс и ОФР), у каждой свой label; тип документа задаёт label; таблицы не интерпретируются в Срезе 0 (Срез 3)); успех — error = {"code": "", "message": ""} (не null), ошибка — непустой code и пустой table_structure. §11 исправлен в docs |
O-1, T3.2; OQ-77 | В Срезе 0 успех файла определяется как error.code == "" (design #57); разбор таблиц — Срез 3 |
| 5 | Решено человеком 04.10.2026 (OQ-78): исходно шлюз ожидал единственный файл <output>/<job_id>/<job_id>.json, старый код читал все файлы каталога Output/<id>/. Факт: ИС кладёт все распознанные документы одновременно в каталог <job_id>, по одному json на документ (aep_3.json + aep_3_ofr.json); шлюз читает все *.json каталога и склеивает в один ответ; готовность — по стабилизации набора (SCAN_SETTLE_SECONDS, 2 с); ошибка одного документа не отменяет остальные (metrics.warnings) |
T8.5; OQ-78 | Реализуется доработкой PR #3 (tasks 7.x, design #54–#58); подходящее значение settle и поведение ИС на реальной шаре — проверить на стенде в Срезе 3 |
| 6 | Решено человеком 04.10.2026 (OQ-79): исходно временный файл .<имя>.part лежал в Input (R-5), и ИС без маски могла забрать недописанный файл. Решение: временный файл уходит в служебный каталог SCAN_TMP_DIR (по умолчанию Tmp) рядом с Input/Output, на той же шаре/ФС, затем rename в Input (атомарно) |
R-5, T7/T8; OQ-79 | Реализуется доработкой PR #3 (tasks 7.x, design #59); Tmp должен лежать на той же шаре, что Input; поведение ИС на реальной шаре — проверить в Срезе 3 |
| 7 | Сценарий «NATS перезапущен -> подписка восстановлена за <= 20 с» не автоматизирован: общий NATS нельзя перезапускать в проверках | T11.7 | Ручная проверка на отдельном nats-server (поднять, подключить шлюз, убить, поднять снова) либо отдельный compose-профиль для тестов |
| 8 | Штатная остановка не публикует конверты для принятых (ack) заданий; D-2 допускает потерю при падении, при rolling deploy задание теряется до reply_deadline |
T12.5, D-2 | Публиковать UNKNOWN_ERROR «сервис остановлен» в stop() для всех принятых заданий; Срез 3+ |
| 9 | /healthcheck всегда 200, даже если NATS постоянно недоступен (nats: down) |
T12.10 | Платформе (readiness/liveness) читать поле nats/hotfolder, а не код ответа; либо отдельные /ready и /live |
| 10 | ../docs/dev/README.md устарел относительно шлюза: «у scan-gateway Makefile пока нет», nats = off (шлюз показывает disabled), порт 8002 против 8000, шлюз требует SCAN_HOTFOLDER_* и не использует OPENBAO__DATABASE__* |
Task 3, O-a | Обновить раздел «Запуск сервисов из venv» и «Интеграционные тесты»; добавить профиль services (фрагмент выше); make up/make check-full/make emulator в scan-gateway есть |
| 11 | Интеграционные тесты шлюза и arm-core делят общий NATS: in-process шлюз подписывается на queue group scan-gateway на реальном subject и может забрать запросы тестов arm-core, идущих параллельно (и наоборот) |
T14.1, T11.8 | Сделать queue group и/или subject-префикс настраиваемыми для тестов (например NATS_TEST_PREFIX), либо отдельный NATS на тесты; пока — не гонять make test-integration двух сервисов одновременно |
| 12 | Пример contracts/examples/request-image.json имеет reply_deadline в прошлом (2026-10-03T12:45:00Z) |
Task 3, O-e | Для ручного прогона подставлять будущий срок (иначе сразу TIMEOUT); в README контракта отметить, либо сделать пример с датой далеко в будущем |
| 13 | DEBUG в настройках шлюза не используется (оставлен шаблоном; raisa_* не импортируются) |
Task 3 | Принято; убрать вместе с остатками шаблона или оставить для единообразия с arm-core |
| 14 | ResourceWarning aiohttp при загрузке > 1 МиБ (miniopy-async) скрыт фильтром в одном модуле тестов; при ручном прогоне со скриптом тоже виден Unclosed client session |
T14.5 | Если сырые ответы ИС будут > 1 МиБ — пересмотреть фильтр/закрывать сессию клиента явно |
| 15 | Зависший poll/put по SMB (до 60 с) не прерывается дедлайном; нет connection_timeout у smbclient |
T12.2, T8.4, design #46 | Задать connection_timeout при регистрации сессии и ограничить вызовы wait_for остатком до дедлайна; проверить на стенде |
| 16 | Порт шлюза на хосте задаётся ${SCAN_GATEWAY_PORT:-8002} (фрагмент выше), но в ../docs/dev/.env.example и в таблице переменных ../docs/dev/README.md его нет |
T16.1 | Владельцу docs: добавить SCAN_GATEWAY_PORT=8002 в ../docs/dev/.env.example, строку в таблицу переменных ../docs/dev/README.md (рядом с MINIO_CONSOLE_PORT) и 8002 — в список свободных портов хоста |
| 17 | Replay с attempt + 1 для job_id, который есть в LRU процесса, получает только ack и не обрабатывается (R-17, spec «обработан этим процессом»); по ../docs/context/03-architecture.md replay — «сброс в queued с attempt + 1». На единственном инстансе такое задание повисает до reply_deadline -> timeout |
I-3, R-17 | При одобрении: LRU _done с ключом (job_id, attempt) — более высокий attempt готового задания обрабатывать заново; _active — по job_id (повтор во время обработки — только ack). Решает человек |
| 18 | make up в tabular-parser (../tabular-parser/Makefile, цель up) тоже не запускает minio-init: на свежем томе бакета нет |
I-1 | Чужой репозиторий — только сообщить владельцу; в scan-gateway исправлено (run --rm minio-init после up -d --wait) |
| 19 | Расхождения и наблюдения контроллера при исполнении (Observation в progress.md) |
progress.md |
См. ниже |
| 20 | Сквозная проверка 7.5 (Task 6, 04.10.2026) на профиле services: aep_3.pdf → error NOT_IMPLEMENTED + metrics.warnings про <job_id>_ofr.json, aep_6.pdf → error NOT_IMPLEMENTED без metrics, aep_5.PDF → error INVALID_DOCUMENT + одна строка warnings; ../docs/dev/docker-compose.yml функциональных правок не требует (только устаревший комментарий, п. 11): умолчания SCAN_TMP_DIR=Tmp и SCAN_SETTLE_SECONDS=2 подходят (Tmp создаётся шлюзом в томе hotfolder, после прогона пуст). Устарел раздел «Проверка окружения» ../docs/dev/README.md (сценарий 2 и абзац «не покрывает») |
Task 6, R-37 (design #78) | Владельцу docs, в ../docs/dev/README.md: (1) строки 220–221 заменить на # [2026-10-04 11:23:58 UTC] 600B STANDARD 0ea16933-4cc1-4b50-bda9-a97089a918c5.json и dmc cat "local/arm-ka/raw/$JOB.json" # {"job_id": "<job_id>", "files": [{"name": "<job_id>.json", "response": {"file_uid": "<job_id>", "error": {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."}, "data_ocr": {…}}, "parse_error": null}]}; (2) в строке 226 лог эмулятора — <job_id>_aep_5.PDF -> /data/hotfolder/Output/<job_id> (каталог набора, не файл); (3) строки 227–229 («успешный путь image … не проходит — нужен успешный ответ ИС») заменить на: «Успешные ответы ИС есть (aep_3.json + aep_3_ofr.json, aep_6.json); шлюз склеивает все *.json каталога ответа и отвечает NOT_IMPLEMENTED (нормализация — Срез 3), ошибочные файлы набора — в metrics.warnings»; (4) строку 230 заменить на «processing виден ~10 с: задержка эмулятора 2 с, затем до SCAN_POLL_SECONDS + SCAN_SETTLE_SECONDS (5 + 2 с) после первого опроса, увидевшего набор»; (5) в строке 255 «у ошибок scan-gateway recognition_job.metrics пустой» заменить на «у ошибок scan-gateway в recognition_job.metrics только warnings — по строке <имя файла>: <код>: <сообщение> на ошибочный файл ответа ИС, если такие есть»; (6) после сценария 2 добавить сценарий «2а. image → успешный ответ ИС → NOT_IMPLEMENTED»: new_job image PDF ocr/aep_3.pdf && poll 60 → # 11:22:07 queued / # 11:22:08 processing / # 11:22:18 error NOT_IMPLEMENTED; dpsql -c "select status, error_code, metrics from dkb.recognition_job where id = '$JOB'" → `# error |
Строки Observation из progress.md (только пересказ, статусы — в ledger):
- O-1 — [закрыто 04.10.2026: образцы
aep_3.json,aep_6.json(OQ-76), пункты 3 и 4 таблицы]. Исходно: обе реальные фикстуры (aep_5.json,aep_8_form4.json) сами являлись ответами-ошибками ИС (INVALID_DOCUMENT,data_ocr— объект, не список), поэтому путьNOT_IMPLEMENTEDпроверялся синтетическим ответом (R-10, design №32, заменено D-9); теперь он проверяется на реальных успешных ответах. - O-2 — исполнитель T11 вопреки инструкции поставил трейлер харнесса; история локальных коммитов переписана
(
filter-branch), в remote ничего не уходило. Процессное, к docs не относится. - O-a..O-e (по итогам Task 3) —
docs/dev/README.mdустарел (пункт 10); порт 8002 против 8000 (пункт 10 и примечание к compose);design.mdD-9 описывал compose-интеграцию эмулятора — исправлено до R-7 (e228635);check-fullбез docker-сборки — закрыто задачей 15;DEBUGне используется (пункт 13);reply_deadlineв примере в прошлом (пункт 12).
Дополнительно по итогам прогона DoD (реальные процессы шлюза и эмулятора, SIGTERM):
- шлюз при SIGTERM корректно завершает lifespan (
Приложение закрывается,Соединение с NATS закрыто), но код выхода процесса — 143 (uvicorn повторно поднимает полученный сигнал); для оркестраторов это штатно, скрипты, ожидающие 0, должны учитывать; - кэш
hotfolderв/healthcheck— 5 с: после возврата каталога поле остаётсяdownдо 5 с.
Что осталось человеку
- Просмотреть и влить PR ветки
feat/1-slice-0-skeleton(merge — только по явной команде); выводmake check-full— в описании PR (.superpowers/sdd/2026-10-03-slice-0-skeleton/make-check-full.log, локальный). - После merge:
openspec archive slice-0-skeleton, коммит и push архива в main (чеклист закрытия). - Перенести compose-фрагмент выше в
../docs/dev/docker-compose.yml(и обновитьREADME.md, пункт 10) — отдельным PR репозиторияdocs. - Перенести правки раздела «Проверка окружения»
../docs/dev/README.mdпо итогам сквозной проверки 7.5 (пункт 20) — тем же PR репозиторияdocs; compose правки не требует. - Согласовать с владельцем tabular-parser семантику
PUBLISH_RETRIES(пункт 1) и решить судьбу правила проSCHEMA_MISMATCH+ ack (пункт 2). - Пункты 3-6 (OQ-76/77/78/79: образцы успешных ответов, формат
data_ocr, состав файлов ответа, временный файл) решены человеком 04.10.2026 и внесены вdocs/context/07-open-questions.md; пункты 7, 8, 15 — в follow-up issue (Follow-ups from #1). - Не архивировать change
slice-0-skeleton, пока compose-фрагмент не применён вdocsи не проверена командаdocker compose --profile services up -d --wait scan-gateway scan-emulator(I-2). - Решить I-3 (пункт 17: replay с
attempt + 1для задания из LRU процесса). - Определить приоритет follow-up по T12.5 (пункт 8): при каждом деплое задания в работе превращаются в
окончательный
timeout.