Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
36 KiB
Design: Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR
Context
Срез 0 построил конвейер scan-gateway (ack → S3 → горячая папка → опрос → raw/ → нормализатор → конверт) с
NullNormalizer. Контракт v1.2 (Срез 2b) определил statementDocument: header, columns[{column_index, label, period_date}], lines[{line_code, line_code_confidence, line_name, line_name_confidence, values[{column_index, amount, string_value, confidence, is_recognized}]}], необязательный checks. Реальные ответы ИС (../fixtures/ocr/aep_3.json,
aep_6.json) — data_ocr {label, organization, inn, units, date, table_structure[{label, rows[{row_index, cells[{column_index, value, confidence}]}]}]}; текст в ячейках зашумлён (двойники букв и цифр, | как границы клеток, переносы строк, скобки),
в aep_3.json у таблицы нет строки-шапки. Старое ядро (DKB_ARMKA, services/ofr_result_conversion/) решает эти
задачи кодом, проверенным на стенде ИС Сканирование; владелец 06.10.2026 разрешил его перенос (§11.1).
Goals / Non-Goals
Goals: PDF-путь отдаёт ядру тот же statementDocument v1.2, что Excel-путь; раскладка горячих папок совпадает с
проверенным в контуре FileWorker; перенос проверенных функций очистки без их DB-обвязки; dev-стенд с эмулятором
работает на реальных парах PDF+JSON.
Non-goals: см. proposal.md.
Decisions
D-1. Имена в горячих папках как у FileWorker, временный файл остаётся в Tmp (решение владельца)
put(job_id, ext, data) пишет <Tmp>/.<job_id>.<ext>.part, затем rename без замены в <Input>/<job_id>.<ext>.
Расширение — суффикс original_name запроса в нижнем регистре; без суффикса — RECOGNITION_FAILED (ИС различает
форматы по расширению). Назначение уже существует (повторная публикация задания после потери ack) → файл считается
доставленным, .part остаётся до очистки. Владелец отклонил .tmp рядом с назначением (как у FileWorker): ИС может
подхватить недописанный файл; FileWorker полагается на игнор *.tmp, у нас такой гарантии нет. poll без изменений.
D-2. Очистка — отдельный метод под флагом SCAN_CLEANUP (решение владельца)
cleanup(job_id, ext) удаляет Output/<job_id>/*.json, каталог Output/<job_id>, <Input>/<job_id>.<ext>,
<Input>/<job_id>.<ext>.done (след эмулятора) и <Tmp>/.<job_id>.<ext>.part. Вызывается воркером только после того,
как набор ответа прочитан и склеен в raw/ (любой исход разбора), и только при SCAN_CLEANUP=true. При TIMEOUT,
ошибке чтения S3, ошибке записи в горячую папку — не вызывается. Любое удаление, включая .part, подчиняется флагу: на
SMB-шаре контура права на удаление не подтверждены. Ошибки удаления — одна строка лога на задание, исход задания и
/healthcheck не меняются. По умолчанию false; dev-compose — true.
D-3. Эмулятор ищет пару по sha256 содержимого, при промахе — набор по умолчанию (решение владельца, вариант 3)
Имя входящего файла больше не несёт имени фикстуры. Эмулятор при старте индексирует SCAN_FIXTURES_DIR: для каждого
файла, не являющегося *.json, считается sha256 → основа имени <name> (без расширения); индекс пересчитывается,
если изменился список файлов каталога. Для входящего <uuid>.<ext> считается sha256; совпадение → набор <name>.json +
<name>_*.json по прежним правилам (<uuid>.json, <uuid>_<suffix>.json, file_uid = <uuid>); промах →
SCAN_EMULATOR_DEFAULT=<name> (если задан и его набор существует) → иначе ответ-ошибка INVALID_DOCUMENT с текстом «нет
пары по содержимому». Владелец дополнит пары PDF+JSON в fixtures (сейчас пары есть для aep_3, aep_5, aep_6, aep_8).
D-4. Нормализатор — конвейер чистых функций, проверенные куски переносятся дословно (решение владельца, вариант B)
src/normalizer/ocr/:
text.py—fix_lookalikes(замена двойников только вплотную к цифре, несколько проходов;|как разделитель, если первая строка состоит из цифр, пробелов,|,•,·, точек, двоеточий, запятых),clean_value(буквы, цифры, пробел, точка;\n/\t→ пробел),digits_only,normalize_header(нижний регистр, не-буквенно-цифровое → пробел).dates.py—parse_date(text) -> date | None: текстовые формы по приоритету, затем числовые; дословный переносdate_utils._parse_dateи паттернов. Паттерны остаются в коде (регулярки с лямбдами).amounts.py—parse_amount(text) -> ParsedAmount(amount: Decimal | None, verified: bool);is_blank(прочерки).layout.py—ColumnLayout(label_idx, code_idx, value_idxs),build_layout(cells)по строке с ячейкой «код…»,infer_layout(rows_cells, known_codes)по данным;cells—dict[int, Cell(value, confidence)].rows.py—parse_table_rows(rows, known_codes) -> list[ParsedRow] | None(первая строка — шапка настоящая или синтетическая, повторная шапка перезаписывает раскладку),classify_row(HEADER/TOTAL/BALANCE/ROW),table_type_by_codes.header.py—parse_file_header(data_ocr) -> FileHeader(организация, ИНН, единицы, дата отчёта, ихconfidenceи признаки «распознано» по §11.1 п. 8).document.py—build_document(table, index, file_name, file_header, rules) -> BuiltDocument(документ контракта + предупреждения + флаг «всё распознано»).__init__.py—OcrNormalizer(rules): обходfilesсклейки по возрастанию имени, сбор документов, статус (D-6),metrics. Исключение при разборе одной таблицы → предупреждениеBACKEND_PARSING_ERROR, остальные таблицы разбираются; исключение вне таблицы → как в Срезе 0 (воркер отвечаетUNKNOWN_ERROR,raw/уже сохранён). Все функции без I/O и настроек; правила (D-7) передаются объектом. Перенос с именами без подчёркиваний, типами и docstring'ами на русском; алгоритмы и регулярки — как в источнике, расхождения фиксируются в тестах.
D-5. checks в документе scan-gateway отсутствуют (решение владельца)
Отсутствие checks по контракту означает «не проверялось». Соотношения §7 для обоих источников считает ядро в 3b;
дублировать statement_checks.yaml из 2b в scan-gateway не нужно. Чтобы не конфликтовать: ядро не должно полагаться на
checks как на единственный источник, это требование уйдёт в change 3b.
D-6. Статусы и предупреждения
Файлы ответа: правила Среза 0 без изменений (ошибочные файлы, error при всех ошибочных, строки metrics.warnings).
Из успешных файлов собираются документы по одному на таблицу:
- ни одной таблицы не разобрано (нигде нет столбца кода,
table_structureпусты) →status = error,error.code = RECOGNITION_FAILED,error.message = "ни одной таблицы не разобрано", причины по таблицам — в warnings; - есть документы →
done, если у всех документовinn_confidenceиorganization_name_confidenceравны 100,report_dateи всеperiod_dateзаполнены, всеline_code_confidence/line_name_confidenceравны 100, все значенияis_recognized, тип совпал сlabel, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы; иначеneeds_check. Единицы (units) в критерий не входят: у поля нетconfidenceв контракте. Предупреждения уровня таблицы:<имя json>: table:<i>: <текст>— таблица пропущена (нет шапки и столбца кода), шапка синтезирована (колонки без дат), тип по кодам разошёлся сlabel, колонка без даты, редакция определена по кодам, редакцияfsbu4не заполнена, код из другой редакции,BACKEND_PARSING_ERROR: <класс>: <сообщение>. Уровня файла:<имя json>: ИНН не распознан,<имя json>: дата отчёта не разобрана. Длина и число строк — как в Срезе 0 (clean_text).metrics.rows— сумма строк документов. ДокументOTHER(тип не определён) включается с разобранным содержимым и даётneeds_check.
D-7. Правила — данные в api/rules/, справочник кодов с редакциями (решение владельца)
statement_lines.yaml:editions: [{id: 66n, valid_until: 2024-12-31, lines: {...}}, {id: fsbu4, valid_from: 2025-01-01, lines: {}}].66n— изcodes_mappingстарого ядра с исправленными опечатками;fsbu4— пустая, данные положит владелец, код не меняется. Выбор редакции для документа: поreport_dateшапки → по самой позднейperiod_dateколонок → по совпадениям кодов (с предупреждением). Пустая выбранная редакция → предупреждение «редакция fsbu4 не заполнена», имена из OCR,needs_check. Код не в выбранной редакции, но есть в другой → предупреждение,needs_check; код нигде → имя из ячейки OCR,line_code_confidence = min(confidence ячейки кода, 99)(решение №19).ocr_lookalikes.yaml: пары «символ → цифра» и список символов замены.statement_labels.yaml: нормализованные подстрокиlabelтаблицы →BALANCE/INCOME. Загрузка один раз при старте (SCAN_RULES_DIR, по умолчаниюrules/сервиса), pydantic-валидация; битый yaml — ошибка старта. Тест покрытия: коды 4–5 цифр, имена непустые, дубли отсутствуют, обе редакции валидируются одинаково.
D-8. Тесты
Юнит по каждому модулю normalizer/ocr/; golden на реальных фикстурах ../fixtures/ocr/*.json с эталонами
../fixtures/ocr/expected_normalize/<name>.json (формат — файл результата: status, documents, warnings;
структурное сравнение, обновление — make golden-update); протокольные тесты нормализатора переводятся на
OcrNormalizer; тесты горячих папок, эмулятора, воркера обновляются; test_roundtrip.py на aep_6.pdf до конверта с
документами. Интеграция не запускается одновременно с интеграцией arm-core (OQ-83).
D-9. Настройки и соседние репозитории
SCAN_CLEANUP=false, SCAN_RULES_DIR, SCAN_EMULATOR_DEFAULT=""; SCAN_TMP_DIR сохраняется. Ветка — от main после
мержа scan-gateway #5 (копия схемы 1.2). fixtures: пары PDF+JSON от владельца и expected_normalize/ — отдельный PR.
docs: bounded-PR (03-architecture.md, OQ-137, §11.1 п. 6 и 9, новые OQ).
Risks / Trade-offs
- Реальные сканы почти всегда дадут
needs_check— это ожидаемо;doneозначает лишь отсутствие подсветок в 3b. - Пустая редакция
fsbu4до заполнения: документы 2025 года идут с именами из OCR. Цена — больше ручной проверки, не потеря данных. |-эвристика и двойники могут исказить редкие корректные значения;string_valueхранит очищенный, аraw/— сырой текст, аналитик видит оба.- Rename без замены на SMB:
smbclient.renameподнимаетFileExistsError; при гонке с ИС, уже забравшей файл, назначения может не быть — тогда.partпереименовывается повторно, это идемпотентно.
Migration Plan
Не требуется: сервис без БД; изменение имён файлов видят только ИС и эмулятор; контракт не меняется. Dev-стенд: после
мержа обновить docs/dev/.env.example и compose (SCAN_CLEANUP=true, SCAN_EMULATOR_DEFAULT по желанию).
Open Questions
- Права на удаление на SMB-шаре контура (новый OQ) — пока
SCAN_CLEANUP=false. - Нужна ли ядру редакция формы в конверте (кандидат в MINOR 1.3) — новый OQ.
- Словари §7 и §12 по редакциям (2b, 3b) — новый OQ, расширение OQ-12.
- Состав редакции
fsbu4— данные владельца. - Скобки, распознанные как
}/]((7744536}вaep_3.json): дословныйparse_amountминус не ставит (решение №12). Владелец 06.10.2026: «пока устроит как есть», вынести в follow-up — новый OQ в07-open-questions.md(расширить множество закрывающих скобок в §11.1 п. 7 после проверки на стенде).
Decisions made without discussion
| № | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-06 | Один statementDocument на каждую таблицу table_structure[i]; locator = "file:<имя json>;table:<i>" |
§11: тип документа задаёт label таблицы; locator нужен ядру для привязки к raw/ |
смена формата locator |
| 2 | 2026-10-06 | Расширение входящего файла — суффикс original_name в нижнем регистре; без суффикса RECOGNITION_FAILED |
ИС различает форматы по расширению; FileWorker делает так же | потеря заданий без расширения — их не бывает (ядро проверяет сигнатуру) |
| 3 | 2026-10-06 | Очистка не вызывается при TIMEOUT и транспортных ошибках |
ИС может ещё писать ответ; FileWorker чистит только после результата | мусор в папке при таймаутах |
| 4 | 2026-10-06 | Исключение при разборе одной таблицы — предупреждение, остальные таблицы разбираются; исключение вне таблиц — UNKNOWN_ERROR воркера |
§11.1 п. 9, поведение старого ядра по файлам | скрытая ошибка в предупреждении вместо конверта error |
| 5 | 2026-10-06 | Документ без строки-шапки отдаётся с синтетической шапкой и колонками без period_date, needs_check |
рулинг §11.1 п. 3 | аналитик расставляет даты руками |
| 6 | 2026-10-06 | Выбор редакции: report_date → поздняя period_date → совпадения кодов |
дата отчёта надёжнее кодов; коды — последний резерв | неверная редакция при мусорной дате; тогда предупреждение по кодам |
| 7 | 2026-10-06 | Паттерны дат остаются в коде, двойники и label — в yaml |
регулярки с лямбдами в yaml нечитаемы; таблицы символов — данные | править даты — через код |
| 8 | 2026-10-06 | OTHER включается в documents, а не отбрасывается |
как OTHER у Excel-пути 2b; ядро решает |
лишний документ на экране проверки |
| 9 | 2026-10-06 | Эмулятор пересчитывает индекс хешей при изменении списка файлов каталога, а не на каждый файл | дёшево и достаточно для dev | изменённый, но не переименованный PDF не переиндексируется |
| 10 | 2026-10-06 | label колонки — сырой текст шапки с \n → пробел; period_date из parse_date |
ядро показывает текст как есть | — |
| 12 | 2026-10-06 | parse_amount переносится дословно: скобка, распознанная как } ((7744536}), минус не даёт — сумма положительная с is_recognized = false |
проверенный код; расширение множества скобок — отдельное правило §11.1 после проверки на стенде | знак потерян, аналитик видит raw/ и подсветку |
| 13 | 2026-10-06 | В переносе parse_amount признак verified считается до постановки знака минус (в источнике — после, и любая сумма в скобках получала verified = False) |
иначе каждая отрицательная сумма формы давала is_recognized = false и needs_check без причины; единственное отличие от источника, зафиксировано тестом (6032409) → -6032409, verified |
расхождение с поведением старого ядра на стенде: там такие суммы подсвечивались, у нас — нет. Побочный эффект: скобки без содержимого ((), ( ), (.)) дают (None, verified = true) (в источнике false) |
| 14 | 2026-10-06 | CONTRACT_VERSION scan-gateway остаётся 1.0, хотя копия схемы — v1.2 |
документы 3a не используют полей 1.1–1.2 (DISCLOSURE, checks, границы периода ОСВ); потребитель принимает любой MINOR; меньше изменений в тестах конверта |
если ядро начнёт различать MINOR производителя — одна константа и проверка в worker_helpers |
| 15 | 2026-10-06 | Повтор кода строки в таблице OCR: остаётся первая строка, предупреждение, needs_check |
DBML: уникальный индекс financial_statement_line (meta_id, line_code) — без этого маппинг 3b падает; Excel-путь 2b делает так же (§12 п. 5); старое ядро дубли не убирало |
вторая строка с тем же кодом теряется в документе (остаётся в raw/) |
| 16 | 2026-10-06 | string_value заполняется только при amount = null и непустом не-прочерке, текстом как распознан (\n/\t → пробел); у разобранных сумм — null |
DBML financial_statement_value.string_value: «как распознано, если не число»; так же в 2b; первичная редакция спеки и старое ядро писали очищенный текст всегда |
аналитик не видит сырой текст суммы на экране (он в raw/) |
| 11 | 2026-10-06 | Имя строки: из словаря редакции по коду; из OCR — только если кода нет в словаре | старое ядро, §11.1 п. 6 | расхождение имени словаря и формы клиента не видно аналитику (видно в raw/) |
| 17 | 2026-10-07 | Запись во входящий каталог «без замены» реализуется как os.link(.part → назначение) + unlink(.part) (локальная ФС), с запасным путём os.replace при EPERM/EXDEV/ENOTSUP, если назначения нет; на SMB — smbclient.rename |
POSIX rename перезаписывает; жёсткая ссылка атомарно даёт FileExistsError; в D-1 сказано «rename без замены» — это его реализация |
на ФС без жёстких ссылок окно гонки (проверка существования и replace) — повтор задания с тем же job_id редок |
| 18 | 2026-10-07 | R-1. Тест плана test_aep3_like_rows_without_header требовал 5 in layout.value_idxs; ожидание заменено на layout.value_idxs == (2, 4), алгоритм infer_layout не менялся |
столбец 5 содержит только мусор («:», «+ :») и пустые ячейки: по дословному _infer_layout суммы (0) < мусор (2) — столбец не значение; ошибка плана, а не переноса |
если реальный aep_3.json даст иной набор колонок — выяснится в golden (Task 10) |
| 19 | 2026-10-07 | R-2. line_code_confidence = confidence ячейки кода только если очищенный текст равен сырому И код есть в выбранной редакции; иначе min(confidence, 99); line_name_confidence словарного имени равна line_code_confidence, имени из OCR — confidence ячейки наименования |
требование «Нормализация ответа ИС» спеки; код плана капил уверенность только при мусоре в тексте кода, тест плана ожидал 99 для кода вне справочника | код вне справочника чаще даёт needs_check — это то, что нужно аналитику |
| 20 | 2026-10-07 | О-3 (наблюдение, не меняет поведение): прочерк «—»/«–» при confidence = 100 даёт is_recognized = false (дословный parse_amount вырезает тире → verified = false), дефис «-» — true |
дословный перенос и формулировка спеки «разбор не отбрасывал символов» | лишний needs_check на формах с длинным тире; правило уточняется в §11.1 после проверки на стенде |
| 21 | 2026-10-07 | R-3. values строки строятся по позиции колонки документа; при ином числе колонок у раскладки строки после повторной шапки — недостающие как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу, needs_check |
спека: «values — по одному на колонку»; код плана давал значения по индексам раскладки строки и мог породить column_index-сироту, на которой упадёт уникальность (meta_id, column_index) в 3b |
значения «лишних» колонок повторной шапки теряются в документе (остаются в raw/) |
| 22 | 2026-10-07 | R-4. fix_lookalikes — только для дат (дата отчёта, подписи колонок); коды — clean_value, суммы — parse_amount, ИНН — digits_only. Формулировка спеки про коды и суммы исправлена |
§11.1 п. 1 и проверенный на стенде старый код; замена S→5 и т. п. в суммах не проверена на стенде |
код с латинской O вместо нуля отбрасывается как не-целое (остаётся в raw/) |
| 23 | 2026-10-07 | R-5. Предупреждения «код N из редакции X» не выдаются, если выбранная редакция пуста | до заполнения fsbu4 каждая строка 2025 года давала бы шумное предупреждение (на aep_6 39+25), информативно одно «редакция не заполнена» |
при пустой редакции аналитик не видит, что код из другой редакции |
| 24 | 2026-10-07 | header.form_name не передаётся, если у таблицы нет label |
контракт запрещает null в form_name (OptionalNotNull) |
поле отсутствует вместо null |
| 25 | 2026-10-07 | R-6. Таблица без колонок значений (columns = []) → предупреждение «нет колонок значений», needs_check |
иначе документ без колонок мог оказаться done |
— |
| 26 | 2026-10-07 | R-8. .part, ставший целью успешной публикации (os.link → назначение), снимается самой записью независимо от SCAN_CLEANUP; ошибка его снятия подавляется. Флаг управляет остатками: осиротевшие .part, .part при уже существующем назначении, ответы, входящий файл, .done |
иначе при SCAN_CLEANUP=false на каждое задание оставалась бы жёсткая ссылка; ошибка удаления на шаре с неподтверждёнными правами не должна превращать доставленный файл в RECOGNITION_FAILED |
на ФС без права удаления в Tmp у каждого задания останется .part-ссылка (место не занимает) |
| 27 | 2026-10-07 | R-9. При SCAN_CLEANUP=false сервис ничего не удаляет и не логирует «осиротевший .part»; сценарий «Очистка выключена» приведён к «все файлы остаются на месте» |
в слое горячих папок нет логгера, а поиск сирот потребовал бы обхода Tmp на каждое задание; ценность — только диагностическая |
сироты в Tmp при выключенной очистке накапливаются без заметки в логе; чистятся вручную или включением флага |
| 28 | 2026-10-07 | R-10. Ошибочный файл набора при хотя бы одном успешном понижает статус до needs_check (done невозможен) |
спека «Ошибка одного из документов»: потерянный документ (например, ОФР) не должен остаться незамеченным под done; строка об ошибке уже в metrics.warnings |
чаще needs_check; в D-6 («только документы») — уточнение |
| 29 | 2026-10-07 | R-11 (уточнён финальным ревью I-1). На таблицу — предупреждение строк с нечисловым кодом пропущено: N (непустой нечисловой код, строка отброшена classify_row) и предупреждение строк без кода с суммами пропущено: N (пустой код, в колонках значений есть хотя бы одна разобранная сумма — как в Excel-пути 2b, OQ-141); строки с пустым кодом без сумм — без предупреждения |
в aep_3 «Выручка» и «БАЛАНС» имеют ПУСТОЙ код (мусор 211(1 | — в колонке значения), без предупреждения они терялись молча; поведение источника (отбрасывание) не меняется |
таблица с отброшенными строками всегда needs_check; восстановление строк — OQ-149 |
| 30 | 2026-10-07 | R-12 (финальное ревью C-1). Сумма с более чем двумя знаками после запятой (1,234, 0,125) → amount = null, string_value — текст ячейки, is_recognized = false; confidence ячейки и полей шапки приводится к [0, 100], NaN → 0 |
Money контракта — ^-?\d+(\.\d{1,2})?$; иначе один ячейковый артефакт OCR (пробел тысяч как запятая) валил всю таблицу (BACKEND_PARSING_ERROR) и документ |
значение остаётся в string_value и raw/, аналитик вводит сумму вручную |
| 31 | 2026-10-07 | R-13 (финальное ревью I-3). Отклонение от дословного переноса: тексты дат длиннее 200 символов не разбираются (None), fix_lookalikes к ним не применяется |
регулярки квартала/полугодия — кубическое backtracking ("1" + " "*4000 + "b" — 76 с, GIL держит весь процесс); дата — не более десятков символов |
дата в аномально длинном тексте не найдена (не дата и так) |
| 32 | 2026-10-07 | R-14 (финальное ревью). Локальная запись: .part создаётся заново (удаление старого + O_EXCL), fallback os.replace — только вокруг os.link, EOPNOTSUPP добавлен к списку |
.part, оставшийся жёсткой ссылкой на уже опубликованный файл (подавленное удаление, OQ-144), перезаписывался бы на месте и портил файл, который читает ИС; EPERM на записи маскировался FileNotFoundError |
— |
| 33 | 2026-10-07 | R-15 (финальное ревью, T1.3). Загрузчик yaml отклоняет повторяющиеся ключи (SafeLoader с проверкой) |
спека: «Коды справочника SHALL быть без дублей»; safe_load молча перезаписывает дубли, тест len(set) тавтологичен; владелец заполняет fsbu4 вручную |
битый справочник не стартует — это и нужно |
| 34 | 2026-10-07 | R-16 (финальное ревью I-2). №15 (повтор кода → остаётся первая строка) НЕ меняется: в aep_6 ОФР код 2410 повторён, и оставленная первая строка пуста, а отброшенная ((649)) — со значением; выбор «первая с суммой» отложен владельцу как OQ |
паритет с Excel-путём 2b (§12 п. 5); эвристика «первая с суммой» для aep_3 даёт иное, чем для aep_6 — нужна проверка на стенде | сумма отброшенной строки остаётся только в raw/; предупреждение «код N повторяется» сигнализирует аналитику |
| 35 | 2026-10-07 | R-17. put при уже существующем назначении ничего не пишет и не трогает .part (при включённой очистке — снимает существующий .part); .part от прерванной записи перед новой записью удаляется с подавлением ошибки; запись поверх оставшегося .part без назначения — только при st_nlink == 1 |
после №32 неудаляемый .part (нет права удаления, OQ-144) на повторе задания давал бы PermissionError → RECOGNITION_FAILED для уже доставленного файла |
редкий остаток .part в Tmp без права удаления |