From 3413328f023ccea537bcfaadaf421c70b7d2bf17 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:41:32 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D1=80=D1=8F=D0=BC=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B2=20Input,=20SCAN?= =?UTF-8?q?=5FTMP=5FDIR=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=83=20?= =?UTF-8?q?=D1=8D=D0=BC=D1=83=D0=BB=D1=8F=D1=82=D0=BE=D1=80=D0=B0,=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B9=D0=BC=D0=B0=D1=83=D1=82=201200=20=D1=81=20(#?= =?UTF-8?q?26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 ++-- README.md | 56 ++++++++----------- api/.env.example | 14 ++--- .../changes/hotfolder-direct-write/tasks.md | 16 +++--- 4 files changed, 45 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 431bd42..36608b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,12 @@ ## Что это Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: положить файл во «Входящие» как `.` -(расширение `original_name` в нижнем регистре; `.part` пишется в `SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же ФС/шаре и -публикуется **без замены**), забрать **все** `Исходящие//*.json` (ИС кладёт по одному json на каждый +(расширение `original_name` в нижнем регистре; пишется **сразу** открытием `wb`, существующий файл перезаписывается — как в +старом АРМ), забрать **все** `Исходящие//*.json` (ИС кладёт по одному json на каждый распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument` v1.2: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed` -(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, +(`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`, старт — запись файла во «Входящие». **Нет доступа к БД.** ## Правила @@ -28,9 +28,9 @@ и golden. - Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть в fixtures и объяснять; эталоны меняются отдельным PR fixtures. -- Горячие папки (раскладка FileWorker): имя `.`, `.part` только в `Tmp`, запись без замены (`os.link` / - `smbclient.rename`; существующее назначение = «доставлено»). `.part`, ставший целью публикации, снимается самой записью - независимо от флага (№26); любое другое удаление (ответ, входящий файл, `.done`, осиротевшие `.part`) — только при +- Горячие папки (раскладка FileWorker): имя `.`, запись сразу в `Input` открытием `wb` с перезаписью, без + временного файла и `Tmp` — как в старом АРМ (change `hotfolder-direct-write`, ASSUMPTION(OQ-79)); `Tmp` — только у + эмулятора. Любое удаление (ответ, входящий файл, `.done`, недописанный при ошибке записи входящий файл) — только при `SCAN_CLEANUP=true` и только после сохранения `raw/` (не при `TIMEOUT`, `FILE_READ_ERROR`, ошибке записи во входящий каталог или `raw/`). Ошибка удаления — одна строка лога, конверт не меняется. Нет расширения у `original_name` → `RECOGNITION_FAILED`. - Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2; diff --git a/README.md b/README.md index 997130a..340d7b2 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ files.img_uploaded (NATS, queue group scan-gateway) 2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота) 3. слот (SCAN_MAX_INFLIGHT одновременных заданий) 4. файл из S3 (s3.bucket/s3.key) - 5. горячая папка: /. (через /...part, без замены) + 5. горячая папка: /. (сразу, wb, существующий перезаписывается) 6. опрос // каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда не менялся SCAN_SETTLE_SECONDS 7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации) @@ -60,19 +60,15 @@ ack (с `attempt` повторного запроса); файл в ИС вто `original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII (`aep_5.PDF` → `.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку. -- Запись атомарна и идёт **без замены**: сначала временный файл `...part` в служебном каталоге - `` (по умолчанию `Tmp`), затем он публикуется в `Input` под настоящим именем. `Tmp` лежит рядом с `Input` - и `Output`, на той же ФС/шаре: публикация атомарна, а ИС не может забрать недописанный файл (временных файлов в - `Input` нет — решение владельца 06.10.2026, у FileWorker `.tmp` лежит рядом с назначением). В режиме `local` публикация — - жёсткая ссылка `.part` → `Input/<имя>` и удаление `.part`; на файловой системе без жёстких ссылок - (`EPERM`/`EXDEV`/`ENOTSUP`/`EOPNOTSUPP` от самой ссылки) — `os.replace`, если назначения ещё нет (решение №17). - `.part` создаётся заново: остаток прежней записи удаляется (ошибка удаления подавляется), файл открывается с `O_EXCL`; - поверх оставшегося `.part` пишем только если это не жёсткая ссылка (`st_nlink == 1`) — иначе исключение (решение №32), - чтобы не испортить уже опубликованный файл. В режиме `smb` — `smbclient.rename` (поднимает `FileExistsError`, если - назначение есть). Если `Input/.` уже существует (повторная публикация задания после потери ack в другом - процессе), файл считается доставленным: ничего не пишется, назначение не перезаписывается, `.part` не трогается (при - `SCAN_CLEANUP=true` существующий `.part` снимается), задание продолжается опросом `Output//` (решение №35). Каталоги `Input` и `Tmp` - создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`. +- Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`): + файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и + переименования нет. В режиме `smb` — `smbclient.open_file(\<имя>, "wb")`: файл открыт монопольно, пока идёт + запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/.` уже существует + (повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание + продолжается опросом `Output//`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается + только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без + родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня — + `RECOGNITION_FAILED`. - Ответ ИС — каталог `//`: ИС кладёт туда **все** распознанные документы задания одновременно, по одному `*.json` на файл (`Output//*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть @@ -96,24 +92,19 @@ ack (с `attempt` повторного запроса); файл в ИС вто - **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в `raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС). - **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output//` (не только `*.json`) и сам каталог, - `Input/.`, `Input/..done` (след эмулятора), `Tmp/...part`. Отсутствующий файл + `Input/.`, `Input/..done` (след эмулятора). `Tmp` шлюз не трогает. Отсутствующий файл или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а подкаталог внутри `Output//` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки - поднимается и логируется, остальные пункты (входящий файл, `.done`, `.part`) в этом задании не удаляются. + поднимается и логируется, остальные пункты (входящий файл, `.done`) в этом задании не удаляются. - **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются. - **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт, `metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа. -- **`.part` и флаг (решение №26).** `.part`, ставший целью успешной публикации, снимается **самой записью**, независимо - от `SCAN_CLEANUP` (в `local` — удаление после жёсткой ссылки, ошибка удаления подавляется; в `smb` `.part` - переименовывается во входящий и отдельного удаления не требует): иначе при выключенной очистке на каждое задание - оставалась бы лишняя ссылка, а ошибка удаления на шаре с неподтверждёнными правами превращала бы доставленный файл в - `RECOGNITION_FAILED`. **Флаг управляет остатками:** осиротевшие `.part` (оборванная запись, `.part` при уже - существующем назначении), каталог ответа, входящий файл и `.done`. При `SCAN_CLEANUP=false` сервис не удаляет ничего из - этого и не пишет в лог об осиротевших `.part` (решение №27): они накапливаются в `Tmp` и убираются вручную или - включением флага. -- Права на SMB: для очистки учётной записи шары нужны права на удаление в `Input`, `Output` (включая каталог - ``) и `Tmp`; для записи — создание файлов в `Tmp` и `Input` и переименование. Пока права на удаление не +- **Флаг управляет всем удалением:** при `SCAN_CLEANUP=false` сервис не удаляет ничего — ни каталог ответа, ни входящий + файл и `.done`, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки + `Tmp/.*.part` от версий шлюза до change `hotfolder-direct-write` сервис не трогает — убрать вручную. +- Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в `Input`, для чтения ответа — + листинг и чтение `Output`; для очистки — удаление в `Input` и `Output` (включая каталог ``). Пока права на удаление не подтверждены, оставляйте `SCAN_CLEANUP=false`. ### Эмулятор ИС в режиме `local` @@ -315,12 +306,11 @@ pydantic-валидацией; битый файл (код не из 4–5 ци | `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) | | `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня | | `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня | -| `SCAN_TMP_DIR` | `Tmp` | служебный каталог относительно корня (на той же ФС/шаре, что `Input`/`Output`): временный `...part` записи входящего файла | | `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды | | `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output//` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) | -| `SCAN_TIMEOUT_SECONDS` | `300` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`) | +| `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) | | `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий | -| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output//` и каталог, `Input/.` (и `.done`), `Tmp/...part`; `false` — ничего не удаляется (кроме `.part`, снятого самой записью); см. «Очистка горячей папки» | +| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output//` и каталог, `Input/.` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» | | `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует | | `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте | | `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) | @@ -335,7 +325,8 @@ MinIO и NATS); те же значения подхватываются из `EN | Переменная | По умолчанию | Назначение | |---|---|---| | `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 | -| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_TMP_DIR` | `Input`, `Output`, `Tmp` | подкаталоги корня (как у шлюза); в `Tmp` эмулятор собирает набор ответа перед rename в `Output//` | +| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR` | `Input`, `Output` | подкаталоги корня (как у шлюза) | +| `SCAN_TMP_DIR` | `Tmp` | служебный каталог эмулятора: в нём собирается набор ответа перед rename в `Output//` (шлюз эту переменную не читает) | | `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` | | `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` | | `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) | @@ -508,8 +499,9 @@ make golden-update # пересобрать эталоны нормализ - Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается: `response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`. -- Запись без замены на файловой системе без жёстких ссылок (`EPERM`/`EXDEV`/`ENOTSUP`): между проверкой назначения и - `os.replace` есть крошечное окно гонки (повтор задания с тем же `job_id` редок, решение №17). +- Запись сразу в `Input` (change `hotfolder-direct-write`): защита от чтения недописанного файла — только монопольное + открытие `smbclient` на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время + записи, распознавание даст ошибку; откат — вернуть запись через временный файл. - Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не diff --git a/api/.env.example b/api/.env.example index 9005d7d..2808c3d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -43,19 +43,17 @@ SCAN_HOTFOLDER_PATH=./.hotfolder # Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из /) SCAN_INPUT_DIR=Input SCAN_OUTPUT_DIR=Output -# Служебный каталог корня: временный .part пишется сюда и переименовывается во «Входящие» (та же ФС/шара — rename -# атомарен, во «Входящих» не бывает недописанных файлов); создаётся при первой записи -SCAN_TMP_DIR=Tmp # Период опроса «Исходящих», секунды SCAN_POLL_SECONDS=5 # Набор *.json в / считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0) SCAN_SETTLE_SECONDS=2 -# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса); повторной отправки нет -SCAN_TIMEOUT_SECONDS=300 +# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса; 20 мин — в паре со сроком +# ответа OCR ядра); повторной отправки нет +SCAN_TIMEOUT_SECONDS=1200 # Сколько заданий обрабатывается одновременно SCAN_MAX_INFLIGHT=4 # Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output//, входящий файл -# (и его .done), осиротевший .part в Tmp; false (по умолчанию) — ничего не удалять +# (и его .done); false (по умолчанию) — ничего не удалять SCAN_CLEANUP=false # Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml); # относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует @@ -70,7 +68,9 @@ SCAN_RULES_DIR=rules # --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) --- # Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги # эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает -# во временном каталоге SCAN_TMP_DIR (выше) и переименовывает в // целиком. +# во временном каталоге SCAN_TMP_DIR и переименовывает в // целиком (шлюз SCAN_TMP_DIR не читает: файл +# во «Входящие» он пишет сразу, как в старом АРМ). +SCAN_TMP_DIR=Tmp # Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace) SCAN_FIXTURES_DIR=../../fixtures/ocr # Задержка перед выдачей ответа, секунды (имитация работы ИС) diff --git a/openspec/changes/hotfolder-direct-write/tasks.md b/openspec/changes/hotfolder-direct-write/tasks.md index 9e4b52e..d8f0b16 100644 --- a/openspec/changes/hotfolder-direct-write/tasks.md +++ b/openspec/changes/hotfolder-direct-write/tasks.md @@ -1,18 +1,18 @@ ## 1. Тесты (scan-gateway, `api/tests/`) -- [ ] 1.1 `unit/test_smb_hotfolder.py`: `put` пишет `open_file(\<имя>, "wb")` без `Tmp`/`rename`; существующий +- [x] 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` +- [x] 1.2 `unit/test_local_hotfolder.py`: то же для `LocalHotFolder`; `Tmp` не создаётся `put` +- [x] 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` +- [x] 2.1 `gateway/smb.py`: `put` — прямая запись (D-1, D-2, D-5), убрать `tmp_dir`, `.part` из `cleanup` +- [x] 2.2 `gateway/hotfolder.py`: `LocalHotFolder.put` — прямая запись; `tmp_dir` только для эмулятора (D-3) +- [x] 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` +- [x] 3.1 `README.md`, `CLAUDE.md`, `api/.env.example` — прямая запись, `SCAN_TMP_DIR` только у эмулятора, таймаут 1200 +- [x] 3.2 `make check-full`