63 Commits

Author SHA1 Message Date
Andrey Epifancev
793516fa61 fix(emulator): тесты неперестроения индекса, единственный refresh за проход, различающий тест skip-списка и верхний регистр (T14.2, T14.4)
T14.2(a): Добавлен тест с шпионом на build_index, проверяющий что индекс не пересчитывается повторно на каждом проходе при неизменных фикстурах. 5 проходов — ровно 1 вызов build_index (при инициализации run), остальные используют refresh.

T14.2(b): Добавлен тест корректной обработки появления каталога фикстур после стартапа — run не выбрасывает исключение и может использовать фикстуры после их создания.

T14.1 remainder (double refresh): Исправлено поведение process_once: если index=None, то build_index один раз; если index передан, то только refresh. Обновлена docstring процесса.

T14.2(c): Добавлен мутационный тест test_skip_suffixes_not_logged_as_regex_mismatch, проверяющий что файлы с _SKIP_SUFFIXES пропускаются молча и НЕ логируются как не совпадающие с шаблоном.

T14.4: Переписан test_uppercase_extension_accepted — теперь пишет файл напрямую .PDF и проверяет нормализацию в .pdf.done, вместо использования _drop которая нормализует расширение.

Дополнительно:
- Обновлена docstring run() — точное описание: один refresh в конце каждого прохода
- Удалено "(Review Focus 5)" из docstring test_ignores_part_tmp_done
- Добавлен @pytest.mark.skipif для chmod теста при запуске от root

915 тестов пройдено, 10 деселектировано (10 избранных тестов требуют fixtures).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 02:13:23 +04:00
Andrey Epifancev
31e05955ae fix(emulator): индекс не пересчитывается на каждом проходе, устойчивость к нечитаемому файлу, тесты (T14.1–T14.4)
T14.1: run() ВСЕГДА строит индекс один раз (даже если fixtures_dir отсутствует → пустой индекс), обновляет через refresh() на каждом проходе. process_once() больше не пересчитывает при None, берёт переданный индекс.

T14.3: OSError при чтении входящего файла логируется одной строкой, проход продолжается со следующего файла.

T14.4: Лог сообщений об ошибке использует "по умолчанию" вместо `default`; исправлена опечатка "фиксутрах" → "фикстурах"; удалено "(Review Focus 5)" из docstring модуля.

Новые тесты: test_upload_file_read_error_continues (T14.3), test_uppercase_extension_accepted (M-4), test_ignores_part_tmp_done_robust (T14.2c).

