pylib_ai_arm_statements 0.1.5: разрез таблицы ИС по смене формы (#54)

- Пин 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:<i>;form:<k>`). Исключение изолировано по таблице (адаптер, разрез) и по части
  (нормализация): упавшая часть — 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 13:50:48 +04:00
parent 199afedc3c
commit f418aa3190
5 changed files with 61 additions and 20 deletions

View File

@ -8,18 +8,18 @@
старом АРМ), забрать **все** `Исходящие/<job_id>/*.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`); своего конвертера и своего уплощения шапки нет, шлюз только валидирует словарь моделями

View File

@ -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:<i>"`, `header.form_name` — заголовок
таблицы ИС. Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и
таблицы ИС. Если внутри таблицы сменилась форма (сегмент со своей шапкой и кодами другой формы), следующие части —
отдельные документы с `locator = "file:<имя json>;table:<i>;form:<k>"`, тип — только по кодам (§11, «Смена формы
внутри кандидата»). Подробные правила шагов 2–8 и статусов — `05-domain-rules.md` §11.9, предупреждения документа — там же и
в схеме `statementDocument`.
### Статус результата

View File

@ -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

View File

@ -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
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)

View File

@ -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")]