From f418aa3190d82b469badcdda2d020f2eb70316ff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 13:50:48 +0400 Subject: [PATCH] =?UTF-8?q?pylib=5Fai=5Farm=5Fstatements=200.1.5:=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=80=D0=B5=D0=B7=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=98=D0=A1=20=D0=BF=D0=BE=20=D1=81=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Пин pylib_ai_arm_statements==0.1.5 (0.1.4 — группа альтернатив 2400, AURORA-1864; 0.1.5 — split_by_form и редкий столбец, AURORA-1734/1735). - answer_set: кандидат каждой таблицы режется split_by_form перед normalize_candidate — ББ и ОФР одной таблицей ИС («Баланс АК-ЮЛ.pdf») становятся двумя документами (`…;table:;form:`). Исключение изолировано по таблице (адаптер, разрез) и по части (нормализация): упавшая часть — TABLE_PARSE_ERROR со своим locator, остальные остаются. - Тесты: разрез ББ+ОФР одной таблицей, изоляция части. - Эталон aep_3 — fixtures #36 (уходит мягкое 2400 при сошедшейся альтернативе). - README и CLAUDE.md: обход набора с split_by_form, версия библиотеки — по пину. Проверено на реальных ответах ИС из AURORA-1734 (АК-ЮЛ: ББ + ОФР) и AURORA-1735 (Калтан: ОФР без лишнего столбца). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++-- README.md | 10 ++++-- api/requirements.txt | 2 +- api/src/normalizer/ocr/answer_set.py | 32 ++++++++++++-------- api/tests/unit/normalizer/test_answer_set.py | 31 +++++++++++++++++++ 5 files changed, 61 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 123333e..6713a5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,18 +8,18 @@ старом АРМ), забрать **все** `Исходящие//*.json` (ИС кладёт по одному json на каждый распознанный документ), склеить и сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить горячую папку (только при `SCAN_CLEANUP=true`), нормализовать в `../docs/contracts/recognition-result.schema.json` -(`statementDocument` v2.0 общим кодом `arm_statements`: по одному документу на таблицу `table_structure[i]`), опубликовать `files.tabular_parsed` +(`statementDocument` v2.0 общим кодом `arm_statements`: по документу на таблицу `table_structure[i]`, а при смене формы внутри таблицы — на каждую часть), опубликовать `files.tabular_parsed` (по умолчанию; задаётся `OPENBAO__NATS__NATS_*_SUBJECT`) (`done` / `needs_check` / `error`). Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=1200)`, старт — запись файла во «Входящие». **Нет доступа к БД.** ## Правила -- `arm_statements` — библиотека `pylib_ai_arm_statements==0.1.1`, не публикуется в PyPI. +- `arm_statements` — библиотека `pylib_ai_arm_statements` (версия — пин в `api/requirements.txt`), не публикуется в PyPI. `make venv` и `make install` устанавливают её из `../pylib_ai_arm_statements` (`STATEMENTS_DIR`). - Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14), по `05-domain-rules.md` §11, §11.1 и §11.9: правило, которого там нет, не реализуется, а поднимается вопросом. Шаги 2–8, словари и статусы — в пакете `arm_statements`; в шлюзе остаются только: - адаптер шага 1 — `api/src/normalizer/ocr/adapter.py` (таблица ИС → кандидат, `ref` — номер строки ИС); - - обход набора json — `api/src/normalizer/ocr/answer_set.py` (`normalize_candidate` на каждую таблицу, + - обход набора json — `api/src/normalizer/ocr/answer_set.py` (`split_by_form` на каждую таблицу — ББ и ОФР одной таблицей ИС, AURORA-1734 — и `normalize_candidate` на каждую часть, `envelope_status`, коды `JSON_FAILED` / `TABLE_PARSE_ERROR`; Р-32/Р-33); - граница контракта — `to_contract` / `warning_to_contract` пакета, вызываются в `api/src/normalizer/ocr/__init__.py` (`OcrNormalizer`); своего конвертера и своего уплощения шапки нет, шлюз только валидирует словарь моделями diff --git a/README.md b/README.md index 9d6eb3c..9e4a0d3 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,10 @@ ack (с `attempt` повторного запроса); файл в ИС вто пакета (сетка ячеек по возрастанию `row_index`, `ref` ячейки — номер строки ИС, шапка `data_ocr`, заголовок формы). Нарушенная структура таблицы (повтор `row_index` / `column_index`, нецелые индексы, `rows` не список) — исключение адаптера, то есть `TABLE_PARSE_ERROR` всей таблицы. -- `ocr/answer_set.py` — обход набора json склейки: на каждую таблицу успешного файла зовётся `normalize_candidate` - пакета, исключение одной таблицы не роняет остальные; статус набора — `envelope_status` пакета. +- `ocr/answer_set.py` — обход набора json склейки: кандидат каждой таблицы успешного файла режется `split_by_form` + пакета (ББ и ОФР одной таблицей ИС — две части, AURORA-1734), на каждую часть зовётся `normalize_candidate`; + исключение одной таблицы или части не роняет остальные (`TABLE_PARSE_ERROR` с её `locator`); статус набора — + `envelope_status` пакета. - `ocr/__init__.py` — `OcrNormalizer`: результат пакета переводится в модели контракта функциями `to_contract` / `warning_to_contract` (граница контракта; шлюз только валидирует словарь своими pydantic-моделями), версия библиотеки (`statements_version()`) идёт в `NormalizeOutcome` и в `service.statements_version` файла результата. @@ -170,7 +172,9 @@ ack (с `attempt` повторного запроса); файл в ИС вто удалены. Документ на каждую таблицу успешного файла: `locator = "file:<имя json>;table:"`, `header.form_name` — заголовок -таблицы ИС. Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и +таблицы ИС. Если внутри таблицы сменилась форма (сегмент со своей шапкой и кодами другой формы), следующие части — +отдельные документы с `locator = "file:<имя json>;table:;form:"`, тип — только по кодам (§11, «Смена формы +внутри кандидата»). Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и в схеме `statementDocument`. ### Статус результата diff --git a/api/requirements.txt b/api/requirements.txt index 66f4ae3..ffd6685 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -34,7 +34,7 @@ multidict==6.7.1 nats-py==2.14.0 nkeys==0.2.1 propcache==0.5.2 -pylib_ai_arm_statements==0.1.3 +pylib_ai_arm_statements==0.1.5 pycparser==3.0 pycryptodome==3.23.0 pydantic==2.13.4 diff --git a/api/src/normalizer/ocr/answer_set.py b/api/src/normalizer/ocr/answer_set.py index b2d1b4d..ace70d4 100644 --- a/api/src/normalizer/ocr/answer_set.py +++ b/api/src/normalizer/ocr/answer_set.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from arm_statements import ( EnvelopeStatus, + FormCandidate, Rules, StatementDocument, StatementWarning, @@ -17,6 +18,7 @@ from arm_statements import ( envelope_status, make_warning, normalize_candidate, + split_by_form, ) from src.contract import ErrorCode @@ -107,18 +109,22 @@ def _normalize_file( header = header_cells(data_ocr) for i, table in enumerate(tables): try: - result = normalize_candidate(table_candidate(name, i, table, header, rules), rules) + # ББ и ОФР одной таблицей ИС — части по смене формы (`split_by_form`, AURORA-1734) + parts: tuple[FormCandidate, ...] = split_by_form(table_candidate(name, i, table, header, rules), rules) except Exception as exc: # noqa: BLE001 -- изоляция разбора одной таблицы (P5-9) - text = f"{type(exc).__name__}: {str(exc)[:EXCEPTION_TEXT_LIMIT]}" - warnings.append( - make_warning( - WarningCode.TABLE_PARSE_ERROR, - f"BACKEND_PARSING_ERROR: {text}", - rules, - locator=f"file:{name};table:{i}", - ) - ) + warnings.append(_table_parse_error(exc, f"file:{name};table:{i}", rules)) continue - if result.document is not None: - documents.append(result.document) - warnings.extend(result.file_warnings) + for part in parts: + try: + result = normalize_candidate(part, rules) + except Exception as exc: # noqa: BLE001 -- изоляция разбора одной части таблицы (P5-9) + warnings.append(_table_parse_error(exc, part.locator, rules)) + continue + if result.document is not None: + documents.append(result.document) + warnings.extend(result.file_warnings) + + +def _table_parse_error(exc: Exception, locator: str, rules: Rules) -> StatementWarning: + text = f"{type(exc).__name__}: {str(exc)[:EXCEPTION_TEXT_LIMIT]}" + return make_warning(WarningCode.TABLE_PARSE_ERROR, f"BACKEND_PARSING_ERROR: {text}", rules, locator=locator) diff --git a/api/tests/unit/normalizer/test_answer_set.py b/api/tests/unit/normalizer/test_answer_set.py index 162ed05..ffe189c 100644 --- a/api/tests/unit/normalizer/test_answer_set.py +++ b/api/tests/unit/normalizer/test_answer_set.py @@ -186,3 +186,34 @@ def test_bad_entry_and_parse_error_through_answer_set() -> None: assert "Expecting value" in res.error.message [w] = res.file_warnings assert w.locator == "file:j.json" + + +def test_balance_and_income_in_one_table_split_by_form() -> None: + """ББ и ОФР одной таблицей ИС (АК-ЮЛ, AURORA-1734): шапка ОФР внутри таблицы режет её на два документа.""" + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE + INCOME)))), RULES) + + assert [(d.locator, d.document_type) for d in res.documents] == [ + ("file:j.json;table:0", DocumentType.BALANCE), + ("file:j.json;table:0;form:1", DocumentType.INCOME), + ] + balance, income = res.documents + assert {line.line_code for line in balance.lines} == {row[1] for row in BALANCE[1:]} + assert {line.line_code for line in income.lines} == {row[1] for row in INCOME[1:]} + assert [c.period_date for c in income.columns] == [date(2024, 12, 31)] + assert "TYPE_CONFLICT" not in {w.code for w in income.warnings} + + +def test_part_exception_keeps_other_part(monkeypatch: pytest.MonkeyPatch) -> None: + """Ошибка разбора части `;form:1` — `TABLE_PARSE_ERROR` с её locator, документ первой части остаётся.""" + real = answer_set_module.normalize_candidate + + def flaky(candidate: Any, rules: Any = None) -> Any: + if candidate.locator.endswith(";form:1"): + raise RuntimeError("сломалось") + return real(candidate, rules) + + monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky) + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE + INCOME)))), RULES) + + assert [d.document_type for d in res.documents] == [DocumentType.BALANCE] + assert [(w.code, w.locator) for w in res.file_warnings] == [("TABLE_PARSE_ERROR", "file:j.json;table:0;form:1")] -- 2.47.2