Срез 0 (issue #1): раздел «Соглашения шаблона» (что оставлено от шаблона команды, что вырезается, что добавляется); change openspec/changes/slice-0-skeleton — proposal, design с таблицей «Decisions made without discussion», спеки и tasks; в openspec/config.yaml строки с двоеточием взяты в кавычки (YAML разбирал их как словари). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
11 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[]), таймаут
5 мин без повторов (ТЗ 3, OQ-15). Старый код держал задания в in-memory кэше и опрашивал шару двумя поллерами в одном
процессе с ядром — это и есть то, от чего уходит DECISION-4.
Goals / Non-Goals
Goals: конвейер «NATS → S3 → горячая папка → ответ → S3 → NATS» работает против эмулятора в compose и в unit-тестах без Docker; таймаут и ошибки ИС всегда превращаются в результат; сырой ответ сохраняется для разбора инцидентов. Non-Goals: см. proposal.
Decisions
D-1. Горячие папки — протокол с двумя реализациями, имена каталогов настраиваемы
HotFolder.put(name, data), poll(job_id) -> bytes | None, healthy() -> bool. LocalHotFolder(root) работает с
обычным каталогом (dev, compose-том hotfolder, unit-тесты через tmp_path); SmbHotFolder(host, path, login, password)
— через smbclient шаблона (UNC-пути). Подкаталоги SCAN_INPUT_DIR/SCAN_OUTPUT_DIR по умолчанию Input/Output
(как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Режим —
SCAN_HOTFOLDER_MODE = local | smb.
D-2. Без in-memory реестра заданий: состояние — в NATS и в горячей папке
Задание «в работе» = неподтверждённое сообщение consumer'а. Падение инстанции → сообщение не ack'нуто, но max_deliver = 1
(OQ-15: OCR без повторов) → повторной отправки в ИС не будет; ядро закроет задание по reply_deadline (timeout). Так
нет расхождения между кэшем и шарой, как в старом коде. Альтернатива — хранить задания в Redis/файле: лишняя
инфраструктура ради ситуации, которую ядро уже обрабатывает.
D-3. Параллелизм — SCAN_MAX_INFLIGHT задач, каждая ждёт свой ответ
Воркер делает fetch(n) на свободные слоты и запускает asyncio.Task на задание; ожидание — опрос poll(job_id) каждые
SCAN_POLL_SECONDS (5) до deadline = min(request.reply_deadline, start + SCAN_TIMEOUT_SECONDS). ack_wait consumer'а
= SCAN_TIMEOUT_SECONDS + 120. При таймауте — результат TIMEOUT, файл во входящем каталоге не удаляется (ИС может
ответить позже; «поздний» ответ игнорируется — его никто не опрашивает; уборка каталога — задача эксплуатации, вне среза).
D-4. Сырой ответ — в S3 raw/{job_id}.json, в конверте raw_s3_key
По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов. Сохраняется до нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3.
D-5. Нормализатор — интерфейс; в Срезе 0 пробрасывает ошибки ИС и отвечает NOT_IMPLEMENTED на успех
Normalizer.normalize(raw: dict) -> NormalizeOutcome(status, documents, error, metrics). NullNormalizer: если в ответе
error не null — результат status = error с кодом из ответа (коды Content AI совпадают с перечнем контракта); иначе —
NOT_IMPLEMENTED с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3». Срез 3 подменяет реализацию
(§11, OQ-13/14), транспорт не меняется.
D-6. Эмулятор — тот же репозиторий и образ, другой entrypoint
python -m src.scan_emulator: цикл по Input/ каждую секунду; новый файл <uuid>_<name>.<ext> → поиск
SCAN_FIXTURES_DIR/<name>*.json (точное имя, затем префикс) → через SCAN_EMULATOR_DELAY запись Output/<uuid>/<uuid>.json
(с подстановкой file_uid = <uuid>); нет пары → ответ Content AI {"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": []}. Обработанные входящие файлы переименовываются в <uuid>_<name>.<ext>.done, чтобы
не обрабатываться повторно. Эмулятор использует LocalHotFolder — тем самым тестируя и его.
D-7. Ошибки транспорта — результат, не молчание
Запрос не по схеме / MAJOR ≠ 1 → SCHEMA_MISMATCH; S3 get упал → FILE_READ_ERROR; put в горячую папку упал →
RECOGNITION_FAILED (сообщение: «горячая папка недоступна»); ответ не JSON → INVALID_FORMAT; прочее → UNKNOWN_ERROR.
Конверт публикуется всегда; ack — после публикации; провал публикации → nak 5 с.
D-8. NATS — как у ядра и парсера
Без учётных данных при пустых NATS_SEED/NATS_JWT, иначе nkeys/JWT шаблона; stream не создаёт; повтор подписки каждые
10 с; /healthcheck отдаёт nats и hotfolder.
D-9. Тесты
Unit: LocalHotFolder на tmp_path; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end
«put → эмулятор → poll» без Docker; обработчик воркера с фейками S3/NATS — все коды D-7 и таймаут (с укороченными
настройками). SmbHotFolder — unit с подменённым smbclient. Интеграционный (маркер integration): compose с
scan-emulator — request-image.json для aep_5.pdf → конверт NOT_IMPLEMENTED с raw_s3_key, объект raw/ в MinIO.
Risks / Trade-offs
- [SMB в проде ведёт себя иначе, чем локальный каталог (права, кодировки имён)] →
SmbHotFolderминимален и повторяет вызовы старогоPDFWorker; первый прогон на реальной шаре — отдельная задача Среза 3 с доступом к стенду. - [Имена каталогов ИС отличаются от
Input/Output] → настройки, не код. - [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре.
- [Эмулятор знает только две пары фикстур] → ошибка
INVALID_DOCUMENTдля остальных — тоже тестовый сценарий; новые пары добавляются вfixtures/ocrбез изменения кода.
Migration Plan
Нет данных. Удаление каталогов — отдельным первым коммитом. Откат — revert.
Open Questions
Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего каталогов и путь SMB на стенде ИС Сканирование — нужны к Срезу 3.
Decisions made without discussion
| № | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-03 | D-1: протокол HotFolder, режимы local/smb, имена каталогов Input/Output по умолчанию |
dev без SMB; имена из старого кода | смена настройки |
| 2 | 2026-10-03 | D-2: без реестра заданий, max_deliver = 1 |
OQ-15, таймаут закрывает ядро | добавить хранилище состояния, если понадобятся повторы |
| 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 |
честный статус; коды Content AI совпадают с контрактом | нет |
| 6 | 2026-10-03 | D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, .done после обработки |
общие соглашения горячих папок; aep_8.pdf ↔ aep_8_form4.json |
нет |
| 7 | 2026-10-03 | D-7: таблица транспортных ошибок | ядро не ждёт таймаута, если шлюз жив | нет |
| 8 | 2026-10-03 | external_task_id в ядро не передаётся (идентификатор ИС = job_id в имени файла) |
у протокола горячих папок нет отдельного id задания | поле DBML остаётся NULL |