diff --git a/openspec/changes/hotfolder-direct-write/.openspec.yaml b/openspec/changes/hotfolder-direct-write/.openspec.yaml new file mode 100644 index 0000000..8126432 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-09 diff --git a/openspec/changes/hotfolder-direct-write/design.md b/openspec/changes/hotfolder-direct-write/design.md new file mode 100644 index 0000000..4d7e198 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/design.md @@ -0,0 +1,58 @@ +## Context + +Запись входящего файла сейчас — двухфазная: `Tmp/.<имя>.part`, затем rename (SMB) / жёсткая ссылка (local) в `Input` +без замены (решения №17, №26, №32, №35 Среза 3a). Старый АРМ писал файл прямо в `Input` и в контуре работал. Владелец +09.10.2026 выбрал повторить старый АРМ и перезаписывать существующий файл. + +## Goals / Non-Goals + +**Goals:** прямая запись в `Input` в обоих режимах; меньше требований к правам на шаре; срок ожидания ответа 20 мин. + +**Non-Goals:** см. proposal. + +## Decisions + +### D-1. Прямая запись с усечением (решение владельца 09.10.2026) + +`SmbHotFolder.put`: `smbclient.open_file(\<имя>, mode="wb", **credentials)` и `write`. `LocalHotFolder.put`: +`open(/<имя>, "wb")` (`Path.write_bytes`). Существующий файл перезаписывается. Повторная запись в рамках одного +процесса по-прежнему исключена дедупликацией по `job_id` в воркере (требование «Подтверждение запроса до обработки»); +перезапись возможна только при повторе задания ядром в другом процессе. + +### D-2. Каталог `Input` создаётся, как раньше + +Перед записью `Input` создаётся без родителей, «уже есть» и отказ в правах при существующем каталоге — не ошибка +(`_ensure_dir` в `smb`, `mkdir(exist_ok=True)` в `local`); корень не создаётся. Старый АРМ каталог не создавал, но +терпимое создание поведения в контуре не меняет, а dev-режим и тесты на нём держатся. + +### D-3. `Tmp` остаётся только у эмулятора + +`SCAN_TMP_DIR` удаляется из настроек шлюза (`Settings`, `extra="ignore"` — старое значение в `.env` не ломает старт). +Эмулятор читает свою переменную `SCAN_TMP_DIR` (`EmulatorSettings`) и собирает набор ответа через +`LocalHotFolder.tmp_dir`, поэтому параметр `tmp_dir` у `LocalHotFolder` остаётся, но `put` его не использует. + +### D-4. Срок ожидания 20 мин + +`SCAN_TIMEOUT_SECONDS` по умолчанию 1200. Эффективный дедлайн — `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, +поэтому arm-core поднимает срок ответа OCR до 20 мин отдельным PR (RSHB/arm-core#87). + +### D-5. Ошибка записи + +При исключении во время открытия/записи `put` снимает недописанный `/<имя>` только при включённой очистке +(ошибка снятия подавляется, исходное исключение поднимается) — R-3. + +## Risks / Trade-offs + +- ИС может увидеть недописанный файл (OQ-79) → смягчение: монопольное открытие `smbclient` на время записи; так же + работал старый АРМ. Откат — вернуть двухфазную запись из истории ветки. +- Ошибка посреди записи оставляет обрезанный файл в `Input` → удаляется только при `SCAN_CLEANUP=true` (как `.part` + раньше, D-5); иначе остаётся, конверт `RECOGNITION_FAILED`. + +## Decisions made without discussion + +| # | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-1 | 2026-10-09 | Режим `local` тоже пишет прямо в `Input` (не только `smb`) | Одинаковая семантика режимов, один набор тестов; эмулятор пишет/читает локально мгновенно | Эмулятор может прочитать недописанный файл — на локальной ФС практически невозможно; откат — вернуть `.part` в `local` | +| R-2 | 2026-10-09 | `Input` по-прежнему создаётся перед записью (D-2), хотя старый АРМ этого не делал | Отказ в правах на `mkdir` при существующем каталоге уже не ошибка; dev и тесты на этом держатся | Лишний вызов `mkdir` на шаре на каждое задание | +| R-3 | 2026-10-09 | Ошибка записи: недописанный файл в `Input` удаляется только при `SCAN_CLEANUP=true` (D-5) | Требование «при `SCAN_CLEANUP=false` ничего не удалять» (OQ-144) сохраняется | Обрезанный файл остаётся в `Input`, ИС может его распознать с ошибкой; ядро получит `RECOGNITION_FAILED` от шлюза раньше | +| R-4 | 2026-10-09 | `SCAN_TMP_DIR` удаляется из шлюза, у эмулятора остаётся (D-3) | Шлюзу каталог больше не нужен; эмулятору нужен для атомарной сборки ответа | Нет: `extra="ignore"` — старые `.env` стартуют | diff --git a/openspec/changes/hotfolder-direct-write/proposal.md b/openspec/changes/hotfolder-direct-write/proposal.md new file mode 100644 index 0000000..c805bf5 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/proposal.md @@ -0,0 +1,43 @@ +## Why + +Решение владельца 09.10.2026: запись входящего файла в горячую папку ИС Сканирование — «как в старом АРМ», где она +работала в контуре: `smbclient.open_file('/Input/<имя>', 'wb')` прямо во входящий каталог +(`reference-old-arm/api/src/core/clients.py:111`). Нынешняя схема (временный `Tmp/.<имя>.part` + rename без замены, +OQ-79, OQ-137) требует от учётной записи шары каталога `Tmp`, прав на его создание и на переименование и в контуре не +проверялась. Там же владелец решил: срок ожидания ответа OCR — 20 минут. +Issue — RSHB/arm-scan-gateway#26. Вне среза `09-delivery-plan.md` (доработка Среза 3a по эксплуатации). + +## What Changes + +- `put` (режимы `smb` и `local`): файл `.` пишется сразу во входящий каталог, открытием на запись с + усечением (`wb`); существующий файл **перезаписывается** (как в старом АРМ). Временного файла, каталога `Tmp` и + переименования больше нет. +- Настройка шлюза `SCAN_TMP_DIR` удаляется; параметр `tmp_dir` у `SmbHotFolder` удаляется. У `LocalHotFolder` он остаётся + только для эмулятора (сборка набора ответа, переменная эмулятора `SCAN_TMP_DIR` не меняется). +- Очистка (`SCAN_CLEANUP=true`) больше не удаляет `Tmp/...part`. +- `SCAN_TIMEOUT_SECONDS` по умолчанию 300 → 1200 (20 мин) — в паре с `reply_deadline` OCR 20 мин в arm-core + (RSHB/arm-core#87). +- README, `CLAUDE.md`, `api/.env.example`, тесты — под новую семантику. + +## Non-goals + +- Протокол ответа (`Output//*.json`, стабилизация набора, OQ-78) не меняется. +- Эмулятор ИС не меняется: он по-прежнему пропускает `*.part`/`*.tmp`/dot-файлы и собирает ответ через свой `Tmp`. +- Аутентификация SMB не меняется: `OPENBAO__SMB__SMB_LOGIN`/`SMB_PASSWORD` уже передаются в сессию и в каждый вызов. +- Таймаут вызовов `smbclient` (соединение ~60 с по умолчанию библиотеки) не трогаем. +- Таблицы DBML не затрагиваются. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `image-recognition-gateway`: запись входящего файла без временного файла и с перезаписью; очистка без `Tmp`. + +## Impact + +- ASSUMPTION(OQ-79): ИС Сканирование не берёт файл, пока шлюз держит его открытым на запись (`smbclient.open_file` + по умолчанию открывает монопольно), — так работал старый АРМ. Если подтвердится обратное, вернуть запись через + временный файл. +- Учётной записи шары больше не нужны права на `Tmp` и на переименование; нужна запись в `Input`. +- Остатки `Tmp/.*.part` от прежних версий шлюз больше не трогает — убрать вручную. diff --git a/openspec/changes/hotfolder-direct-write/specs/image-recognition-gateway/spec.md b/openspec/changes/hotfolder-direct-write/specs/image-recognition-gateway/spec.md new file mode 100644 index 0000000..62d5643 --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/specs/image-recognition-gateway/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: Передача файла в ИС Сканирование через горячие папки +После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как +`.`, где `` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL +завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Файл SHALL записываться сразу во входящий каталог +открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога +и переименования нет; если `/.` уже существует, он SHALL быть перезаписан содержимым объекта S3. +Затем сервис SHALL опрашивать исходящий каталог `//` каждые `SCAN_POLL_SECONDS` до +`min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)` (`SCAN_TIMEOUT_SECONDS` по умолчанию 1200). ИС кладёт в этот каталог +все распознанные документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется; +пустой или отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один +`*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL +включать **все** `*.json` каталога по возрастанию имени, остальные файлы игнорируются. + +#### Scenario: Файл положен во входящий каталог +- **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…` +- **THEN** во входящем каталоге появляется `3f1c….pdf`, байт в байт равный объекту S3; каталог `Tmp/` не создаётся, временных файлов нет + +#### Scenario: Назначение уже существует +- **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит с другим содержимым (задание повторено ядром после потери ack в другом процессе) +- **THEN** `Input/3f1c….pdf` перезаписан и байт в байт равен объекту S3, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется + +#### Scenario: Запрос без расширения +- **WHEN** получен запрос с `original_name = "scan"` без точки +- **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет + +#### Scenario: Ответ из одного файла +- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру + +#### Scenario: Ответ из нескольких файлов +- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется + +#### Scenario: Набор ещё пишется +- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад +- **THEN** ответ не считается готовым, опрос продолжается до дедлайна + +#### Scenario: Ошибка посреди записи +- **WHEN** запись `Input/3f1c….pdf` прервалась ошибкой ввода-вывода +- **THEN** опубликован конверт `RECOGNITION_FAILED`; недописанный файл удалён только при `SCAN_CLEANUP=true`, при `false` остаётся на месте + +### Requirement: Очистка горячей папки под флагом +При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от +исхода нормализации), сервис SHALL удалить всё содержимое `//` (json-файлы ответа и любые +прочие) и сам каталог ``, входящий файл `/.` и его вариант `/..done`, +если они существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая недописанный при +ошибке записи входящий файл. При `TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться +(при ошибке записи и `SCAN_CLEANUP=true` снимается только недописанный входящий файл). Ошибка удаления SHALL быть +записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`. + +#### Scenario: Очистка включена +- **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/` +- **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf` и `Input/3f1c….pdf.done` + +#### Scenario: Очистка выключена +- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан +- **THEN** все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27) + +#### Scenario: Нет прав на удаление +- **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError` +- **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok` + +#### Scenario: Таймаут не чистит +- **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна +- **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output//` (если есть) не тронуты diff --git a/openspec/changes/hotfolder-direct-write/tasks.md b/openspec/changes/hotfolder-direct-write/tasks.md new file mode 100644 index 0000000..9e4b52e --- /dev/null +++ b/openspec/changes/hotfolder-direct-write/tasks.md @@ -0,0 +1,18 @@ +## 1. Тесты (scan-gateway, `api/tests/`) + +- [ ] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(\<имя>, "wb")` без `Tmp`/`rename`; существующий + файл перезаписывается; ошибка записи — снятие файла только при `cleanup=True`; `cleanup` не трогает `.part` +- [ ] 1.2 `unit/test_local_hotfolder.py`: то же для `LocalHotFolder`; `Tmp` не создаётся `put` +- [ ] 1.3 `unit/test_config.py`, `unit/test_hotfolder_factory.py`: нет `SCAN_TMP_DIR` у шлюза, `SCAN_TIMEOUT_SECONDS` + по умолчанию 1200; интеграционные `test_roundtrip.py` — без ожиданий про `Tmp/.part` + +## 2. Реализация (scan-gateway, `api/src/`) + +- [ ] 2.1 `gateway/smb.py`: `put` — прямая запись (D-1, D-2, D-5), убрать `tmp_dir`, `.part` из `cleanup` +- [ ] 2.2 `gateway/hotfolder.py`: `LocalHotFolder.put` — прямая запись; `tmp_dir` только для эмулятора (D-3) +- [ ] 2.3 `core/config.py`, `gateway/__init__.py`: убрать `SCAN_TMP_DIR` у шлюза; `SCAN_TIMEOUT_SECONDS = 1200` + +## 3. Документация + +- [ ] 3.1 `README.md`, `CLAUDE.md`, `api/.env.example` — прямая запись, `SCAN_TMP_DIR` только у эмулятора, таймаут 1200 +- [ ] 3.2 `make check-full`