docs(scan-gateway): README и CLAUDE.md по итогам финального ревью Среза 3a

Статус при ошибочном файле набора (№28), новые предупреждения (№29, формулировка ИНН), правило Money (№30), длина дат (№31),
дубли yaml (№33), очистка удаляет всё содержимое Output/<job_id>/, процедура заполнения fsbu4 со списком тестов с пустой
редакцией; SCAN_CLEANUP и SCAN_RULES_DIR в .env.example.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 03:35:11 +04:00
parent a65ff8cf40
commit 576ce2c205
3 changed files with 61 additions and 19 deletions

View File

@ -17,12 +17,13 @@
§11 и §11.1: правило, которого там нет, не реализуется, а поднимается вопросом. Функции очистки
(`src/normalizer/ocr/`) перенесены из старого ядра **дословно** (единственное исключение для `reference-old-arm`):
при правке сначала разберись, что делает источник; расхождения с ним — только решением, записанным в
`openspec/changes/slice-3a-ocr-normalizer/design.md` (таблица «Decisions made without discussion»: №12, №13, №20, №22).
`openspec/changes/slice-3a-ocr-normalizer/design.md` (таблица «Decisions made without discussion»: №12, №13, №20, №22,
№30 — `Money` не больше двух знаков после запятой, №31 — тексты дат длиннее 200 символов не разбираются).
Функции `ocr/*` — чистые: без I/O, без настроек, без логов.
- Справочники — данные: `api/rules/*.yaml` (редакции форм `66n` / `fsbu4`, двойники, `label` → тип), правила не
зашиваются в код. `fsbu4` пока пуста — данные кладёт владелец, код при этом не меняется.
- Статус: `done` только когда всё распознано (`confidence` 100, даты разобраны, редакция определена по дате и заполнена,
нет предупреждений); иначе `needs_check`; ни одной таблицы → `error RECOGNITION_FAILED`. Предупреждения — строки
нет предупреждений) и ни один файл набора не ошибочен (№28); иначе `needs_check`; ни одной таблицы → `error RECOGNITION_FAILED`. Предупреждения — строки
`metrics.warnings` (`<имя json>: table:<i>: <текст>`); при изменении их текстов обнови README (раздел «Предупреждения»)
и golden.
- Эталоны нормализатора лежат в **fixtures** (`../fixtures/ocr/expected_normalize/`): `make golden-update`, diff смотреть
@ -39,7 +40,7 @@
- Успех файла ответа — `error.code == ""` (`error` при этом не `null`!), ошибка — непустой `error.code`,
`parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна.
Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть
успешный — документы из успешных файлов. `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только
успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только
для тестов воркера).
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы (запасной
путь — коды строк). `checks` в документах не передаются — их считает ядро (Срез 3b).

View File

