diff --git a/README.md b/README.md index 03a0d91..8fde0dd 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ MinIO и NATS); те же значения подхватываются из `EN |---|---|---| | `SCHEMA_MISMATCH` | тело не JSON / не по схеме / MAJOR версии ≠ 1 / `pipeline` ≠ `image`. Конверт публикуется, только если в теле есть корректные `job_id` и `file_id`, иначе запрос пишется в лог и отбрасывается | нет | | `FILE_READ_ERROR` | объект S3 не прочитан; `s3.bucket` не совпадает с бакетом сервиса | да | -| `RECOGNITION_FAILED` | у `original_name` нет допустимого расширения (файл в ИС не передаётся; `.`, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; **ни одной таблицы не разобрано** (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», `raw_s3_key` заполнен, причины — в `metrics.warnings`: `TABLE_PARSE_ERROR`, `TABLE_LOST`, `JSON_FAILED`) | да | +| `RECOGNITION_FAILED` | у `original_name` нет допустимого расширения (файл в ИС не передаётся; `.`, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; **ни одной таблицы не разобрано** (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», `raw_s3_key` заполнен, причины — в `metrics.warnings`: `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `JSON_FAILED`) | да | | `TIMEOUT` | ИС не ответила до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`; `reply_deadline` истёк до отправки файла | да | | `INVALID_FORMAT` | все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, `raw_s3_key` указан) или корень ответа не объект; склейка нарушена или пуста | да | | код ошибки ИС | **все** файлы ответа ошибочны (`error.code` непустой): код первого ошибочного файла по имени пробрасывается, если он входит в `ErrorCode` (например, `INVALID_DOCUMENT`); каждый ошибочный файл — предупреждение `JSON_FAILED` в `metrics.warnings` | да | @@ -398,7 +398,7 @@ make golden-update # пересобрать эталоны нормализ вне числа — 0 (P5-3). - Успешный json ИС без таблиц — `JSON_FAILED` (`error`), рядом с нормальными json конверт будет `needs_check` (P5-7, Р-32). - Нарушенная структура одной таблицы ИС теряет всю таблицу (`TABLE_PARSE_ERROR`, P5-2); остальные таблицы разбираются. -- Расшифровки без кода (OQ-36) и склейка одной формы из нескольких таблиц (OQ-77) не обрабатываются: каждая таблица — +- Склейка одной формы из нескольких таблиц (OQ-77) не обрабатываются: каждая таблица — свой документ. Транспорт и эмулятор: diff --git a/api/src/core/worker.py b/api/src/core/worker.py index b5e154b..5d17fab 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.py @@ -699,7 +699,9 @@ def build_result( # noqa: PLR0913 - идентификаторы + исход + def _limit_warnings(warnings: list[FileWarning]) -> list[FileWarning]: - """Не больше `MAX_WARNINGS`: все `error`, затем `info` по порядку; итоговый порядок — исходный (P5-10).""" + """Не больше `MAX_WARNINGS`: все `error`, затем `info` по порядку; итоговый порядок — исходный (P5-10). + + Если `error` больше `MAX_WARNINGS`, остаются первые `MAX_WARNINGS` из `error`, а `info` отбрасываются полностью.""" if len(warnings) <= MAX_WARNINGS: return warnings errors = [i for i, w in enumerate(warnings) if w.severity is Severity.ERROR] diff --git a/api/src/normalizer/__init__.py b/api/src/normalizer/__init__.py index 14b6f89..1e9e768 100644 --- a/api/src/normalizer/__init__.py +++ b/api/src/normalizer/__init__.py @@ -51,7 +51,8 @@ class Normalizer(Protocol): Структурные ошибки входа (не объект, нет `files`) возвращаются как `status = error`, а не исключением; `NullNormalizer` не бросает ни на каком входе. `OcrNormalizer` изолирует только ошибки разбора таблицы (одна - таблица — одно предупреждение `BACKEND_PARSING_ERROR`, файл и набор живут дальше); исключения вне разбора таблиц + таблица — одно предупреждение `TABLE_PARSE_ERROR`, `BACKEND_PARSING_ERROR` — префикс `message`, файл и набор + живут дальше); исключения вне разбора таблиц поднимаются сознательно (D-4): воркер превращает их в `UNKNOWN_ERROR`.""" def normalize(self, raw: object) -> NormalizeOutcome: ... diff --git a/api/tests/unit/normalizer/test_adapter.py b/api/tests/unit/normalizer/test_adapter.py index 646778b..fc84bd3 100644 --- a/api/tests/unit/normalizer/test_adapter.py +++ b/api/tests/unit/normalizer/test_adapter.py @@ -91,6 +91,8 @@ def test_confidence_of(raw: object, expected: float) -> None: table("ББ", row(0, ("1", "a", 100.0))), table("ББ", row(0, (-1, "a", 100.0))), table("ББ", row(0, (1, "a", 100.0), (1, "b", 100.0))), + table("ББ", row(0, (True, "a", 100.0))), + table("ББ", {"row_index": 0, "cells": ["не объект"]}), ], ) def test_broken_structure_raises(broken: object) -> None: diff --git a/api/tests/unit/normalizer/test_answer_set.py b/api/tests/unit/normalizer/test_answer_set.py index 897156c..58c5ed7 100644 --- a/api/tests/unit/normalizer/test_answer_set.py +++ b/api/tests/unit/normalizer/test_answer_set.py @@ -6,6 +6,7 @@ import pytest import src.normalizer.ocr.answer_set as answer_set_module from arm_statements import DocStatus, DocumentType, EnvelopeStatus, PeriodKind, Severity, default_rules from src.contract import ErrorCode +from src.normalizer.files import BAD_ENTRY_MESSAGE from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE, normalize_answer_set from tests.unit.normalizer.ocr_builders import BAD, BALANCE, HEAD, INCOME, OK, merged, ocr_table, response @@ -157,3 +158,31 @@ def test_one_failed_one_successful_without_documents_is_recognition_failed(secon assert res.status is EnvelopeStatus.ERROR and res.documents == () assert (res.error.code, res.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE) assert ("JSON_FAILED", "file:j.json") in [(w.code, w.locator) for w in res.file_warnings] + + +def test_table_exception_text_truncated_to_300(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(candidate: Any, rules: Any = None) -> Any: + raise RuntimeError("я" * 500) + + monkeypatch.setattr(answer_set_module, "normalize_candidate", boom) + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE)))), RULES) + [w] = res.file_warnings + assert w.message == "BACKEND_PARSING_ERROR: RuntimeError: " + "я" * 300 # R-3: усекается текст исключения + + +def test_bad_entry_and_parse_error_through_answer_set() -> None: + res = normalize_answer_set({"job_id": "j", "files": ["не объект"]}, RULES) + assert res.status is EnvelopeStatus.ERROR + assert res.error is not None + assert (res.error.code, res.error.message) == (ErrorCode.INVALID_FORMAT, BAD_ENTRY_MESSAGE) + [w] = res.file_warnings + assert (w.code, w.locator) == ("JSON_FAILED", "file:files[0]") + + res = normalize_answer_set( + {"job_id": "j", "files": [{"name": "j.json", "response": None, "parse_error": "Expecting value"}]}, RULES + ) + assert res.error is not None + assert res.error.code is ErrorCode.INVALID_FORMAT + assert "Expecting value" in res.error.message + [w] = res.file_warnings + assert w.locator == "file:j.json" diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index 29a6dfb..5685059 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -570,6 +570,24 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ _drop("service.statements_version"), False, ), + ( + "result/statement-detail-without-parent_ref", + "result-statement-minimal.json", + lambda p: p["documents"][0]["lines"][0].update(line_status="detail", line_code=None, parent_ref=None), + False, + ), + ( + "result/statement-balance-lines-without-checks", + "result-statement-minimal.json", + _drop("documents.0.checks"), + False, + ), + ( + "result/statement-needs_review_fields-duplicate", + "result-statement-minimal.json", + _set("documents.0.header.needs_review_fields", ["inn", "inn"]), + False, + ), ] diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index 8d0b452..89de6f5 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -1047,7 +1047,7 @@ class TestSlice3a: body = env.request() async with emulator(env): result = await env.run(body) - assert result["status"] == "needs_check" # SUCCESS_ANSWER: confidence < 100 в шапке + assert result["status"] == "needs_check" # SUCCESS_ANSWER: needs_check из-за CODE_COLUMN_NOT_FOUND key = result["result_s3"]["key"] stored = json.loads(env.storage.objects[(BUCKET, key)]) assert stored["documents"][0]["document_type"] == "BALANCE" diff --git a/openspec/changes/statements-v2/design.md b/openspec/changes/statements-v2/design.md index 54b7b4b..d0efc13 100644 --- a/openspec/changes/statements-v2/design.md +++ b/openspec/changes/statements-v2/design.md @@ -29,10 +29,10 @@ `form_name` сам не ставит и пару «кандидат → документ» не хранит. - **P5-7.** Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` — `JSON_FAILED` (`error`): «ничего не выбрасывать молча» (§11, принцип 2). Цена: если ИС вернёт успешный пустой json рядом с нормальными, конверт - будет `needs_check` (вопрос владельцу — Q-1). + будет `needs_check`. Q-1 закрыт решением владельца Р-32 (подтверждает выбор плана). - **P5-8.** Все json набора ошибочны — конверт `error` с кодом первого, а `metrics.warnings` всё равно перечисляют `JSON_FAILED` каждого json (как строки Среза 0): иначе коды остальных json теряются. Цена: спека называет - `JSON_FAILED` «при успешном соседе»; на `error`-конверт ядро `RECOGNIZED` не считает, вреда нет (Q-2). + `JSON_FAILED` «при успешном соседе»; на `error`-конверт ядро `RECOGNIZED` не считает, вреда нет. Q-2 закрыт решением владельца Р-33 (подтверждает выбор плана). - **P5-9.** `TABLE_PARSE_ERROR.message` = `BACKEND_PARSING_ERROR: <тип>: <текст>` (текст до 300 символов) — код старого §11.1 п. 9 сохранён в тексте для поиска по логам и golden. Цена: нет. - **P5-10.** Предел `metrics.warnings` (100 структур, `message` до 1000 символов) сохранён; при переполнении остаются все @@ -78,3 +78,7 @@ ошибки: нет, эквивалентно. - **R-6 (T4).** Сравнение с эталонами в `test_golden` временно стояло под `pytest.skip` до задачи 6 (эталоны fixtures были старого формата); в задаче 6 skip снят. Цена ошибки: окно без golden-проверки на ветке между задачами 4 и 6. +- **R-7.** Набор «ошибочный json + успешный json без таблиц / без документов» даёт `RECOGNITION_FAILED` / «ни одной + таблицы не разобрано», а код первого ошибочного — только если успешных json нет (чтение Р-32 + Р-33; исправление + T3.I-1, commit 3b9e1f6). Цена ошибки: при другом чтении код ошибки конверта в смешанном случае будет другим; ядро на + `error`-конверт `RECOGNIZED` не считает. diff --git a/openspec/changes/statements-v2/specs/image-recognition-gateway/spec.md b/openspec/changes/statements-v2/specs/image-recognition-gateway/spec.md index d6fe401..b402d4d 100644 --- a/openspec/changes/statements-v2/specs/image-recognition-gateway/spec.md +++ b/openspec/changes/statements-v2/specs/image-recognition-gateway/spec.md @@ -11,7 +11,7 @@ проверкой «`error` не `null`». Конверт (включая ack `processing`) и файл результата несут `schema_version = "2.0"`, `result_schema_version = "2.0"`; `service.statements_version` (содержимое `api/arm_statements/VERSION`) SHALL быть в файле результата, в конверте `service` — `{name, version}`. Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`. -Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС в `statementDocument`», а статус определяется по требованию «Статус результата нормализации». `metrics.warnings` SHALL быть списком структур предупреждений (`code`, `level`, `message`, `locator`, …) по схеме v2.0 — одним и тем же в конверте и в файле результата (предел 100 структур, `message` до 1000 символов; при переполнении остаются все `error`, затем `info`). Каждый ошибочный json набора SHALL давать предупреждение `JSON_FAILED` (`error`, `locator = file:<имя json>`, `message = "<код ИС>: <сообщение ИС>"`; для `parse_error` и не-объекта — код `INVALID_FORMAT`), в том числе когда ошибочны все json; исключение при разборе таблицы — `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:`, `message = "BACKEND_PARSING_ERROR: <тип>: <текст>"`). Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` SHALL давать `JSON_FAILED`. Если предупреждений нет, поле `warnings` SHALL быть опущено. +Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС в `statementDocument`», а статус определяется по требованию «Статус результата нормализации». `metrics.warnings` SHALL быть списком структур предупреждений (`code`, `severity`, `message`, `locator`, …) по схеме v2.0 — одним и тем же в конверте и в файле результата (предел 100 структур, `message` до 1000 символов; при переполнении остаются все `error`, затем `info`). Каждый ошибочный json набора SHALL давать предупреждение `JSON_FAILED` (`error`, `locator = file:<имя json>`, `message = "<код ИС>: <сообщение ИС>"`; для `parse_error` и не-объекта — код `INVALID_FORMAT`), в том числе когда ошибочны все json; исключение при разборе таблицы — `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:`, `message = "BACKEND_PARSING_ERROR: <тип>: <текст>"`). Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` SHALL давать `JSON_FAILED`. Если предупреждений нет, поле `warnings` SHALL быть опущено. #### Scenario: Успешный ответ ИС в Срезе 0 - **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`)