diff --git a/CLAUDE.md b/CLAUDE.md index 8b9673a..f1cd339 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:: <текст>`); при изменении их текстов обнови 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). diff --git a/README.md b/README.md index 83bc0fa..31a7ef0 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,11 @@ ack (с `attempt` повторного запроса); файл в ИС вто - **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в `raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС). -- **Что удаляется** (всё, что есть): `Output//*.json` и сам каталог `Output//`, `Input/.`, - `Input/..done` (след эмулятора), `Tmp/...part`. Отсутствующий файл не ошибка. +- **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output//` (не только `*.json`) и сам каталог, + `Input/.`, `Input/..done` (след эмулятора), `Tmp/...part`. Отсутствующий файл + или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а + подкаталог внутри `Output//` не даст снять каталог (`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` означает лишь «нечего подсвечивать аналитику». -- Ошибочный файл набора при наличии успешного (например, `_ofr.json` с `INVALID_DOCUMENT`) статус сам по себе не - понижает: он попадает в `metrics.warnings` строкой `<имя>: <код ИС>: <сообщение>`. +- Ошибочный файл набора при наличии успешного (например, `_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` не список или у таблицы есть шапка, но нет ни одной строки данных) | +| таблица | `строк с нечисловым кодом пропущено: ` (непустой код, не целое число: `1150 :`, `изо :`; одно на таблицу) | +| таблица | `строк без кода с суммами пропущено: ` (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) | | таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) | | таблица | `колонка без даты: <подпись колонки>` | | таблица | `нет колонок значений` | @@ -283,7 +315,7 @@ pydantic-валидацией; битый файл (код не из 4–5 ци | `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output//` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) | | `SCAN_TIMEOUT_SECONDS` | `300` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`) | | `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий | -| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет `Output//`, `Input/.` (и `.done`), `Tmp/...part`; `false` — ничего не удаляется (кроме `.part`, снятого самой записью); см. «Очистка горячей папки» | +| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output//` и каталог, `Input/.` (и `.done`), `Tmp/...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/`, но + таблица получает предупреждения `строк с нечисловым кодом пропущено: ` и `строк без кода с суммами пропущено: ` + (последнее — только если в строке есть сумма), то есть `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) не обрабатываются: каждая таблица — свой документ. diff --git a/api/.env.example b/api/.env.example index 00c367d..3c3641e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -47,6 +47,12 @@ SCAN_SETTLE_SECONDS=2 SCAN_TIMEOUT_SECONDS=300 # Сколько заданий обрабатывается одновременно SCAN_MAX_INFLIGHT=4 +# Очистка горячей папки после прочтения ответа (D-2): true — удалить всё содержимое Output//, входящий файл +# (и его .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