@ -92,8 +92,11 @@ ack (с `attempt` повторного запроса); файл в ИС вто
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
`raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
- **Что удаляется** (всё, что есть): `Output/<job_id>/*.json` и сам каталог `Output/<job_id>/`, `Input/<job_id>.<ext>`,
`Input/<job_id>.<ext>.done` (след эмулятора), `Tmp/.<job_id>.<ext>.part`. Отсутствующий файл не ошибка.
- **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output/<job_id>/` (не только `*.json`) и сам каталог,
`Input/<job_id>.<ext>`, `Input/<job_id>.<ext>.done` (след эмулятора), `Tmp/.<job_id>.<ext>.part`. Отсутствующий файл
или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а
подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки
поднимается и логируется, остальные пункты (входящий файл, `.done`, `.part`) в этом задании не удаляются.
- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во
входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются.
- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт,
@ -171,13 +174,22 @@ ack (с `attempt` повторного запроса); файл в ИС вто
- `values` — по одному на колонку документа; `amount` — число (в файле результата деньги строкой) или `null`;
`string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не
отбрасывал символов`.
- `header.report_date` — после замены двойников; `inn` — только цифры.
- **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками
(`1,234`, `0,125`: пробел тысяч, распознанный как запятая) не принимается: `amount = null`, `string_value` — текст
ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Сумма вида
`1.234,56` (несколько точек) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) —
с `is_recognized = false`.
- `confidence` ячеек и полей шапки приводится к диапазону 0–100, `NaN` и бесконечность дают 0 (`json.loads` принимает
`NaN`/`Infinity`, а контракт требует 0–100).
- `header.report_date` — после замены двойников; `inn` — только цифры. Тексты дат (дата отчёта, подпись колонки) длиннее
200 символов не разбираются и не проходят замену двойников (решение №31: регулярки дат на длинном тексте работают
неприемлемо долго).
### Справочники `api/rules/`
Данные, а не код; загружаются один раз при старте из `SCAN_RULES_DIR` (по умолчанию `rules`, путь от `api/`) с
pydantic-валидацией; битый файл (код не из 4–5 цифр, пустое наименование, дубль `id`) — сервис не стартует, в ошибке
имя файла.
pydantic-валидацией; битый файл (код не из 4–5 цифр, пустое наименование, дубль `id`, **повторяющийся ключ yaml** — например,
один код строки дважды в `lines`; решение №33) — сервис не стартует, в ошибке имя файла (и ключ с номером строки).
| Файл | Содержимое |
|---|---|
@ -199,20 +211,37 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
`make golden-update` и `git -C ../fixtures diff` (эталоны документов 2025 года изменятся: имена строк и предупреждения —
объяснить в PR fixtures), перезапуск сервиса (справочники читаются при старте).
Тесты, которые закрепляют **пустую** `fsbu4` и после заполнения упадут — их нужно обновить (список получен поиском по
`fsbu4` / `не заполнена`):
- `api/tests/unit/normalizer/test_document.py`: `TestTypeAndEdition::test_edition_2025_is_empty` (имя строки из OCR,
предупреждение «редакция fsbu4 не заполнена») и `TestEditionWarnings::test_empty_edition_gives_single_warning`
(`warnings == ["редакция fsbu4 не заполнена"]`);
- `api/tests/integration/test_roundtrip.py`, сценарий 3 (`aep_6.pdf`, тест `test_real_fixture_aep_6_needs_check_with_two_documents`:
строки «редакция fsbu4 не заполнена» для `table:0` и `table:1`) и сценарий 4 (`ok_doc.pdf`, тест
`test_synthetic_success_answer_gives_needs_check`: имя строки `1600` «БАЛАНС» из OCR, единственное предупреждение про
пустую редакцию, статус `needs_check`; при заполненной редакции с кодом `1600` этот документ станет `done`), а также
комментарии к ним в начале файла;
- эталоны `../fixtures/ocr/expected_normalize/aep_6.json` (предупреждения «редакция fsbu4 не заполнена», имена строк
2025 года из OCR) — их проверяет `api/tests/unit/normalizer/test_golden.py` (обновляются `make golden-update`);
- `api/tests/unit/normalizer/test_ocr_normalizer.py` и `api/tests/unit/test_worker.py` (`aep_6`, `SUCCESS_ANSWER` с датой
2025 года) пустую `fsbu4` напрямую не закрепляют: проверяют `needs_check`, который остаётся из-за `confidence` < 100.
### Статус результата
- Все файлы набора ошибочны — `error` с кодом первого ошибочного файла по имени (см. «Коды ошибок»).
- Из успешных файлов не собран ни один документ (нет ни одной таблицы с шапкой или столбцом кода, `table_structure`
пуст или это не объект `data_ocr`) — `error`, `RECOGNITION_FAILED`, сообщение «ни одной таблицы не разобрано»;
причины по таблицам — в `metrics.warnings`; `raw_s3_key` заполнен.
- Документы есть — `done`, если всё распознано: у всех документов `inn_confidence` и `organization_name_confidence`
- Документы есть — `done`, если всё распознано и ни один файл набора не ошибочен: у всех документов `inn_confidence` и `organization_name_confidence`
равны 100; дата отчёта разобрана и распознана; все подписи колонок разобраны в даты; все `line_code_confidence` и
`line_name_confidence` равны 100; все значения `is_recognized`; тип по `label` совпал с типом по кодам; редакция
определена по дате отчёта или дате колонки и заполнена; нет предупреждений уровня таблицы и файла нормализации. Иначе `needs_check`.
Единицы (`units`) в критерий не входят (в контракте у поля нет `confidence`). Реальные сканы почти всегда дают
`needs_check`: `done` означает лишь «нечего подсвечивать аналитику».
- Ошибочный файл набора при наличии успешного (например, `<job_id>_ofr.json` с `INVALID_DOCUMENT`) статус сам по себе не
понижает: он попадает в `metrics.warnings` строкой `<имя>: <код ИС>: <сообщение>`.
- Ошибочный файл набора при наличии успешного (например, `<job_id>_ofr.json` с `INVALID_DOCUMENT`) **понижает статус до
`needs_check`** (решение №28: потерянный ОФР не должен остаться незамеченным под `done`); документы собираются из
успешных файлов, а ошибочный файл попадает в `metrics.warnings` строкой `<имя>: <код ИС>: <сообщение>`.
- `metrics.rows` — число строк во всех документах. Файл результата `results/{file_id}/{job_id}/result.json` пишется для
`done` и `needs_check`; предупреждения (`metrics.warnings`) лежат и в конверте, и в файле результата.
@ -225,10 +254,13 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
| Уровень | Текст |
|---|---|
| файл | `ИНН не распознан` (ИНН нет или `confidence` ≠ 100) |
| файл | `ИНН не распознан` (в поле `inn` нет цифр, ИНН не извлечён) |
| файл | `ИНН распознан неуверенно` (ИНН извлечён, но `confidence` ≠ 100 или из текста убран мусор) |
| файл | `дата отчёта не разобрана` |
| файл | `data_ocr отсутствует или не объект` |
| таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются) |
| таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются; также когда `rows` не список или у таблицы есть шапка, но нет ни одной строки данных) |
| таблица | `строк с нечисловым кодом пропущено: <N>` (непустой код, не целое число: `1150 :`, `изо :`; одно на таблицу) |
| таблица | `строк без кода с суммами пропущено: <N>` (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) |
| таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) |
| таблица | `колонка <i> без даты: <подпись колонки>` |
| таблица | `нет колонок значений` |
@ -283,7 +315,7 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
| `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) |
| `SCAN_TIMEOUT_SECONDS` | `300` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`) |
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет `Output/<job_id>/`, `Input/<job_id>.<ext>` (и `.done`), `Tmp/.<job_id>.<ext>.part`; `false` — ничего не удаляется (кроме `.part`, снятого самой записью); см. «Очистка горячей папки» |
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`), `Tmp/.<job_id>.<ext>.part`; `false` — ничего не удаляется (кроме `.part`, снятого самой записью); см. «Очистка горячей папки» |
| `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`) |
@ -435,9 +467,12 @@ make golden-update # пересобрать эталоны нормализ
- Редакция `fsbu4` (отчётность с 2025 года) пока пуста: имена строк документов 2025 года берутся из OCR, предупреждение
«редакция fsbu4 не заполнена», статус `needs_check`. Состав редакции — данные владельца (см. «Справочники»).
- Строки, чей код не целое число после обрезки пробелов (например, `1150 :` — перенос `_classify_row` старого ядра:
считается повторной шапкой), и строки без кода **пропускаются молча**, без предупреждения; в документ не попадают,
остаются в `raw/`.
- Строки, чей код не целое число после обрезки пробелов (например, `1150 :`), разбор относит к классу `HEADER` и
пропускает (перенос `_classify_row`; раскладка колонок при этом **не** переписывается — её меняет только строка с
ячейкой «Код…»). Строки с пустым кодом тоже пропускаются. В документ они не попадают и остаются в `raw/`, но
таблица получает предупреждения `строк с нечисловым кодом пропущено: <N>` и `строк без кода с суммами пропущено: <N>`
(последнее — только если в строке есть сумма), то есть `needs_check` (решение №29). Сами строки не восстанавливаются
(OQ-149): в реальных сканах так теряются, например, «БАЛАНС» и «Выручка» с пустым кодом.
- Скобка, распознанная как `}` или `]` (`(7744536}`), знак минус не даёт: сумма остаётся положительной с
`is_recognized = false` (решение №12, дословный перенос `_parse_amount`; расширение набора закрывающих скобок — после
проверки на стенде, вынесено в открытые вопросы docs).
@ -447,8 +482,8 @@ make golden-update # пересобрать эталоны нормализ
`O` вместо нуля отбрасывается как не-целое (решение №22). Суммы разбираются `parse_amount` без замены символов.
- Повтор кода строки в таблице: остаётся первая строка, вторая теряется в документе (решение №15).
- `checks` в документах нет: соотношения §7 считает ядро (Срез 3b).
- Ошибочный файл набора при успешных статус не понижает (`done` возможен, предупреждение `<имя>: <код>: ...` остаётся
в `metrics.warnings`).
- Ошибочный файл набора при успешных даёт `needs_check` (`done` невозможен, решение №28); предупреждение
`<имя>: <код>: ...` остаётся в `metrics.warnings`.
- Расшифровки без кода (OQ-36) и склейка одной формы из нескольких таблиц (OQ-77) не обрабатываются: каждая таблица —
свой документ.

View File

@ -47,6 +47,12 @@ SCAN_SETTLE_SECONDS=2
SCAN_TIMEOUT_SECONDS=300
# Сколько заданий обрабатывается одновременно
SCAN_MAX_INFLIGHT=4
# Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output/<job_id>/, входящий файл
# (и его .done), осиротевший .part в Tmp; false (по умолчанию) — ничего не удалять
SCAN_CLEANUP=false
# Каталог справочников нормализатора OCR (statement_lines.yaml, ocr_lookalikes.yaml, statement_labels.yaml);
# относительный путь — от каталога запуска (api/); битый справочник — сервис не стартует
SCAN_RULES_DIR=rules
# --- SMB (только при SCAN_HOTFOLDER_MODE=smb; в режиме local весь блок не нужен) ---
# OPENBAO__SMB__SMB_HOST=smb.example.local