Срез 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>
100 lines
11 KiB
Markdown
100 lines
11 KiB
Markdown
# 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 |
|