Все 912 тестов зелёные.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 02:02:55 +04:00
Andrey Epifancev
bb56226177 feat(emulator): пара фикстур по sha256 содержимого, SCAN_EMULATOR_DEFAULT, входящий <uuid>.<ext> (D-3)
- Добавлена FixtureIndex с индексированием по sha256 содержимого файлов (не-JSON)
- build_index() строит индекс один раз, refresh() обновляет только при изменении списка файлов
- resolve_stem() находит основу имени по хешу, при промахе возвращает default или (None, "none")
- process_once() принимает параметр index и default, работает с файлами <uuid>.<ext>
- Пропускаются dot-файлы, *.part, *.tmp, *.done (Review Focus 5)
- Сообщение об ошибке: "{sha256[:12]}: нет пары по содержимому в фикстурах"
- EmulatorSettings.default = "" для SCAN_EMULATOR_DEFAULT
- Логирование источника пары: hash / default / none
- Все 909 тестов зелёные, включая 54 новых и переписанных тестов эмулятора

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 01:53:22 +04:00
Andrey Epifancev
c1782b9524 feat(worker): очистка горячей папки под SCAN_CLEANUP, RECOGNITION_FAILED без расширения, OcrNormalizer в lifespan (D-2, D-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 01:36:28 +04:00
Andrey Epifancev
c00823c673 feat(hotfolder): SMB — rename без замены и cleanup под флагом (D-1, D-2) 2026-10-07 01:21:51 +04:00
Andrey Epifancev
5c2d89bef3 fix(hotfolder): пустой тест, неоспоримая публикация при сбое удаления .part, validate_name в cleanup (T11.1, T11.2, T11.4)
- T11.1: добавлен test_keeps_part_file_on_failure_when_cleanup_disabled (cleanup=False);
  переименован test_cleanup_failure_does_not_mask_original_error (cleanup=True).
- T11.2 (R-8): завершающий part.unlink после успешного link обёрнут в contextlib.suppress(OSError);
  файл уже доставлен, ошибка удаления .part её не отменяет.
  Добавлен test_successful_put_despite_final_unlink_failure: успешный link,
  затем PermissionError в unlink → put НЕ падает, целевой файл на месте.
- T11.4: cleanup вызывает validate_name(name) перед удалениями (защита от выхода за пределы).
  Добавлен test_rejects_unsafe_names в TestCleanup.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 01:09:29 +04:00
Andrey Epifancev
f7ce9f4cdc feat(hotfolder): имя <job_id>.<ext>, запись без замены, cleanup под SCAN_CLEANUP (D-1, D-2)
- inbox_name: возвращает <job_id>.<ext> (суффикс в нижнем регистре, 1–16 символов [0-9a-z])
- put: атомарная запись через os.link (без замены), запасной путь os.replace при EPERM/EXDEV/ENOTSUP
- cleanup: удаляет ответ, входящий файл, временные файлы при флаге cleanup_enabled
- LocalHotFolder: параметр cleanup: bool = False, атрибут cleanup_enabled
- SmbHotFolder: параметр cleanup: bool = False (заглушка, логика в Task 12)
- create_hotfolder: передаёт cleanup=settings.SCAN_CLEANUP обоим конструкторам
- SpyHotFolder: метод cleanup для шпионажа за очисткой
- Удалены: MAX_PART_NAME_BYTES, _fit_name, _MAX_EXTENSION_CHARS, _utf8_len (имя всегда короткое)
- Тесты: TestInboxName с новым форматом, TestPutWithoutReplace (без замены),
  TestCleanup (включена/выключена), cleanup_enabled в фабрике
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 01:01:09 +04:00
Andrey Epifancev
ebbeff58bd test(normalizer): golden на реальных ответах ИС, make golden-update (D-8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:47:42 +04:00
Andrey Epifancev
2a7ceca334 feat(normalizer): OcrNormalizer — документы из успешных файлов, статусы done/needs_check/error (D-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:42:44 +04:00
Andrey Epifancev
8ebf572a22 fix(normalizer): values по числу колонок, предупреждения редакций и колонок (T8.1–T8.6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:37:30 +04:00
Andrey Epifancev
80622ed331 feat(normalizer): сборка statementDocument из таблицы OCR — тип, колонки, строки, редакции (D-6, D-7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:30:22 +04:00
Andrey Epifancev
6882493d98 feat(normalizer): шапка документа из data_ocr — ИНН, единицы, дата отчёта (§11.1 п. 8)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:25:48 +04:00
Andrey Epifancev
462ea9ee32 feat(normalizer): строки таблицы OCR — шапки, классы строк, тип по кодам (§11.1 п. 3–5)
Перенос _classify_row, _parse_table_rows, _synthetic_header_cells, _resolve_table_type
из reference-old-arm с адаптацией к новой архитектуре:
- Cell dataclass вместо dict
- ParsedTable вместо list возврата
- Fallback для row_index если не int
- Безопасная обработка пустых кодов в table_type_by_codes

14 тестов пройдено (2 на реальных данных fixtures).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:21:20 +04:00
Andrey Epifancev
0123dc0067 feat(normalizer): раскладка колонок OCR — шапка «Код» и вывод по данным (§11.1 п. 3)
Перенос `_build_layout`, `_infer_layout`, `_cells_by_index`, `_cell_value` из старого ядра.
- Cell dataclass (frozen): значение и уверенность из ячейки OCR
- build_layout: раскладка из строки с ячейкой «Код…» (заголовок)
- infer_layout: раскладка из строк данных по кодам (когда заголовка нет)
- Справочник кодов приходит параметром, ячейки — Cell вместо словарей

R-1 (design #18): тест test_aep3_like_rows_without_header исправлен:
- столбец 5 содержит только мусор (пусто, ':', '+ :') → не входит в value_idxs
- столбец 4 содержит суммы ('3', '5 678') не реже мусора ('--') → входит
- замена assertion на layout.value_idxs == (2, 4) вместо ошибочного условия

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:15:13 +04:00
Andrey Epifancev
60092b8293 feat(normalizer): разбор сумм OCR — перенос _parse_amount (§11.1 п. 7)
Переносим функции парсинга сумм и проверки пустоты из старого ядра:
замена запятой, удаление пробелов, извлечение круглых скобок (отрицательное),
множественные точки как разделители тысяч. Отличие: verified вычисляется
ДО постановки минуса (решение №13 design.md), чтобы не отмечать суммы
в скобках как неверифицированные.
2026-10-07 00:10:20 +04:00
Andrey Epifancev
5f56f62dbb feat(normalizer): разбор дат из OCR — дословный перенос date_utils старого ядра (§11.1 п. 2)
Портирован модуль date_utils.py из DKB_ARMKA с перечисленными в §11.1 правками:
- удалены _LOOKALIKES, _LOOKALIKE_CHARS, _NEAR_DIGIT, _normalize (перенесены в text.fix_lookalikes)
- переименована _parse_date в parse_date с новой сигнатурой (value: object -> date | None)
- обновлены аннотации типов: Optional[...] -> ... | None
- добавлена аннотация для _PATTERNS: list[tuple[re.Pattern[str], Callable[[re.Match[str]], date | None]]]
- добавлены noqa/type: ignore комментарии для прохода линтеров

Тесты: 35 тестов пройдены (парсинг дат в текстовых и числовых формах, отклонение названий организаций).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:06:14 +04:00
Andrey Epifancev
746d993077 fix(normalizer): замена неразрывного пробела в fix_lookalikes, тест
Исправлена строка 26 в fix_lookalikes: текст.replace("\xa0", " ") для
корректной замены неразрывного пробела (U+00A0) на обычный пробел (U+0020),
как в источнике date_utils._normalize.

Добавлен тест test_nbsp_replacement для проверки замены NBSP с таблицей
двойников и без неё.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 00:00:31 +04:00
Andrey Epifancev
cea8dfe485 feat(normalizer): очистка OCR-текста — двойники, разделитель клеток, clean_value (§11.1 п. 1, 6)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 23:56:16 +04:00
Andrey Epifancev
ecdf9a4e13 fix(normalizer): опечатка кода 1220 в справочнике 66n, тест данных редакции
I-1: Исправлена опечатка в api/rules/statement_lines.yaml код 1220:
- было: 'ценностями' (неправильный падеж)
- стало: 'ценностям' (правильный падеж, соответствует брифу)

I-2: Добавлен тест test_66n_edition_data_integrity() в TestRealRules:
- проверяет точное количество кодов в редакции 66n (63)
- валидирует специфические коды из брифа: 1220 (опечатка), 1105, 2411, 2412
- тест фиксирует данные редакции 66n, предотвращает проскальзывание опечаток

Результат: 760 тестов пройдено (749 + 11 новых), lint чист.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 23:44:44 +04:00
Andrey Epifancev
393d65208f feat(normalizer): справочники yaml с редакциями форм, загрузчик правил, SCAN_CLEANUP/SCAN_RULES_DIR (D-7)
Справочники нормализатора OCR:
- api/rules/statement_lines.yaml: редакции форм ББ/ОФР (66n до 2024-12-31, fsbu4 с 2025-01-01)
- api/rules/ocr_lookalikes.yaml: замены букв на цифры (из date_utils._LOOKALIKES)
- api/rules/statement_labels.yaml: подстроки label → тип документа (BALANCE/INCOME)

Модуль rules.py:
- Edition: редакция с интервалом дат и таблицей кодов → наименований
- OcrRules: все справочники + методы edition_for(), edition_by_codes(), known_codes(), editions_with()
- load_rules(): загрузчик с валидацией (коды 4–5 цифр, двойники 1→1, файлы обязательны)

Настройки:
- SCAN_CLEANUP: удаление файлов из горячей папки (по умолчанию false)
- SCAN_RULES_DIR: путь к справочникам (по умолчанию 'rules')

TDD: 10 тестов (TestRealRules, TestEditionChoice, TestValidation, test_settings_defaults)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 23:33:54 +04:00
Andrey Epifancev
55c017e819 chore(contract): копия docs/contracts — $comment без «1.1», пример Excel done, OCR-пример без checks, описание мягких нарушений (M-12, OQ-138)
Refs RSHB/arm-tabular-parser#20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-06 23:17:33 +04:00
Andrey Epifancev
e2f1bb5f4a chore(contract): копия схемы v1.2 и модели (DISCLOSURE, checks, границы периода), без изменения логики
Refs RSHB/arm-tabular-parser#14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 14:30:44 +04:00
Andrey Epifancev
b6d7753a80 Срез 0 [7.5]: M-4 — валидация SCAN_SETTLE_SECONDS < SCAN_TIMEOUT_SECONDS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:04:19 +04:00
Andrey Epifancev
8c3c06266c Срез 0 [7.5]: M-2 — склейка и сериализация ответа в потоке, сбой сериализации как сбой записи raw (закрывает T3.1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:04:19 +04:00
Andrey Epifancev
5b16a53a99 Срез 0 [7.5]: M-1 — TIMEOUT при чередующихся ошибках опроса несёт описание последней ошибки (R-39, design #80)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:02:48 +04:00
Andrey Epifancev
877541db10 Срез 0 [7.4]: README и CLAUDE.md — набор ответа, Tmp, settle, склеенный raw; уточнение D-5, отметки 7.4
Раскладка Output/<job_id>/*.json (склеиваются все файлы), SCAN_TMP_DIR, SCAN_SETTLE_SECONDS, структура raw/{job_id}.json,
успех = error.code == "", ошибочные файлы в metrics.warnings, правило набора эмулятора. D-5: не строковый error.code
тоже ошибка (UNKNOWN_ERROR, R-32). Решения R-36 (design #76). Тест TestAnswerSet на реальных фикстурах помечен fixtures.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:08:07 +04:00
Andrey Epifancev
68389d5cc2 Срез 0 [7.4]: интеграционные тесты — наборы ответа ИС на реальных фикстурах (aep_3/aep_6/aep_5.PDF)
Сквозные сценарии с реальными NATS и MinIO: aep_3 (два файла, warnings про _ofr.json, склеенный raw),
aep_6 (NOT_IMPLEMENTED без metrics), aep_5.PDF (INVALID_DOCUMENT, повтор запроса), unknown (заглушка эмулятора).
settle 0.2 с, SCAN_TMP_DIR передаётся эмулятору. Исправлено устаревшее ожидание data_ocr в сценарии unknown (R-28).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:04:30 +04:00
Andrey Epifancev
24af6f1b9a Срез 0 [7.3]: эмулятор — набор файлов ответа на задание
Для `<uuid>_<name>.<ext>` эмулятор собирает все `<name>.json` и `<name>_*.json` из SCAN_FIXTURES_DIR (не префикс
`<name>*`) и кладёт их в `Output/<uuid>/` как `<uuid>.json` / `<uuid>_<suffix>.json` с `file_uid = <uuid>`. Набор
пишется в `<root>/<SCAN_TMP_DIR>/.<uuid>.answer` и переименовывается в каталог ответа целиком (R-26). Ответ «нет пары» —
`data_ocr` объектом с пустыми полями (R-28). Решения исполнителя — design №75 (R-35).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:48:57 +04:00
Andrey Epifancev
973cf2717f Срез 0 [7.2]: склейка ответа ИС, сырой raw/ = склейка, NullNormalizer по файлам, metrics.warnings
- Исправлена критическая ошибка: NullNormalizer считал ответ ошибкой при `error is not None`,
  а у реального успеха ИС error = {"code": "", "message": ""} — aep_3.json и aep_6.json давали
  UNKNOWN_ERROR. Успех файла теперь — error.code == "" (отсутствующий/null/не-объектный error
  и error без кода ошибкой не считаются); регрессия — на реальных фикстурах (маркер fixtures).
- src/gateway/merge.py: merge_answers(job_id, files) -> MergedResponse
  {"job_id", "files": [{"name", "response", "parse_error"}]} по возрастанию имени; не JSON —
  parse_error файла; dump_merged — UTF-8 (с ASCII-экранированием при одиночном суррогате).
- Воркер: опрос отдаёт готовый набор → склейка → raw/{job_id}.json (application/json) до
  нормализации → Normalizer.normalize(склейка). Перечитывание «не JSON» убрано (R-24).
- NullNormalizer: все файлы ошибочны — код/сообщение первого по имени (parse_error и
  не-объект — INVALID_FORMAT, код вне контракта — UNKNOWN_ERROR с исходным кодом); есть
  успешный — NOT_IMPLEMENTED; ошибочные файлы — строки `<имя>: <код ИС>: <сообщение>`.
- NormalizeOutcome.warnings (R-25) → metrics.warnings конверта и файла результата; пустые
  опускаются; не больше 100 строк по 1000 символов, лишние — сводной строкой (R-30).
- Тесты: test_merge.py, test_normalizer.py переписан под склейку, test_worker.py — склейка в
  raw, ошибка одного файла, parse_error в warnings, все ошибочны, реальные aep_3+aep_3_ofr и
  aep_6, settle и таймаут ненастоявшегося набора; SUCCESS_ANSWER — в форме реального успеха.
  Интеграционный test_roundtrip.py минимально переведён на формат склейки (прогон — Task 5).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:27:07 +04:00
Andrey Epifancev
83e55af941 Срез 0 [7.2, fix T2.1 T2.2 T2.4]: горячие папки — повторный листинг после чтения, ENOTDIR в SMB
- T2.1: после чтения набора каталог ответа листается заново (Local и SMB); если снимок (имена и
  размеры) отличается от настоявшегося — None, набор настаивается заново.
- T2.2: SmbHotFolder — ENOTDIR из листинга Output/<job_id> (на месте каталога файл) — None,
  как у LocalHotFolder.
- T2.4: тест SMB — ENOENT / SHARING_VIOLATION / ENOTDIR во время настаивания сбрасывают
  накопленное состояние settle (pending_jobs() == 0).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:18:29 +04:00
Andrey Epifancev
e8453159e8 Срез 0 [7.1]: горячие папки — набор *.json ответа, готовность по settle, служебный каталог Tmp
- HotFolder.poll(job_id) -> list[AnswerFile] | None: все *.json каталога <output>/<job_id>/ по возрастанию
  имени, когда набор не пуст и имена/размеры не менялись SCAN_SETTLE_SECONDS (SettleTracker: состояние по
  job_id, внедряемые часы, сброс при выдаче/опустении, предел 1024 задания, потокобезопасно);
- put: временный .<name>.part в <корень>/<SCAN_TMP_DIR>/ (создаётся), rename в Input (Local и SMB);
- настройки SCAN_TMP_DIR (Tmp) и SCAN_SETTLE_SECONDS (2, >= 0); в .env.test settle = 0 (R-23);
- воркер: переходная прослойка — берётся первый файл набора (склейка всех — Task 3, R-22).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:01:39 +04:00
Andrey Epifancev
ee272b2b92 Срез 0 [fix T2.2]: удалён мёртвый _normalize_root_path из app.py
Функция осталась от шаблона и нигде не вызывается (grep по api/, включая тесты).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 05:04:07 +04:00
Andrey Epifancev
ef22e3b075 Срез 0 [fix T7.3, T7.5]: inbox_name укорачивает длинные имена; сбой удаления .part не маскирует ошибку
T7.3: имя из 124+ кириллических символов давало `.part` длиннее 255 байт -> ENAMETOOLONG на ext4 и SMB/NTFS
-> ложный RECOGNITION_FAILED. `inbox_name` обрезает основу по code points так, чтобы `.<job_id>_<имя>.part`
занимал не больше 240 байт UTF-8, сохраняя расширение (до 16 символов после последней точки); короткие имена не
меняются, ValueError — по-прежнему только для пустого имени, `.` и `..`.
T7.5: `LocalHotFolder.put` подавляет OSError при удалении `.part` (`contextlib.suppress`), наружу выходит исходное
исключение; в `smb.py` это уже было.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 05:04:00 +04:00
Andrey Epifancev
d288d19622 Срез 0 [fix T12.4]: тест суррогата в original_name назван по фактическому поведению
Запрос с одиночным суррогатом в `original_name` отвергается схемой (pydantic `string_unicode`), а не «не ломает
публикацию»: тест переименован в `..._is_schema_mismatch` и проверяет код SCHEMA_MISMATCH, отсутствие ack и `put`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 05:02:43 +04:00
Andrey Epifancev
0420c85219 Срез 0 [fix T12.1]: SCHEMA_MISMATCH для задания в работе/обработанного не публикуется
Конверт `result:<job_id>:<attempt>` битого запроса совпадал по Nats-Msg-Id с настоящим результатом, и ядро
(дедуп по result_msg_id) отбросило бы настоящий итог. `_reject` нормализует job_id (`uuid.UUID`, нижний регистр)
и при `_seen` только пишет предупреждение. Тест: битый запрос с тем же job_id во время ожидания ИС и после итога,
в том числе UUID в верхнем регистре — опубликованы только ack и настоящий итог.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 05:02:28 +04:00
Andrey Epifancev
ad3fb34dba Срез 0 [fix C-1]: пакет src не создаёт приложение — эмулятор стартует без настроек шлюза
`api/src/__init__.py` оставлен только с docstring: `python -m src.scan_emulator` больше не импортирует `src.app`
и не требует `OPENBAO__*`. `run_server.py` импортирует `app` из `src.app`, `get_settings` — из `src.core.config`.
Регрессионный тест: subprocess эмулятора с окружением только PATH/ENV_FILE/SCAN_HOTFOLDER_PATH -> код 2,
сообщение про корень горячей папки, без ValidationError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 05:01:16 +04:00
Andrey Epifancev
58bcae2a57 Срез 0 [6.3]: прогон DoD, предложения для docs и правки .env.example
- open-questions-proposal.md: compose-фрагмент scan-emulator/scan-gateway, таблица предложений по контракту и OQ,
  чеклист для человека (файлы в ../docs не менялись)
- .env.example: SCAN_EMULATOR_POLL_SECONDS; уточнён комментарий PUBLISH_RETRIES (повторы после первой попытки)
- отмечена задача 16 плана и пункт 6.3 tasks.md

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 04:44:33 +04:00
Andrey Epifancev
6552ebe345 Срез 0 [6.1]: сквозной интеграционный тест test_roundtrip.py, отметка задачи
Lifespan приложения и эмулятор ИС в процессе теста (R-7) против compose NATS/MinIO, горячая папка в tmp-каталоге.
Три сценария (R-10): aep_5.pdf (реальная фикстура) — INVALID_DOCUMENT от ИС, повтор во время ожидания и после
результата даёт ещё один ack без второго файла; ok_doc.pdf (синтетический успех) — NOT_IMPLEMENTED; unknown.pdf —
INVALID_DOCUMENT эмулятора. Проверяются схема конверта, service.name, Nats-Msg-Id, raw/<job_id>.json байт в байт,
входящий файл против объекта S3, /healthcheck, остановка без незавершённых задач и ошибок в логе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 04:23:10 +04:00
Andrey Epifancev
d74b56a0d1 Срез 0 [5.4]: тест параллелизма воркера (test_worker_concurrency.py), отметка задачи
Сценарий spec «Пять заданий подряд» переписан детерминированно: паузы опроса горячей
папки управляются тестом (SteppedSleep), ожидания — по событиям с тайм-аутом. Проверяется:
пять ack'ов до освобождения слота, ровно четыре файла и четыре чтения S3 при занятых слотах,
пятый файл — только после итога освобождённого задания, у всех пяти — итоговый конверт.
Добавлены строгая очерёдность при SCAN_MAX_INFLIGHT = 1 и остановка воркера при очереди
на слот (T12.9): задачи отменены, утечек задач и слотов нет, новых файлов нет.
Прежний test_five_jobs_with_four_slots удалён из test_worker.py (без дублирования).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 04:07:38 +04:00
Andrey Epifancev
d199414e9c Срез 0 [5.4]: обвязка тестов воркера — в tests/worker_helpers.py и tests/unit/conftest.py
Фейки S3/NATS, шпион горячей папки, Env, эмулятор ИС и eventually вынесены из test_worker.py
без изменения логики, чтобы ими пользовался отдельный тест параллелизма. make_env получил
подмену sleep (пауза между опросами горячей папки).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 04:03:47 +04:00
Andrey Epifancev
09f0bb32e2 Срез 0 [5.3]: /healthcheck.nats и .hotfolder, воркер и NATS в lifespan, отметка задачи
src/core/health.py: HealthProvider — nats ok/down/disabled по is_connected; hotfolder —
healthy() в потоке под wait_for (2 с) с кэшем 5 с, зависшая проба не запускается
повторно (T8.4). Сбой регистрации SMB-сессии виден естественно: SmbHotFolder.healthy()
обращается к шаре с теми же учётными данными (T2.3).

app.py: в lifespan S3Storage, NatsGateway, Worker(NullNormalizer); подключение к NATS
и подписка — фоновой задачей (connect ждёт сервер), её сбой — logger.exception и
nats=down без падения HTTP; остановка отменяет подключение, останавливает воркер,
закрывает NATS и S3. Объекты lifespan — в app.state.

test_app.py: тесты lifespan переведены на NATS_ENABLED=false и tmp-корень (иначе фоновое
подключение шло бы в compose NATS), проверка полей healthcheck расширена nats/hotfolder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:50:22 +04:00
Andrey Epifancev
ea49abd03b Срез 0 [5.3]: воркер шлюза (src/core/worker.py) и unit-тесты сценариев spec
on_request проверяет тело (SCHEMA_MISMATCH без ack, R-16), дедуплицирует по job_id
(задания в работе + LRU DONE_CACHE_SIZE, R-17) и запускает задачу: ack до чтения S3 и
до ожидания слота, семафор SCAN_MAX_INFLIGHT, handle. handle: S3 (один бакет, R-19) ->
горячая папка через asyncio.to_thread (R-15) -> опрос до min(reply_deadline, старт +
SCAN_TIMEOUT_SECONDS) (R-14, R-20) -> raw/{job_id}.json до нормализации (R-18) ->
нормализатор -> results/{file_id}/{job_id}/result.json -> конверт. Таблица ошибок D-7,
публикация через NatsGateway с повторами, PublishFailed логируется. Текст ошибки
очищается от одиночных суррогатов и ограничен 4000 символами (T10.1, T10.2).

Тесты: фейки S3/NATS с общим журналом событий, настоящий LocalHotFolder и process_once
эмулятора; каждый конверт проверяется JSON Schema контракта. Фикстура log_lines в
conftest перехватывает loguru. src.core.worker и src.core.health — в строгом mypy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:48:37 +04:00
Andrey Epifancev
c3a62bb27c Срез 0 [5.2] T11.1: PUBLISH_RETRIES — число повторов после первой попытки (до 4 попыток), R-13
publish делает первую попытку и до retries повторов (retries=0 — одна попытка), PublishFailed.attempts —
фактическое число попыток. Комментарий PUBLISH_RETRIES в config.py уточнён. Докстринги: PublishFailed не значит
«не доставлено» (T11.3), колбэк подписки должен возвращаться быстро (T11.4).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:35:24 +04:00
Andrey Epifancev
5d82607715 Срез 0 [5.2]: слой NATS без JetStream (src/core/nats.py), интеграционные тесты, отметка задачи
NatsGateway: подключение с nkeys/JWT или без учётных данных, NATS_ENABLED=false — без подключения,
автопереподключение nats-py, subscribe_requests в queue group scan-gateway, publish с Nats-Msg-Id и
повторами (PUBLISH_RETRIES попыток всего) -> PublishFailed, publish_result для конвертов ack/result.
src.core.clients и src.core.nats — в strict-списке mypy. Интеграционные тесты против compose-NATS/MinIO:
queue group, заголовок Nats-Msg-Id, roundtrip S3, отсутствующий ключ.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:27:35 +04:00
Andrey Epifancev
c6dfb3e22c Срез 0 [5.2]: S3Storage (src/core/clients.py): get_bytes, put_bytes, put_json, from_settings с secure
Ошибки не проглатываются: S3ObjectNotFoundError (NoSuchKey) и S3ReadError для чтения, S3WriteError для записи,
исходная причина — в __cause__. JSON пишется в UTF-8 без экранирования, Content-Type application/json.
Шаблонные put_object/get_object удалены (не использовались); удалён и шаблонный nats_callback.py — его
единственный импорт S3Storage, сам он нигде не используется (слой NATS — следующим коммитом в src/core/nats.py).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:27:35 +04:00
Andrey Epifancev
f448992e16 Срез 0 [5.1]: нормализатор ответа ИС (src/normalizer): Normalizer, NormalizeOutcome, NullNormalizer
Ошибка ИС пробрасывается с кодом из ответа, неизвестный код/вид ошибки -> UNKNOWN_ERROR с исходным
представлением, успех -> NOT_IMPLEMENTED, не объект -> INVALID_FORMAT. Пакет добавлен в strict-список mypy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:18:17 +04:00
Andrey Epifancev
0ea59da2a3 Срез 0 [4.3] T9.1: test_exact_beats_prefix различает точное имя и префикс
Пара фикстур a_b-1.json / a_b.json: префикс по алфавиту раньше точного имени, поэтому тест падает,
если шаг «сначала <name>.json» убран. Убран посторонний комментарий в соседнем тесте.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:14:31 +04:00
Andrey Epifancev
b489f6e0b3 Срез 0 [4.3]: эмулятор ИС Сканирование (src/scan_emulator), unit-тесты, отметка задачи
process_once(root, fixtures, delay): новые <uuid>_<имя> -> ответ из фикстуры (точное имя, затем префикс,
file_uid подставляется) либо INVALID_DOCUMENT, атомарная запись ответа, затем переименование в .done.
Цикл python -m src.scan_emulator с корректной остановкой по SIGINT/SIGTERM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:10:36 +04:00
Andrey Epifancev
5fb046fced Срез 0 [4.2] T8.1-T8.3: inbox_name заменяет символы Windows/SMB, poll ждёт занятый файл, put терпит отказ mkdir при существующем Input
T8.1: inbox_name заменяет `: < > " | ? *` и управляющие символы на `_`, убирает завершающие точки/пробелы;
ValueError только если имени не осталось. validate_name/put остаются строгими.
T8.2: SmbHotFolder.poll возвращает None на STATUS_SHARING_VIOLATION/STATUS_DELETE_PENDING (ИС ещё пишет ответ),
монопольное открытие сохранено.
T8.3: при ошибке mkdir, кроме EEXIST/ENOENT, put продолжает, если smbclient.path.isdir(Input); иначе исходная ошибка.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:05:02 +04:00
Andrey Epifancev
1695573b35 Срез 0 [4.2]: SmbHotFolder поверх smbclient (src/gateway/smb.py), фабрика create_hotfolder, unit-тесты, отметка задачи
SmbHotFolder: UNC-корень \\host\SMB_PATH (или полный UNC в SMB_PATH), put — `.<name>.part` + smbclient.replace,
poll — open_file(rb), ENOENT (в т.ч. нет каталога) -> None, healthy — listdir корня без исключений.
create_hotfolder(settings): local -> LocalHotFolder, smb -> SmbHotFolder с ленивым импортом модуля (R-6);
отсутствие импорта src.gateway.smb в режиме local проверяется в отдельном интерпретаторе.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 02:57:31 +04:00