From ae2922eb30b08341edbd82c48471fabd5ef406f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:08:44 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=202.0=20=E2=80=94=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BE=D0=B1=D1=89=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=BE=D0=B4=D0=B0=20arm=5Fstatements=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/contracts/examples/result-error.json | 7 +- .../examples/result-message-done.json | 9 +- .../examples/result-message-error.json | 7 +- .../examples/result-message-processing.json | 7 +- .../examples/result-osv-minimal.json | 21 +- .../examples/result-osv-needs-check.json | 46 +- .../examples/result-statement-excel.json | 97 ++- .../examples/result-statement-minimal.json | 69 +- .../recognition-result-message.schema.json | 25 +- api/contracts/recognition-result.schema.json | 218 ++++-- api/src/app.py | 7 +- api/src/contract/__init__.py | 8 +- api/src/contract/common.py | 47 +- api/src/contract/message.py | 2 +- api/src/contract/request.py | 12 +- api/src/contract/result.py | 128 +++- api/src/core/worker.py | 90 ++- api/src/normalizer/__init__.py | 76 +- api/src/normalizer/files.py | 9 +- api/src/normalizer/ocr/__init__.py | 143 ++-- api/src/normalizer/ocr/document.py | 264 ------- api/src/normalizer/outcome.py | 44 ++ api/tests/golden_update.py | 9 +- .../unit/normalizer/test_contract_boundary.py | 88 +++ api/tests/unit/normalizer/test_document.py | 678 ------------------ api/tests/unit/normalizer/test_golden.py | 9 +- .../unit/normalizer/test_ocr_normalizer.py | 285 +++----- api/tests/unit/test_app.py | 9 +- api/tests/unit/test_contract.py | 57 +- api/tests/unit/test_emulator.py | 3 +- api/tests/unit/test_normalizer.py | 61 +- api/tests/unit/test_worker.py | 115 ++- api/tests/worker_helpers.py | 2 +- 33 files changed, 1066 insertions(+), 1586 deletions(-) delete mode 100644 api/src/normalizer/ocr/document.py create mode 100644 api/src/normalizer/outcome.py create mode 100644 api/tests/unit/normalizer/test_contract_boundary.py delete mode 100644 api/tests/unit/normalizer/test_document.py diff --git a/api/contracts/examples/result-error.json b/api/contracts/examples/result-error.json index 54af7cb..b043599 100644 --- a/api/contracts/examples/result-error.json +++ b/api/contracts/examples/result-error.json @@ -1,8 +1,11 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "error", "error": { "code": "NOT_IMPLEMENTED", diff --git a/api/contracts/examples/result-message-done.json b/api/contracts/examples/result-message-done.json index 6d56f17..4e47bd1 100644 --- a/api/contracts/examples/result-message-done.json +++ b/api/contracts/examples/result-message-done.json @@ -1,14 +1,17 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "status": "done", - "service": { "name": "scan-gateway", "version": "0.1.0" }, + "service": { + "name": "scan-gateway", + "version": "0.1.0" + }, "result_s3": { "bucket": "arm-ka", "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" }, - "result_schema_version": "1.0", + "result_schema_version": "2.0", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", "metrics": { "duration_ms": 18400, diff --git a/api/contracts/examples/result-message-error.json b/api/contracts/examples/result-message-error.json index f5ec77a..6da980c 100644 --- a/api/contracts/examples/result-message-error.json +++ b/api/contracts/examples/result-message-error.json @@ -1,9 +1,12 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "error", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "error": { "code": "NOT_IMPLEMENTED", "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" diff --git a/api/contracts/examples/result-message-processing.json b/api/contracts/examples/result-message-processing.json index 2087150..9136790 100644 --- a/api/contracts/examples/result-message-processing.json +++ b/api/contracts/examples/result-message-processing.json @@ -1,7 +1,10 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "processing", - "service": { "name": "tabular-parser", "version": "0.1.0" } + "service": { + "name": "tabular-parser", + "version": "0.1.0" + } } diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json index b0a9e97..f54525f 100644 --- a/api/contracts/examples/result-osv-minimal.json +++ b/api/contracts/examples/result-osv-minimal.json @@ -1,8 +1,11 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "done", "documents": [ { @@ -24,7 +27,11 @@ "header_variant": 1 }, "group_schema": [ - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "C", @@ -70,14 +77,18 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } } ], "checks": { "total_row_matches_root": true, "balance_identity_violations": [], "parent_sum_violations": [] - } + }, + "status": "done", + "warnings": [] } ] } diff --git a/api/contracts/examples/result-osv-needs-check.json b/api/contracts/examples/result-osv-needs-check.json index 81d64a8..6827c89 100644 --- a/api/contracts/examples/result-osv-needs-check.json +++ b/api/contracts/examples/result-osv-needs-check.json @@ -1,15 +1,15 @@ { - "schema_version": "1.1", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "1.0.0" }, + "service": { + "name": "tabular-parser", + "version": "1.0.0" + }, "status": "needs_check", "metrics": { "rows": 3, - "warnings": [ - "sheet=Лист_1 hierarchy=outline", - "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" - ] + "warnings": [] }, "documents": [ { @@ -28,8 +28,15 @@ "header_variant": 1 }, "group_schema": [ - { "type": "ACCOUNT", "name": "Счет" }, - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "ACCOUNT", + "name": "Счет" + }, + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "D", @@ -76,7 +83,9 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } }, { "row_number": 3, @@ -117,9 +126,24 @@ ], "checks": { "total_row_matches_root": true, - "balance_identity_violations": [3], + "balance_identity_violations": [ + 3 + ], "parent_sum_violations": [] - } + }, + "status": "needs_check", + "warnings": [ + { + "code": "OSV_HIERARCHY_BY_OUTLINE", + "severity": "info", + "message": "иерархия строк — по группировке листа (outline)" + }, + { + "code": "OSV_BALANCE_IDENTITY", + "severity": "info", + "message": "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" + } + ] } ] } diff --git a/api/contracts/examples/result-statement-excel.json b/api/contracts/examples/result-statement-excel.json index 260528b..5e7b311 100644 --- a/api/contracts/examples/result-statement-excel.json +++ b/api/contracts/examples/result-statement-excel.json @@ -1,44 +1,99 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", - "service": { "name": "tabular-parser", "version": "0.3.0" }, - "status": "done", - "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "service": { "name": "tabular-parser", "version": "0.4.0", "statements_version": "abc1234" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 12, "warnings": [] }, "documents": [ { "document_type": "BALANCE", "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", - "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "status": "done", + "form_variant": "full", + "warnings": [ + { "code": "FRACTIONAL_IN_THOUSANDS", "severity": "info", "message": "значений с дробной частью при единице тыс. руб.: 6 из 14" } + ], + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "на 30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710001", "okud_text": "0710001", "okud_confidence": 100, + "okpo": "12345678", "okpo_text": "12345678", "okpo_confidence": 100, + "okei": "384", "okei_text": "384", "okei_confidence": 100, + "needs_review_fields": [] + }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 3, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }, + { "column_index": 4, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" } ], "lines": [ - { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + { "row_index": 0, "ref": "'Бухгалтерский баланс. Лист 1'!C14", "line_status": "ok", "line_code": "1110", "line_code_text": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Бухгалтерский баланс. Лист 1'!C15", "line_status": "ok", "line_code": "11101", "line_code_text": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "900000.00", "text": "900 000,00", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Бухгалтерский баланс. Лист 1'!C16", "line_status": "ok", "line_code": "1100", "line_code_text": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Бухгалтерский баланс. Лист 1'!C17", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": null, "text": "-", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": null, "text": "", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "'Бухгалтерский баланс. Лист 1'!C18", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "'Бухгалтерский баланс. Лист 2'!C5", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 6, "ref": "'Бухгалтерский баланс. Лист 2'!C6", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 7, "ref": "'Бухгалтерский баланс. Лист 2'!C7", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] } ], - "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + "checks": { + "relations_failed": [], + "soft_relations_failed": [ { "column_index": 4, "relation": "1110 = 11101", "codes": ["1110", "11101"], "expected": "905786.99", "actual": "900000.00" } ] + } }, { "document_type": "INCOME", "locator": "sheet:Отчет о финансовых результатах", - "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, - "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], - "lines": [ - { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "INN_NOT_FOUND", "severity": "info", "message": "в шапке не найден ИНН", "field": "inn" } ], - "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + "header": { + "form_name": "Отчет о финансовых результатах", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": null, "inn_text": null, "inn_confidence": null, + "report_date": "2025-09-30", "report_date_text": "за январь - сентябрь 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710002", "okud_text": "0710002", "okud_confidence": 100, + "needs_review_fields": [] + }, + "columns": [ + { "column_index": 3, "label": "За январь - сентябрь 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } + ], + "lines": [ + { "row_index": 0, "ref": "'Отчет о финансовых результатах'!C12", "line_status": "ok", "line_code": "2110", "line_code_text": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "1000.00", "text": "1 000", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Отчет о финансовых результатах'!C13", "line_status": "detail", "line_code": null, "line_code_text": "", "line_code_confidence": 100, "line_name": "в том числе от продажи продукции", "line_name_confidence": 100, "parent_ref": "'Отчет о финансовых результатах'!C12", "needs_review": false, + "values": [ { "column_index": 3, "amount": "800.00", "text": "800", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Отчет о финансовых результатах'!C14", "line_status": "ok", "line_code": "2120", "line_code_text": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "-600.00", "text": "(600)", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Отчет о финансовых результатах'!C15", "line_status": "ok", "line_code": "2100", "line_code_text": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "400.00", "text": "400", "confidence": 100, "is_recognized": true, "needs_review": true } ] } + ], + "checks": { + "relations_failed": [ { "column_index": 3, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": null, "actual": "400.00" } ], + "soft_relations_failed": [] + } }, { "document_type": "DISCLOSURE", "locator": "sheet:Пояснения", - "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "status": "done", + "form_variant": null, + "warnings": [], + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах", "needs_review_fields": [] }, "columns": [], "lines": [] } diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json index 913cfc5..9a3eb1d 100644 --- a/api/contracts/examples/result-statement-minimal.json +++ b/api/contracts/examples/result-statement-minimal.json @@ -1,50 +1,51 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", - "service": { "name": "scan-gateway", "version": "0.1.0" }, - "status": "done", + "service": { "name": "scan-gateway", "version": "0.2.0", "statements_version": "abc1234" }, + "status": "needs_check", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { "duration_ms": 18400, "pages": 2, "warnings": [] }, "documents": [ { "document_type": "BALANCE", - "locator": "pages:1-2", + "locator": "file:8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json;table:0", + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "ORGANIZATION_UNCERTAIN", "severity": "info", "message": "организация распознана неуверенно", "field": "organization_name" }, + { "code": "ROW_SPLIT", "severity": "info", "message": "строка с несколькими кодами разлеплена: 1370, 1300", "line_refs": ["row:14#1", "row:14#2"] } + ], "header": { "form_name": "Бухгалтерский баланс", - "organization_name": "ООО Ромашка", - "organization_name_confidence": 97.5, - "inn": "7700000000", - "inn_confidence": 99, - "report_date_text": "30 сентября 2025 г.", - "report_date": "2025-09-30", - "units": "тыс. руб." + "organization_name": "ООО Ромашка", "organization_name_text": "ООО Ромашка", "organization_name_confidence": 97.5, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "тыс. руб.", "units_confidence": 100, + "needs_review_fields": [] }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 2, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } ], "lines": [ - { - "line_code": "1100", - "line_code_confidence": 99, - "line_name": "Итого по разделу I", - "line_name_confidence": 96, - "values": [ - { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, - { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } - ] - }, - { - "line_code": "1600", - "line_code_confidence": 99, - "line_name": "БАЛАНС (актив)", - "line_name_confidence": 98, - "values": [ - { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, - { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } - ] - } - ] + { "row_index": 0, "ref": "row:1", "line_status": "ok", "line_code": "1210", "line_code_text": "1210", "line_code_confidence": 100, "line_name": "Запасы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "row:2", "line_status": "ok", "line_code": "1230", "line_code_text": "1230", "line_code_confidence": 100, "line_name": "Дебиторская задолжен-ность", "line_name_confidence": 80, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 2, "ref": "row:3", "line_status": "unreadable", "line_code": null, "line_code_text": "ИЧ,250", "line_code_confidence": 25, "line_name": "Финансовые вложения", "line_name_confidence": 67, "needs_review": false, + "values": [ { "column_index": 2, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": true } ] }, + { "row_index": 3, "ref": "row:4", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "row:5", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "row:14#1", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 6, "ref": "row:14#2", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 7, "ref": "row:15", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [] } } ] } diff --git a/api/contracts/recognition-result-message.schema.json b/api/contracts/recognition-result-message.schema.json index 4d9f401..a78afd8 100644 --- a/api/contracts/recognition-result-message.schema.json +++ b/api/contracts/recognition-result-message.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "arm-ka/recognition-result-message/v1", - "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "$id": "arm-ka/recognition-result-message/v2", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v2.0", "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения; новый MAJOR — «сначала ядро»: ядро на время перехода принимает конверты (включая ack processing) и прежнего, и нового MAJOR. Changelog: 1.0 — исходный контракт; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — metrics.warnings[] стали структурами {code, severity, message, line_refs?, column_index?, field?, locator?} (как metrics.warnings файла результата), производитель пишет в конверт тот же MAJOR, что и в файл результата. Схема 1.0 на окне перехода лежит рядом — recognition-result-message.v1.schema.json (примеры — examples/v1/).", "type": "object", "required": ["schema_version", "job_id", "file_id", "status", "service"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, "status": { "enum": ["processing", "done", "needs_check", "error"] }, @@ -47,7 +47,22 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла — те же, что metrics.warnings файла результата" } + } + } + }, + "$defs": { + "fileWarning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, + "severity": { "enum": ["error", "info"] }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" } }, + "column_index": { "type": "integer" }, + "field": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "locator": { "type": "string" } } } }, diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json index 43781b3..19531dd 100644 --- a/api/contracts/recognition-result.schema.json +++ b/api/contracts/recognition-result.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", - "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", + "$id": "arm-ka/recognition-result/v2", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v2.0", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Формы ББ/ОФР нормализуются общим кодом arm_statements (05-domain-rules.md §11): статусы строк, needs_review, проверки §7, статус документа и структурные предупреждения приходят готовыми; ядро хранит их как пришли и пересчитывает тем же кодом только после правок аналитика. Нормализация счетов ОСВ, резолв контрагентов, сверка ИНН и периода — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1, 2.2, …) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения. Новый MAJOR — порядок «сначала ядро»: ядро на время перехода принимает и прежний, и новый MAJOR (два маппера), сервисы переходят после выкладки ядра. Changelog: 1.0 — исходный контракт; 1.1 — osvDocument.rows[].extra; 1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — line_code nullable и line_status (ok | unreadable | missing | detail), row_index, ref, parent_ref, line_code_text, needs_review у значения и строки, values[].text, header.<поле>_text и <поле>_confidence у всех полей шапки, header.needs_review_fields, columns[].period_kind и label_confidence, document.status и document.warnings у всех документов (включая ОСВ), statementDocument.form_variant, metrics.warnings и warnings — структуры с уровнем и привязкой, checks обязателен у BALANCE/INCOME со строками, нарушения с codes, удалён checks.duplicate_line_codes, service.statements_version, header.okei (+ _text, _confidence), смысл is_recognized у OCR отвязан от confidence, statementDocument.form_variant обязателен, OTHER удалён из statementDocument.document_type. Схема 1.2 на окне перехода лежит рядом — recognition-result.v1.schema.json (примеры — examples/v1/); удаляется вместе с маппером 1.x ядра после перехода обоих сервисов.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -15,10 +15,11 @@ "required": ["name", "version"], "properties": { "name": { "enum": ["tabular-parser", "scan-gateway"] }, - "version": { "type": "string" } + "version": { "type": "string" }, + "statements_version": { "type": "string", "minLength": 1, "description": "коммит репозитория arm-statements, из которого разложена копия общего кода; обязателен, если в documents есть statementDocument" } } }, - "status": { "enum": ["done", "needs_check", "error"] }, + "status": { "enum": ["done", "needs_check", "error"], "description": "done ⇔ все документы done и нет предупреждений файла уровня error; error — документов нет" }, "error": { "type": ["object", "null"], "properties": { @@ -35,7 +36,7 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла (ошибочный json набора ИС, потерянная таблица, формат файла)" } } }, "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, @@ -44,31 +45,68 @@ "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } } }, - "if": { - "properties": { "status": { "enum": ["done", "needs_check"] } }, - "required": ["status"] - }, - "then": { - "properties": { "documents": { "minItems": 1 } } - }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + } + }, + { + "if": { + "properties": { "documents": { "contains": { "properties": { "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"] } }, "required": ["document_type"] } } }, + "required": ["documents"] + }, + "then": { + "properties": { "service": { "required": ["name", "version", "statements_version"] } } + } + } + ], "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "documentStatus": { "enum": ["done", "needs_check"] }, + "headerField": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "warning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$", "examples": ["COLUMN_WITHOUT_DATE", "ROW_SPLIT"], "description": "код по таблице 05-domain-rules.md §11; не перечисление — новый код не меняет MAJOR" }, + "severity": { "enum": ["error", "info"], "description": "error → документ needs_check; info на статус не влияет" }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" }, "description": "ref строк документа — только строк из statementDocument.lines[]; строки ролей title / header / numbering в line_refs не попадают (о них — message)" }, + "column_index": { "type": "integer", "description": "столбец сетки кандидата (как columns[].column_index); у COLUMN_SKIPPED и других предупреждений о столбце вне columns[] — номер этого столбца" }, + "field": { "$ref": "#/$defs/headerField" } + } + }, + "fileWarning": { + "allOf": [ { "$ref": "#/$defs/warning" } ], + "properties": { + "locator": { "type": "string", "examples": ["file:aep_3_ofr.json", "sheet:Лист3"], "description": "место в файле, к которому относится предупреждение" } + } + }, "failedRelation": { "type": "object", - "required": ["column_index", "relation", "expected", "actual"], + "required": ["column_index", "relation", "codes", "expected", "actual"], "properties": { "column_index": { "type": "integer" }, "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, - "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, - "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + "codes": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^\\d{4,5}$" }, "description": "коды цели и членов соотношения: нарушение подсвечивает все строки этих кодов в колонке" }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения (цель) по строкам ok; null — цели нет" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам ok" } } }, "osvDocument": { "type": "object", - "required": ["document_type", "locator", "header", "group_schema", "rows"], + "required": ["document_type", "locator", "header", "group_schema", "rows", "status", "warnings"], "properties": { "document_type": { "const": "OSV" }, "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "status": { "$ref": "#/$defs/documentStatus", "description": "done — контрольные суммы §1.4 листа сошлись, иначе needs_check (05-domain-rules.md §1.6)" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" }, "description": "предупреждения листа ОСВ; все уровня info, код с префиксом OSV_" }, "header": { "type": "object", "required": ["organization_name", "account_code_raw", "period_text"], @@ -142,79 +180,131 @@ } } }, + "statementValue": { + "type": "object", + "required": ["column_index", "amount", "text", "confidence", "is_recognized", "needs_review"], + "properties": { + "column_index": { "type": "integer", "description": "колонка значений — columns[].column_index" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"], "description": "текст ячейки, только когда amount = null и текст непуст и не прочерк" }, + "text": { "type": ["string", "null"], "description": "сырой текст источника всегда (у Excel — как показывает числовой формат); null — позиции нет" }, + "confidence": { "$ref": "#/$defs/confidence", "description": "уверенность источника (у Excel 100; позиции нет — 100)" }, + "is_recognized": { "type": "boolean", "description": "текст разобран как число без выброшенных и исправленных символов; от confidence не зависит" }, + "needs_review": { "type": "boolean", "description": "сводный признак: нуждается в покрытии §7 и не покрыто, не подтверждён знак, или в несошедшемся жёстком соотношении (§11)" } + } + }, + "statementLine": { + "type": "object", + "required": ["row_index", "ref", "line_status", "line_code", "line_code_text", "line_name", "needs_review", "values"], + "properties": { + "row_index": { "type": "integer", "minimum": 0, "description": "порядковый номер строки в документе с 0, по порядку сетки после разлепления" }, + "ref": { "type": "string", "minLength": 1, "examples": ["row:8", "row:14#2", "Баланс!C27", "'Бухгалтерский баланс. Лист 1'!C14"], "description": "положение в источнике; уникален в документе. OCR — row:[#k]; Excel — <лист>! по правилу Excel: имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры; апостроф внутри имени удваивается" }, + "line_status": { "enum": ["ok", "unreadable", "missing", "detail"] }, + "line_code": { "type": ["string", "null"], "pattern": "^\\d{4,5}$", "description": "разобранный код; null ⇔ line_status ≠ ok" }, + "line_code_text": { "type": ["string", "null"], "description": "сырой текст ячейки кода" }, + "line_code_confidence": { "$ref": "#/$defs/confidence", "description": "от источника; у кода с заменой двойника — не выше 99" }, + "line_name": { "type": ["string", "null"], "description": "распознанное наименование как в источнике (не из справочника)" }, + "line_name_confidence": { "$ref": "#/$defs/confidence" }, + "parent_ref": { "type": ["string", "null"], "description": "ref строки-родителя у detail" }, + "needs_review": { "type": "boolean", "description": "неуверенный код не подтверждён структурой или повтор кода с неуверенной копией" }, + "values": { "type": "array", "items": { "$ref": "#/$defs/statementValue" } } + }, + "allOf": [ + { + "if": { "properties": { "line_status": { "const": "ok" } }, "required": ["line_status"] }, + "then": { "properties": { "line_code": { "type": "string" } } }, + "else": { "properties": { "line_code": { "type": "null" } } } + }, + { + "if": { "properties": { "line_status": { "const": "detail" } }, "required": ["line_status"] }, + "then": { "required": ["parent_ref"], "properties": { "parent_ref": { "type": "string" } } } + } + ] + }, "statementDocument": { "type": "object", - "required": ["document_type", "locator", "header", "columns", "lines"], + "required": ["document_type", "form_variant", "locator", "header", "columns", "lines", "status", "warnings"], "properties": { - "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, - "locator": { "type": "string", "examples": ["pages:1-2"] }, + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"], "description": "OTHER в v2.0 не порождается: лист «прочее» — предупреждение файла SHEET_OTHER, документа нет (значение OTHER — только в схеме 1.x)" }, + "locator": { "type": "string", "examples": ["file:aep_3.json;table:0", "sheet:Баланс,Баланс (2)"] }, + "status": { "$ref": "#/$defs/documentStatus" }, + "form_variant": { "enum": ["full", "simplified", null], "description": "по структуре кодов строк ok (§11); null — DISCLOSURE; обязателен" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" } }, "header": { "type": "object", "properties": { - "form_name": { "type": "string" }, + "form_name": { "type": ["string", "null"], "description": "заголовок формы как в источнике" }, "organization_name": { "type": ["string", "null"] }, - "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "inn": { "type": ["string", "null"] }, - "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "report_date_text": { "type": ["string", "null"] }, + "organization_name_text": { "type": ["string", "null"] }, + "organization_name_confidence": { "$ref": "#/$defs/confidence" }, + "inn": { "type": ["string", "null"], "pattern": "^(\\d{10}|\\d{12})$" }, + "inn_text": { "type": ["string", "null"] }, + "inn_confidence": { "$ref": "#/$defs/confidence" }, "report_date": { "type": ["string", "null"], "format": "date" }, + "report_date_text": { "type": ["string", "null"] }, + "report_date_confidence": { "$ref": "#/$defs/confidence" }, "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "units_text": { "type": ["string", "null"] }, + "units_confidence": { "$ref": "#/$defs/confidence" }, "okud": { "type": ["string", "null"] }, + "okud_text": { "type": ["string", "null"] }, + "okud_confidence": { "$ref": "#/$defs/confidence" }, "okpo": { "type": ["string", "null"] }, + "okpo_text": { "type": ["string", "null"] }, + "okpo_confidence": { "$ref": "#/$defs/confidence" }, "okopf": { "type": ["string", "null"] }, + "okopf_text": { "type": ["string", "null"] }, + "okopf_confidence": { "$ref": "#/$defs/confidence" }, "okfs": { "type": ["string", "null"] }, - "address": { "type": ["string", "null"] } + "okfs_text": { "type": ["string", "null"] }, + "okfs_confidence": { "$ref": "#/$defs/confidence" }, + "okei": { "type": ["string", "null"], "examples": ["384"], "description": "код ОКЕИ единицы (384 — тыс. руб., 383 — руб., 385 — млн руб.); запасной источник units (§11.2)" }, + "okei_text": { "type": ["string", "null"] }, + "okei_confidence": { "$ref": "#/$defs/confidence" }, + "address": { "type": ["string", "null"] }, + "address_text": { "type": ["string", "null"] }, + "address_confidence": { "$ref": "#/$defs/confidence" }, + "needs_review_fields": { "type": "array", "items": { "$ref": "#/$defs/headerField" }, "uniqueItems": true, "description": "поля шапки, значение которых требует проверки и держит документ в needs_check (сейчас — только неуверенная дата отчёта, не подтверждённая датами колонок); сомнения в ИНН и организации — предупреждения info с field, сюда не входят (§11.9)" } } }, "columns": { "type": "array", "items": { "type": "object", - "required": ["column_index", "label"], + "required": ["column_index", "label", "period_kind"], "properties": { - "column_index": { "type": "integer" }, - "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, - "period_date": { "type": ["string", "null"], "format": "date" } - } - } - }, - "lines": { - "type": "array", - "items": { - "type": "object", - "required": ["line_code", "line_name", "values"], - "properties": { - "line_code": { "type": "string" }, - "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "line_name": { "type": "string" }, - "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "values": { - "type": "array", - "items": { - "type": "object", - "required": ["column_index"], - "properties": { - "column_index": { "type": "integer" }, - "amount": { "$ref": "#/$defs/money" }, - "string_value": { "type": ["string", "null"] }, - "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "is_recognized": { "type": "boolean" } - } - } - } + "column_index": { "type": "integer", "minimum": 0, "description": "номер столбца в сетке кандидата шага 1 (у OCR — column_index ИС, не обязательно подряд; у Excel — номер столбца листа первого сегмента, с 0); уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номер" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г.", "На отчетную дату"] }, + "label_confidence": { "$ref": "#/$defs/confidence", "description": "у Excel 100; подписи нет — null" }, + "period_date": { "type": ["string", "null"], "format": "date", "description": "конец периода колонки (period_end); у относительной подписи — от эффективной даты документа" }, + "period_kind": { "enum": ["absolute", "relative:current", "relative:prev_year_end", "relative:prev_prev_year_end", "relative:prev_period", "none"] } } } }, + "lines": { "type": "array", "items": { "$ref": "#/$defs/statementLine" }, "description": "у документа без строк (DISCLOSURE, форма без столбца кодов) — пустой массив" }, "checks": { "type": "object", - "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "required": ["relations_failed", "soft_relations_failed"], + "description": "Самопроверка формы по соотношениям §7 общим кодом (оба конвейера). Есть у BALANCE/INCOME со строками, нет у документа без строк. Пустые массивы — «проверено, сошлось».", "properties": { - "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, - "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, - "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "жёсткие соотношения, не сошедшиеся вне допуска ±1 и округления 0,001 %: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "мягкие соотношения (OQ-12, подстроки) и жёсткие в пределах округления (OQ-138): info" } } } - } + }, + "allOf": [ + { + "if": { + "properties": { "document_type": { "enum": ["BALANCE", "INCOME"] }, "lines": { "minItems": 1 } }, + "required": ["document_type", "lines"] + }, + "then": { "required": ["checks"] } + }, + { + "if": { "properties": { "lines": { "maxItems": 0 } }, "required": ["lines"] }, + "then": { "not": { "required": ["checks"] } } + } + ] } } } diff --git a/api/src/app.py b/api/src/app.py index f8fbb7e..346c202 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -4,8 +4,8 @@ `GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`), `subjects` (`image_request`, `result` — subject'ы NATS из настроек). - lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер - (`Worker` с `OcrNormalizer`). Справочники нормализатора загружаются из `SCAN_RULES_DIR` в `create_app`: битый - справочник — `ValueError`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт + (`Worker` с `OcrNormalizer`). Словари нормализатора — `default_rules()` пакета `arm_statements`; копия без `VERSION` — + `ValueError` в `create_app`, сервис не стартует. Подключение к NATS блокирует до доступности сервера, поэтому идёт фоновой задачей: HTTP отвечает сразу (`nats = down`), подписка на subject запроса `image` появляется после подключения. Сбой этой задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`, HTTP-сервер жив. @@ -35,7 +35,6 @@ from src.core.nats import NatsGateway, used_subjects from src.core.worker import Worker from src.gateway import create_hotfolder from src.normalizer import OcrNormalizer -from src.normalizer.rules import load_rules async def run_nats(gateway: NatsGateway, worker: Worker) -> None: @@ -66,7 +65,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: version = settings.VERSION up_time = datetime.now(UTC) hotfolder = create_hotfolder(settings) - normalizer = OcrNormalizer(load_rules(settings.SCAN_RULES_DIR)) # битый справочник — сервис не стартует (D-7) + normalizer = OcrNormalizer() # словари — default_rules(); нет VERSION копии — не стартует (P5-11) subjects = used_subjects(settings.OPENBAO.NATS) health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder, subjects=subjects) diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py index 98864aa..b82ccff 100644 --- a/api/src/contract/__init__.py +++ b/api/src/contract/__init__.py @@ -1,4 +1,4 @@ -"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`). +"""Контракт обмена с сервисами распознавания v2.0 (запрос — v1.0; схемы — `api/contracts/`, копии `docs/contracts/`). Публичный API: pydantic-модели запроса, конверта и файла результата, конструкторы конвертов, subject'ы NATS, идентификаторы сообщений (`Nats-Msg-Id`), проверка MAJOR версии. @@ -8,11 +8,14 @@ from src.contract.common import ( CONTRACT_VERSION, SUPPORTED_MAJOR, ContractModel, + DocumentWarning, ErrorCode, + FileWarning, Metrics, S3Ref, ServiceInfo, ServiceName, + Severity, is_supported_major, ) from src.contract.message import ( @@ -52,8 +55,10 @@ __all__ = [ "SUPPORTED_MAJOR", "TABULAR_REQUEST_SUBJECT", "ContractModel", + "DocumentWarning", "ErrorCode", "FileType", + "FileWarning", "MessageError", "MessageStatus", "Metrics", @@ -66,6 +71,7 @@ __all__ = [ "S3Ref", "ServiceInfo", "ServiceName", + "Severity", "StatementDocument", "ack_msg_id", "build_ack_message", diff --git a/api/src/contract/common.py b/api/src/contract/common.py index 75dce31..2a76ce1 100644 --- a/api/src/contract/common.py +++ b/api/src/contract/common.py @@ -1,4 +1,4 @@ -"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. +"""Общие основы моделей контракта v2.0 (запрос — v1.0): базовый класс, версия, типы, метрики и ошибки. Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` (кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — @@ -27,20 +27,21 @@ from pydantic import ( model_validator, ) -CONTRACT_VERSION = "1.0" -SUPPORTED_MAJOR = 1 # сервис принимает запросы только этого MAJOR; любой MINOR +CONTRACT_VERSION = "2.0" # файл результата и конверт (включая ack); запрос остаётся 1.x (P5-13) +SUPPORTED_MAJOR = 1 # MAJOR ЗАПРОСА: сервис принимает запросы только этого MAJOR; любой MINOR # Шаблоны версий — буквально из схем -SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR +SCHEMA_VERSION_PATTERN = r"^2\.\d+$" # recognition-result и recognition-result-message: MAJOR 2, любой MINOR +REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # recognition-request: MAJOR 1, любой MINOR RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version T = TypeVar("T") -_SUPPORTED_VERSION_RE = re.compile(SCHEMA_VERSION_PATTERN, re.ASCII) # ровно шаблон схемы, без int() +_SUPPORTED_VERSION_RE = re.compile(REQUEST_SCHEMA_VERSION_PATTERN, re.ASCII) # ровно шаблон схемы запроса, без int() def is_supported_major(schema_version: object) -> bool: - """MAJOR версии `MAJOR.MINOR` равен `SUPPORTED_MAJOR` (запрос неизвестного MAJOR → `SCHEMA_MISMATCH`). + """MAJOR версии запроса `MAJOR.MINOR` равен `SUPPORTED_MAJOR` (запрос неизвестного MAJOR → `SCHEMA_MISMATCH`). Принимает любой объект (версия берётся из ещё не проверенного тела): не строка и не `MAJOR.MINOR` — `False`. """ @@ -188,6 +189,7 @@ HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)] NonEmptyStr = Annotated[str, Field(min_length=1)] SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)] +RequestSchemaVersion = Annotated[str, Field(pattern=REQUEST_SCHEMA_VERSION_PATTERN)] ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)] # Уверенность распознавания, проценты (число 0–100) Confidence = Annotated[float, Field(ge=0, le=100)] | None @@ -223,6 +225,7 @@ class ServiceInfo(ContractModel): name: LaxServiceName version: str + statements_version: OptionalNotNull[NonEmptyStr] = None # коммит arm-statements; только в файле результата class S3Ref(ContractModel): @@ -232,6 +235,36 @@ class S3Ref(ContractModel): key: NonEmptyStr +class Severity(StrEnum): + ERROR = "error" + INFO = "info" + + +LaxSeverity = Annotated[Severity, Field(strict=False)] + +HeaderFieldName = Literal[ + "organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address" +] +WARNING_CODE_PATTERN = r"^[A-Z][A-Z0-9_]*$" + + +class DocumentWarning(ContractModel): + """`warning` схемы v2.0: предупреждение документа (уровень и привязка).""" + + code: Annotated[str, Field(pattern=WARNING_CODE_PATTERN)] + severity: LaxSeverity + message: str + line_refs: OptionalNotNull[list[str]] = None + column_index: OptionalNotNull[IntLike] = None + field: OptionalNotNull[HeaderFieldName] = None + + +class FileWarning(DocumentWarning): + """`fileWarning` схемы: предупреждение файла, `locator` — место в файле.""" + + locator: OptionalNotNull[str] = None + + class Metrics(ContractModel): """Метрики обработки; все поля необязательны.""" @@ -241,7 +274,7 @@ class Metrics(ContractModel): fields_total: OptionalNotNull[IntLike] = None fields_low_confidence: OptionalNotNull[IntLike] = None fields_unrecognized: OptionalNotNull[IntLike] = None - warnings: OptionalNotNull[list[str]] = None + warnings: OptionalNotNull[list[FileWarning]] = None DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"] diff --git a/api/src/contract/message.py b/api/src/contract/message.py index 7ea62fa..9f3933d 100644 --- a/api/src/contract/message.py +++ b/api/src/contract/message.py @@ -1,4 +1,4 @@ -"""Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` v1.0. +"""Конверт сообщения на `files.tabular_parsed`: `recognition-result-message.schema.json` v2.0. `processing` — ack «взял в работу» (до чтения файла из S3); `done | needs_check | error` — итог. Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и diff --git a/api/src/contract/request.py b/api/src/contract/request.py index 5165080..c422aa2 100644 --- a/api/src/contract/request.py +++ b/api/src/contract/request.py @@ -9,14 +9,22 @@ from typing import Annotated, Literal from pydantic import Field -from src.contract.common import ContractModel, ContractUUID, IntLike, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime +from src.contract.common import ( + ContractModel, + ContractUUID, + IntLike, + NonEmptyStr, + RequestSchemaVersion, + S3Ref, + UtcDatetime, +) from src.contract.subjects import Pipeline FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] class RecognitionRequest(ContractModel): - schema_version: SchemaVersion + schema_version: RequestSchemaVersion job_id: ContractUUID # recognition_job.id file_id: ContractUUID # stored_file.id pipeline: Pipeline diff --git a/api/src/contract/result.py b/api/src/contract/result.py index 1af260b..d5cd518 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -1,4 +1,4 @@ -"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.0. +"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v2.0. Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров; маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`). @@ -8,7 +8,7 @@ from enum import StrEnum from typing import Annotated, Literal, Self -from pydantic import Field, model_validator +from pydantic import Field, field_validator, model_validator from src.contract.common import ( Confidence, @@ -16,11 +16,14 @@ from src.contract.common import ( ContractModel, ContractUUID, DataType, + DocumentWarning, + HeaderFieldName, HeaderVariant, IntLike, LaxErrorCode, Metrics, Money, + NonEmptyStr, OptionalNotNull, SchemaVersion, ServiceInfo, @@ -45,6 +48,9 @@ __all__ = [ ] +DocumentStatus = Literal["done", "needs_check"] + + class ResultStatus(StrEnum): DONE = "done" NEEDS_CHECK = "needs_check" @@ -117,6 +123,8 @@ class OsvChecks(ContractModel): class OsvDocument(ContractModel): document_type: Literal["OSV"] locator: str # «sheet:Лист_1» + status: DocumentStatus # done — контрольные суммы §1.4 листа сошлись, иначе needs_check + warnings: list[DocumentWarning] # все уровня info, код с префиксом OSV_ header: OsvHeader group_schema: list[OsvGroup] columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН @@ -124,75 +132,148 @@ class OsvDocument(ContractModel): checks: OptionalNotNull[OsvChecks] = None -# --- ББ / ОФР / прочее ---------------------------------------------------------------------------------------------- +# --- ББ / ОФР / раскрытия ------------------------------------------------------------------------------------------- + +PeriodKind = Literal[ + "absolute", + "relative:current", + "relative:prev_year_end", + "relative:prev_prev_year_end", + "relative:prev_period", + "none", +] +LineStatus = Literal["ok", "unreadable", "missing", "detail"] +CODE_PATTERN = r"^\d{4,5}$" +INN_PATTERN = r"^(\d{10}|\d{12})$" +NonNegativeInt = Annotated[IntLike, Field(ge=0)] class StatementHeader(ContractModel): - form_name: OptionalNotNull[str] = None + """Шапка формы: значение, сырой текст и уверенность каждого поля (схема v2.0, все поля необязательны).""" + + form_name: str | None = None organization_name: str | None = None + organization_name_text: str | None = None organization_name_confidence: Confidence = None - inn: str | None = None + inn: Annotated[str, Field(pattern=INN_PATTERN)] | None = None + inn_text: str | None = None inn_confidence: Confidence = None - report_date_text: str | None = None report_date: ContractDate | None = None + report_date_text: str | None = None + report_date_confidence: Confidence = None units: str | None = None # «тыс. руб.» + units_text: str | None = None + units_confidence: Confidence = None okud: str | None = None + okud_text: str | None = None + okud_confidence: Confidence = None okpo: str | None = None + okpo_text: str | None = None + okpo_confidence: Confidence = None okopf: str | None = None + okopf_text: str | None = None + okopf_confidence: Confidence = None okfs: str | None = None + okfs_text: str | None = None + okfs_confidence: Confidence = None + okei: str | None = None # код ОКЕИ единицы: 384 — тыс. руб., 383 — руб., 385 — млн руб. + okei_text: str | None = None + okei_confidence: Confidence = None address: str | None = None + address_text: str | None = None + address_confidence: Confidence = None + needs_review_fields: OptionalNotNull[list[HeaderFieldName]] = None + + @field_validator("needs_review_fields") + @classmethod + def _unique_fields(cls, value: list[HeaderFieldName] | None) -> list[HeaderFieldName] | None: + if value is not None and len(set(value)) != len(value): + raise ValueError("needs_review_fields: поля не должны повторяться (uniqueItems)") + return value class StatementColumn(ContractModel): - column_index: IntLike + column_index: NonNegativeInt # номер столбца сетки кандидата шага 1; уникален в документе label: str # «На 30 сентября 2025 г.» + label_confidence: Confidence = None period_date: ContractDate | None = None + period_kind: PeriodKind class StatementValue(ContractModel): column_index: IntLike - amount: Money = None + amount: Money string_value: str | None = None - confidence: Confidence = None - is_recognized: OptionalNotNull[bool] = None + text: str | None + confidence: Confidence + is_recognized: bool + needs_review: bool class StatementLine(ContractModel): - line_code: str + row_index: NonNegativeInt + ref: NonEmptyStr # положение в источнике; уникален в документе + line_status: LineStatus + line_code: Annotated[str, Field(pattern=CODE_PATTERN)] | None + line_code_text: str | None line_code_confidence: Confidence = None - line_name: str + line_name: str | None line_name_confidence: Confidence = None + parent_ref: str | None = None + needs_review: bool values: list[StatementValue] + @model_validator(mode="after") + def _check_status_rules(self) -> Self: + """Буквально из `allOf` схемы: `line_code` задан ⇔ `line_status = ok`; у `detail` обязателен `parent_ref`.""" + if (self.line_status == "ok") != (self.line_code is not None): + raise ValueError("line_code задан тогда и только тогда, когда line_status = ok") + if self.line_status == "detail" and self.parent_ref is None: + raise ValueError("у строки detail обязателен parent_ref") + return self + class FailedRelation(ContractModel): """Нарушенное соотношение §7 по одной колонке. - `expected` — левая часть как в файле, `actual` — правая по строкам. + `expected` — левая часть (цель), `actual` — правая по строкам ok; `null` — цели нет или все члены пусты. """ column_index: IntLike - relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml + relation: str # «1600 = 1100 + 1200» + codes: Annotated[list[Annotated[str, Field(pattern=CODE_PATTERN)]], Field(min_length=1)] expected: Money actual: Money class StatementChecks(ContractModel): - """Самопроверка формы (v1.2): отсутствие объекта — «не проверялось», пустые списки — «сошлось».""" + """Самопроверка формы общим кодом: пустые списки — «проверено, сошлось».""" - relations_failed: list[FailedRelation] = Field(default_factory=list) - soft_relations_failed: list[FailedRelation] = Field(default_factory=list) - duplicate_line_codes: list[str] = Field(default_factory=list) + relations_failed: list[FailedRelation] + soft_relations_failed: list[FailedRelation] class StatementDocument(ContractModel): - document_type: Literal["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] - locator: str # «pages:1-2» + document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"] + form_variant: Literal["full", "simplified"] | None # обязателен; null — DISCLOSURE + locator: str # «file:aep_3.json;table:0» + status: DocumentStatus + warnings: list[DocumentWarning] header: StatementHeader columns: list[StatementColumn] lines: list[StatementLine] checks: OptionalNotNull[StatementChecks] = None + @model_validator(mode="after") + def _check_checks_presence(self) -> Self: + """`allOf` схемы: `checks` обязателен у BALANCE/INCOME со строками и запрещён у документа без строк.""" + if self.lines: + if self.document_type in ("BALANCE", "INCOME") and self.checks is None: + raise ValueError(f"у {self.document_type} со строками обязателен checks") + elif self.checks is not None: + raise ValueError("у документа без строк checks не допускается") + return self + ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")] @@ -214,3 +295,10 @@ class RecognitionResult(ContractModel): if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents: raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") return self + + @model_validator(mode="after") + def _check_statements_version(self) -> Self: + """Есть `StatementDocument` ⇒ `service.statements_version` задан (`allOf` схемы).""" + if any(isinstance(d, StatementDocument) for d in self.documents) and self.service.statements_version is None: + raise ValueError("при документах ББ/ОФР обязателен service.statements_version") + return self diff --git a/api/src/core/worker.py b/api/src/core/worker.py index 8252f38..dbd2c90 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.py @@ -38,6 +38,7 @@ from pydantic import ValidationError from src.contract import ( CONTRACT_VERSION, ErrorCode, + FileWarning, Metrics, RecognitionRequest, RecognitionResult, @@ -45,6 +46,7 @@ from src.contract import ( ResultStatus, S3Ref, ServiceInfo, + Severity, build_ack_message, build_done_message, build_error_message, @@ -65,6 +67,7 @@ __all__ = [ "ObjectStorage", "ResultPublisher", "Worker", + "build_result", "raw_key", "result_key", "utc_now", @@ -73,8 +76,8 @@ __all__ = [ # Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на # символ (T10.1). Длинное сообщение обрезается с многоточием. MAX_ERROR_MESSAGE_CHARS = 4000 -# Предел `metrics.warnings` конверта (R-30): не больше MAX_WARNINGS строк по MAX_WARNING_CHARS символов — конверт -# остаётся далеко меньше лимита NATS (1 МБ) при любом числе ошибочных файлов; лишние строки сводятся в последнюю +# Предел `metrics.warnings` конверта (R-30, P5-10): не больше MAX_WARNINGS структур, `message` не длиннее +# MAX_WARNING_CHARS символов — конверт остаётся далеко меньше лимита NATS (1 МБ); при переполнении остаются все `error` MAX_WARNINGS = 100 MAX_WARNING_CHARS = 1000 # Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов @@ -576,7 +579,7 @@ class Worker: return self._error( request, ErrorCode.UNKNOWN_ERROR, f"нормализатор ответа ИС упал: {_describe(exc)}", raw_s3_key=key ) - metrics = _outcome_metrics(outcome, key) + metrics = _outcome_metrics(outcome) if outcome.status is ResultStatus.ERROR: error = outcome.error if error is None: # исключено инвариантом NormalizeOutcome @@ -584,17 +587,13 @@ class Worker: return self._error(request, error.code, error.message, raw_s3_key=key, metrics=metrics) # 6. Успех: файл результата в S3, затем конверт с result_s3 - result = RecognitionResult.model_validate( - { - "schema_version": CONTRACT_VERSION, - "job_id": request.job_id, - "file_id": request.file_id, - "service": self.service, - "status": outcome.status, - "raw_s3_key": key, - "documents": list(outcome.documents), - **({"metrics": metrics} if metrics is not None else {}), - } + result = build_result( + outcome, + job_id=request.job_id, + file_id=request.file_id, + service=self.service, + raw_s3_key=key, + metrics=metrics, ) target = result_key(request.file_id, request.job_id) try: @@ -667,22 +666,61 @@ class Worker: await self._sleep(min(self._settings.SCAN_POLL_SECONDS, (deadline - now).total_seconds())) -def _outcome_metrics(outcome: NormalizeOutcome, raw_s3_key: str) -> Metrics | None: - """`metrics` конверта: метрики исхода + `warnings` исхода (R-25) в конце `metrics.warnings`. +def build_result( # noqa: PLR0913 - идентификаторы + исход + сервис; всё именованное + outcome: NormalizeOutcome, + *, + job_id: uuid.UUID, + file_id: uuid.UUID, + service: ServiceInfo, + raw_s3_key: str, + metrics: Metrics | None, +) -> RecognitionResult: + """Файл результата по исходу нормализации: `schema_version = CONTRACT_VERSION`, `service` с `statements_version` + исхода (P5-11; в конверт он не пишется).""" + result_service = ( + service.model_copy(update={"statements_version": outcome.statements_version}) + if outcome.statements_version is not None + else service + ) + return RecognitionResult.model_validate( + { + "schema_version": CONTRACT_VERSION, + "job_id": job_id, + "file_id": file_id, + "service": result_service, + "status": outcome.status, + "raw_s3_key": raw_s3_key, + "documents": list(outcome.documents), + **({"metrics": metrics} if metrics is not None else {}), + } + ) - Пустые `warnings` опускаются. Строки безопасны для конверта (`clean_text`), их не больше `MAX_WARNINGS` по - `MAX_WARNING_CHARS` символов; лишние сводятся в последнюю строку со ссылкой на сырой ответ (R-30). + +def _limit_warnings(warnings: list[FileWarning]) -> list[FileWarning]: + """Не больше `MAX_WARNINGS`: все `error`, затем `info` по порядку; итоговый порядок — исходный (P5-10).""" + if len(warnings) <= MAX_WARNINGS: + return warnings + errors = [i for i, w in enumerate(warnings) if w.severity is Severity.ERROR] + infos = [i for i, w in enumerate(warnings) if w.severity is not Severity.ERROR] + keep = sorted([*errors, *infos][:MAX_WARNINGS]) + return [warnings[i] for i in keep] + + +def _outcome_metrics(outcome: NormalizeOutcome) -> Metrics | None: + """`metrics` конверта и файла результата: метрики исхода + `warnings` исхода (R-25, P5-10). + + Пустые `warnings` опускаются. `message` безопасен для конверта (`clean_text`), структур не больше `MAX_WARNINGS`; + при переполнении остаются все `error`, затем `info` (строки-сводки нет). Тот же список — в конверте и в файле. """ metrics = outcome.metrics - lines = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings] - if not lines: + warnings = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings] + if not warnings: return metrics - if len(lines) > MAX_WARNINGS: - hidden = len(lines) - (MAX_WARNINGS - 1) - lines = [*lines[: MAX_WARNINGS - 1], f"ещё предупреждений: {hidden}; все ошибки файлов — в {raw_s3_key}"] - warnings = [clean_text(line, MAX_WARNING_CHARS) for line in lines] - base = metrics.model_dump(exclude_none=True) if metrics is not None else {} - return Metrics.model_validate({**base, "warnings": warnings}) + cleaned = [ + w.model_copy(update={"message": clean_text(w.message, MAX_WARNING_CHARS)}) for w in _limit_warnings(warnings) + ] + base = metrics.model_dump(exclude_none=True, exclude={"warnings"}) if metrics is not None else {} + return Metrics.model_validate({**base, "warnings": cleaned}) def _is_uuid(value: object) -> bool: diff --git a/api/src/normalizer/__init__.py b/api/src/normalizer/__init__.py index 394dd1f..14b6f89 100644 --- a/api/src/normalizer/__init__.py +++ b/api/src/normalizer/__init__.py @@ -1,23 +1,20 @@ -"""Нормализатор ответа ИС Сканирование (D-5, R-12, R-25, решения №57, №58, №60; slice-0-skeleton). +"""Нормализатор ответа ИС Сканирование (D-5, R-12, R-25, решения №57, №58, №60; контракт 2.0, #46). Нормализатор получает склеенный ответ ИС (`raw: object`, D-4): `{"job_id", "files": [{"name", "response", "parse_error"}, ...]}` по возрастанию имени — `json.loads` каждого файла уже выполнен склейкой (`src.gateway.merge.merge_answers`). Результат — `NormalizeOutcome`, из которого воркер собирает конверт -(`warnings` -> `metrics.warnings`) и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`. +(`warnings` -> `metrics.warnings`) и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json` +(`src.core.worker.build_result`). -Срез 3a: настоящая нормализация — `OcrNormalizer` (`src/normalizer/ocr/`, тесты — -`tests/unit/normalizer/test_ocr_normalizer.py`); `NullNormalizer` остаётся для тестов воркера Среза 0. - -В Срезе 0 единственная реализация — `NullNormalizer`: сводит ошибки по файлам (успех файла — `error.code == ""`), -при хотя бы одном успешном файле отвечает `NOT_IMPLEMENTED`. Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`) -появится в Срезе 3 новой реализацией протокола `Normalizer`. +Настоящая нормализация — `OcrNormalizer` (`src/normalizer/ocr/`, общий код `arm_statements`); `NullNormalizer` +остаётся для тестов воркера (P5-19): сводит ошибки по файлам (успех файла — `error.code == ""`), при хотя бы одном +успешном файле отвечает `NOT_IMPLEMENTED`; ошибочные файлы — предупреждения `JSON_FAILED` файла. """ -from typing import Protocol, Self, runtime_checkable +from typing import Protocol, runtime_checkable -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument +from arm_statements import default_rules, warning_to_contract +from src.contract import ErrorCode, FileWarning, MessageError, ResultStatus from src.normalizer.files import ( BAD_ENTRY_MESSAGE, NO_FILES_MESSAGE, @@ -27,6 +24,9 @@ from src.normalizer.files import ( FileError, file_error, ) +from src.normalizer.ocr import OcrNormalizer +from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE +from src.normalizer.outcome import NormalizeOutcome __all__ = [ "BAD_ENTRY_MESSAGE", @@ -45,38 +45,6 @@ __all__ = [ NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3" -class NormalizeOutcome(BaseModel): - """Итог нормализации: статус, документы, ошибка, метрики, предупреждения. - - `warnings` — строки для `metrics.warnings` конверта (R-25; внутренний тип, контракт не меняется): воркер - опускает поле, если список пуст. Инварианты: `status == error` <=> `error` задана; для done/needs_check - `documents` непусты (как в схеме файла результата), для error — пусты. Статус `processing` исключён: он не бывает - итогом. - """ - - model_config = ConfigDict(frozen=True, extra="forbid") - - status: ResultStatus - documents: list[StatementDocument] = Field(default_factory=list) - error: MessageError | None = None - metrics: Metrics | None = None - warnings: list[str] = Field(default_factory=list) - - @model_validator(mode="after") - def _check_invariants(self) -> Self: - if self.status is ResultStatus.ERROR: - if self.error is None: - raise ValueError("status=error требует error") - if self.documents: - raise ValueError("status=error не может содержать documents") - else: - if self.error is not None: - raise ValueError(f"status={self.status.value} не может содержать error") - if not self.documents: - raise ValueError(f"status={self.status.value} требует непустой documents") - return self - - @runtime_checkable class Normalizer(Protocol): """Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`. @@ -90,11 +58,12 @@ class Normalizer(Protocol): class NullNormalizer: - """Заглушка Среза 0 (D-5): сводка ошибок по файлам склейки, нормализации нет. + """Заглушка (D-5, P5-19): сводка ошибок по файлам склейки, нормализации нет. Все файлы ошибочны — `status = error` с кодом и сообщением первого ошибочного файла (порядок `files` — по возрастанию имени, D-4); есть хотя бы один успешный — `NOT_IMPLEMENTED`. В обоих случаях каждый ошибочный файл — - строка `warnings` `<имя>: <код ИС>: <сообщение>`. Структура склейки нарушена — `INVALID_FORMAT`. + предупреждение `JSON_FAILED` (`message` — `<код ИС>: <сообщение>`, `locator` — `file:<имя>`). Структура склейки + нарушена — `INVALID_FORMAT`. """ def normalize(self, raw: object) -> NormalizeOutcome: @@ -111,21 +80,18 @@ class NullNormalizer: succeeded += 1 else: errors.append(error) - warnings = [error.warning for error in errors] + rules = default_rules() + warnings = [ + FileWarning.model_validate(warning_to_contract(error.to_warning(rules), file_level=True)) + for error in errors + ] if succeeded: return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE, warnings) first = errors[0] return _error_outcome(first.code, first.summary, warnings) -def _error_outcome(code: ErrorCode, message: str, warnings: list[str] | None = None) -> NormalizeOutcome: +def _error_outcome(code: ErrorCode, message: str, warnings: list[FileWarning] | None = None) -> NormalizeOutcome: return NormalizeOutcome( status=ResultStatus.ERROR, error=MessageError(code=code, message=message), warnings=warnings or [] ) - - -_file_error = file_error # прежнее имя: его берёт старый OcrNormalizer до задачи 4 - - -# в конце модуля: `OcrNormalizer` берёт `NormalizeOutcome` и помощники ошибок из этого модуля лениво (цикл импортов) -from src.normalizer.ocr import NO_TABLES_MESSAGE, OcrNormalizer # noqa: E402 diff --git a/api/src/normalizer/files.py b/api/src/normalizer/files.py index 1bbdd65..4895c6e 100644 --- a/api/src/normalizer/files.py +++ b/api/src/normalizer/files.py @@ -14,19 +14,14 @@ BAD_ENTRY_MESSAGE = "элемент files не соответствует стр @dataclass(frozen=True) class FileError: - """Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`.""" + """Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для предупреждения `JSON_FAILED`.""" name: str code: ErrorCode # error.code сводки, если этот файл — первый ошибочный source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT) - message: str # сообщение файла: строка warnings + message: str # сообщение файла: часть message предупреждения summary: str # error.message сводки, если этот файл — первый ошибочный - @property - def warning(self) -> str: - """Строка `warnings` прежнего формата (до задачи 4).""" - return f"{self.name}: {self.source_code}: {self.message}" - def to_warning(self, rules: Rules) -> StatementWarning: """Предупреждение `JSON_FAILED` пакета: уровень задаёт словарь правил.""" return make_warning( diff --git a/api/src/normalizer/ocr/__init__.py b/api/src/normalizer/ocr/__init__.py index 78b2d00..d1aff9c 100644 --- a/api/src/normalizer/ocr/__init__.py +++ b/api/src/normalizer/ocr/__init__.py @@ -1,103 +1,60 @@ -"""Нормализатор OCR: конвейер чистых функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document` -(D-4, §11.1). Проверенные на стенде функции старого ядра (`../reference-old-arm/api/src/core/PDFWorker/`) перенесены -дословно; DB-обвязка не переносится.""" +"""Нормализатор ответа ИС Сканирование на общем коде `arm_statements` (спека scan-gateway, контракт 2.0). -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any +Обход набора json — `answer_set.normalize_answer_set` (адаптер шага 1, `normalize_candidate` пакета на каждую таблицу); +здесь результат пакета переводится в модели контракта функциями пакета `to_contract` / `warning_to_contract` (P5-15): +своего уплощения шапки и отбора ключей шлюз не пишет. +""" -import src.normalizer as normalizer_package # цикл: `src.normalizer` импортирует этот модуль в конце своего `__init__` -from src.contract import ErrorCode, Metrics, ResultStatus, StatementDocument -from src.normalizer.ocr.document import build_document -from src.normalizer.ocr.header import parse_file_header -from src.normalizer.rules import OcrRules +from arm_statements import ( + EnvelopeStatus, + Rules, + default_rules, + statements_version, + to_contract, + warning_to_contract, +) +from src.contract import FileWarning, MessageError, Metrics, ResultStatus, StatementDocument +from src.normalizer.ocr.answer_set import normalize_answer_set +from src.normalizer.outcome import NormalizeOutcome -if TYPE_CHECKING: - from src.normalizer import NormalizeOutcome - -__all__ = ["NO_TABLES_MESSAGE", "OcrNormalizer"] - -NO_TABLES_MESSAGE = "ни одной таблицы не разобрано" -NO_CODE_COLUMN_MESSAGE = "нет шапки и столбца кода" -FULL_CONFIDENCE = 100.0 +__all__ = ["OcrNormalizer"] class OcrNormalizer: - """Нормализатор ответа ИС Сканирование: ошибочные файлы — как в Срезе 0, из успешных собираются документы по - одному на таблицу; статус — `done` только когда всё распознано и ни один файл набора не ошибочен (D-6, №28).""" + """Нормализатор ответа ИС: статус и документы определяет пакет, шлюз переводит их в контракт 2.0. - def __init__(self, rules: OcrRules) -> None: - self._rules = rules + `version` — версия копии `arm_statements` (по умолчанию — из файла `VERSION` копии, P5-11); нет версии — + сервис не стартует. + """ - def normalize(self, raw: object) -> "NormalizeOutcome": - # имена пакета берутся при вызове: при импорте этого модуля `src.normalizer` ещё не дочитан до конца - outcome_cls = normalizer_package.NormalizeOutcome - error_outcome = normalizer_package._error_outcome - file_error = normalizer_package._file_error + def __init__(self, rules: Rules | None = None, *, version: str | None = None) -> None: + self._rules = rules if rules is not None else default_rules() + resolved = version if version is not None else statements_version() + if resolved is None: + raise ValueError("копия arm_statements без VERSION: разложите её sync-statements") + self._version = resolved - if not isinstance(raw, dict) or not isinstance(raw.get("files"), list): - return error_outcome(ErrorCode.INVALID_FORMAT, normalizer_package.NOT_MERGED_MESSAGE) - files: list[object] = raw["files"] - if not files: - return error_outcome(ErrorCode.INVALID_FORMAT, normalizer_package.NO_FILES_MESSAGE) - warnings: list[str] = [] - documents: list[StatementDocument] = [] - fully_recognized = True - succeeded = 0 - failed = 0 - first_error = None - for index, entry in enumerate(files): - error = file_error(index, entry) - if error is not None: - warnings.append(error.warning) - first_error = first_error or error - failed += 1 - continue - succeeded += 1 - assert isinstance(entry, dict) # проверено _file_error - file_documents, file_warnings, file_recognized = self._normalize_file(entry) - documents.extend(file_documents) - warnings.extend(file_warnings) - fully_recognized = fully_recognized and file_recognized - if not succeeded and first_error is not None: - return error_outcome(first_error.code, first_error.summary, warnings) - if not documents: - return error_outcome(ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE, warnings) - # №28: потерянный ошибочный файл (например, ОФР при успешном балансе) не остаётся под `done` - status = ResultStatus.DONE if fully_recognized and not failed else ResultStatus.NEEDS_CHECK - rows = sum(len(document.lines) for document in documents) - return outcome_cls(status=status, documents=documents, metrics=Metrics(rows=rows), warnings=warnings) + @property + def statements_version(self) -> str: + return self._version - def _normalize_file(self, entry: dict[str, Any]) -> tuple[list[StatementDocument], list[str], bool]: - """Документы одного успешного файла, предупреждения и признак «всё распознано».""" - name = str(entry["name"]) - response = entry["response"] - data_ocr = response.get("data_ocr") if isinstance(response, dict) else None - if not isinstance(data_ocr, Mapping): - return [], [f"{name}: data_ocr отсутствует или не объект"], False - header = parse_file_header(data_ocr, self._rules.lookalikes) - warnings: list[str] = [] - if header.inn is None: - warnings.append(f"{name}: ИНН не распознан") - elif header.inn_confidence != FULL_CONFIDENCE: - warnings.append(f"{name}: ИНН распознан неуверенно") - if header.report_date is None: - warnings.append(f"{name}: дата отчёта не разобрана") - tables = data_ocr.get("table_structure") - documents: list[StatementDocument] = [] - recognized = True - for index, table in enumerate(tables if isinstance(tables, list) else []): - prefix = f"{name}: table:{index}: " - try: - built = build_document(table if isinstance(table, Mapping) else {}, index, name, header, self._rules) - except Exception as exc: # §11.1 п. 9: ошибка одной таблицы не роняет файл - warnings.append(f"{prefix}BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}") - recognized = False - continue - if built is None: - warnings.append(prefix + NO_CODE_COLUMN_MESSAGE) - recognized = False - continue - documents.append(built.document) - warnings.extend(prefix + warning for warning in built.warnings) - recognized = recognized and built.fully_recognized - return documents, warnings, recognized and not warnings + def normalize(self, raw: object) -> NormalizeOutcome: + result = normalize_answer_set(raw, self._rules) + warnings = [FileWarning.model_validate(warning_to_contract(w, file_level=True)) for w in result.file_warnings] + if result.status is EnvelopeStatus.ERROR: + error = result.error + if error is None: # исключено инвариантом AnswerSetResult + raise ValueError("AnswerSetResult со status=error без error") + return NormalizeOutcome( + status=ResultStatus.ERROR, + error=MessageError(code=error.code, message=error.message), + warnings=warnings, + ) + documents = [StatementDocument.model_validate(to_contract(d)) for d in result.documents] + return NormalizeOutcome( + status=ResultStatus(result.status.value), + documents=documents, + metrics=Metrics(rows=sum(len(d.lines) for d in documents)), + warnings=warnings, + statements_version=self._version, + ) diff --git a/api/src/normalizer/ocr/document.py b/api/src/normalizer/ocr/document.py deleted file mode 100644 index 1cc9cc1..0000000 --- a/api/src/normalizer/ocr/document.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Сборка `statementDocument` v1.2 из одной таблицы `data_ocr.table_structure[i]` (spec «Нормализация ответа ИС», -D-6, D-7): тип по label, при расхождении с единодушными кодами — по кодам (#30), колонки из раскладки, строки только -с кодом, значения с `is_recognized`, имена строк из справочника редакции.""" - -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import date -from decimal import Decimal -from typing import Literal - -from src.contract.result import StatementColumn, StatementDocument, StatementHeader, StatementLine, StatementValue -from src.normalizer.ocr.amounts import is_blank, parse_amount -from src.normalizer.ocr.dates import is_date_sized, parse_date -from src.normalizer.ocr.header import FileHeader -from src.normalizer.ocr.layout import Cell, cell_text -from src.normalizer.ocr.rows import ( - ParsedRow, - ParsedTable, - RowKind, - parse_table_rows, - table_type_by_codes, - table_type_by_unanimous_codes, -) -from src.normalizer.ocr.text import clean_value, collapse_whitespace, fix_lookalikes, normalize_header -from src.normalizer.rules import Edition, OcrRules - -__all__ = ["BuiltDocument", "build_document"] - -FULL_CONFIDENCE = 100.0 -CAPPED_CONFIDENCE = 99.0 -MONEY_DECIMAL_PLACES = 2 -DocumentType = Literal["BALANCE", "INCOME", "OTHER"] - - -@dataclass(frozen=True) -class BuiltDocument: - document: StatementDocument - warnings: list[str] - fully_recognized: bool - edition_id: str | None - - -@dataclass -class _Build: - """Накопитель предупреждений и признака «всё распознано» на время сборки одного документа.""" - - warnings: list[str] - fully_recognized: bool = True - - def warn(self, text: str) -> None: - self.warnings.append(text) - self.fully_recognized = False - - -def _label_text(table: Mapping[str, object]) -> str: - label = table.get("label") - form_name = label.get("form_name") if isinstance(label, Mapping) else None - return collapse_whitespace(form_name) if isinstance(form_name, str) else "" - - -def _type_by_label(label: str, rules: OcrRules) -> DocumentType | None: - normalized = normalize_header(label) - for needle, kind in rules.labels.items(): - if needle in normalized: - return kind - return None - - -def _resolve_type(label: str, parsed: ParsedTable, rules: OcrRules, build: _Build) -> DocumentType: - """Тип документа: label и коды согласны → label; расходятся при единодушных кодах → коды (#30), при смешанных → - label; label не сопоставлен → коды по большинству; иначе OTHER. Любое расхождение — предупреждение.""" - by_label = _type_by_label(label, rules) - by_codes = table_type_by_codes(parsed) - if by_label is not None: - if by_codes is None or by_codes == by_label: - return by_label - unanimous = table_type_by_unanimous_codes(parsed) - if unanimous is not None: - build.warn(f"тип по label ({label}) не совпал с кодами: взят {unanimous}") - return unanimous - build.warn(f"тип по кодам ({by_codes}) не совпал с label ({label})") - return by_label - if by_codes is not None: - build.warn("тип определён по кодам") - return by_codes - build.warn("тип не определён: OTHER") - return "OTHER" - - -def _columns(parsed: ParsedTable, lookalikes: Mapping[str, str], build: _Build) -> list[StatementColumn]: - """Колонки значений первой шапки: `label` — текст ячейки шапки, `period_date` — `parse_date`.""" - header = parsed.rows[0] - columns: list[StatementColumn] = [] - for position, source_idx in enumerate(header.layout.value_idxs): - cell = header.cells.get(source_idx) - label = collapse_whitespace(cell.value) if cell is not None else "" - # решение №31: подпись длиннее даты не разбирается и не проходит `fix_lookalikes` - period = parse_date(fix_lookalikes(label, lookalikes)) if label and is_date_sized(label) else None - if period is None and not parsed.synthetic_header: - build.warn(f"колонка {position} без даты: {label}") - columns.append(StatementColumn(column_index=position, label=label, period_date=period)) - if parsed.synthetic_header: - build.warn("шапка синтезирована: колонки без дат") - if not columns: - build.warn("нет колонок значений") - return columns - - -def _latest_period(columns: list[StatementColumn]) -> date | None: - dates = [column.period_date for column in columns if column.period_date is not None] - return max(dates) if dates else None - - -def _choose_edition( - header: FileHeader, columns: list[StatementColumn], codes: list[str], rules: OcrRules, build: _Build -) -> Edition | None: - edition = rules.edition_for(header.report_date) or rules.edition_for(_latest_period(columns)) - if edition is None: - edition = rules.edition_by_codes(codes) - if edition is None: - build.warn("редакция не определена") - return None - build.warn(f"редакция определена по кодам: {edition.id}") - if not edition.lines: - build.warn(f"редакция {edition.id} не заполнена") - return edition - - -def _decimal_places(amount: Decimal) -> int: - exponent = amount.as_tuple().exponent - return -exponent if isinstance(exponent, int) and exponent < 0 else 0 - - -def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValue: - if cell is None: - build.fully_recognized = False - return StatementValue( - column_index=column_index, amount=None, string_value=None, confidence=0, is_recognized=False - ) - parsed = parse_amount(cell.value) - text = collapse_whitespace(cell.value) - amount = parsed.amount - verified = parsed.verified - if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES: - # решение №30: `Money` контракта — не больше двух знаков; «1,234» теперь = 1234 (правило тысяч в parse_amount, - # AURORA-1405), а «0,125», «12.3456» и т. п. — артефакт OCR: сумма не принимается, текст остаётся в - # string_value, иначе ValidationError ронял бы всю таблицу - amount, verified = None, False - # DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана - string_value = text if amount is None and text and not is_blank(text) else None - recognized = cell.confidence == FULL_CONFIDENCE and verified - if not recognized: - build.fully_recognized = False - return StatementValue( - column_index=column_index, - amount=amount, - string_value=string_value, - confidence=cell.confidence, - is_recognized=recognized, - ) - - -def _line(row: ParsedRow, column_count: int, edition: Edition | None, rules: OcrRules, build: _Build) -> StatementLine: - row_cells = row.cells - code_cell = row_cells[row.layout.code_idx] - code = clean_value(code_cell.value) - code_clean = code == code_cell.value.strip() - label_idx = row.layout.label_idx - name_cell = row_cells.get(label_idx) if label_idx is not None else None - if edition is not None and code in edition.lines: - name = edition.lines[code] - # spec: уверенность кода и словарного имени — confidence ячейки, если текст чистый и код в справочнике - code_confidence = code_cell.confidence if code_clean else min(code_cell.confidence, CAPPED_CONFIDENCE) - name_confidence = code_confidence - else: - # пустая редакция уже даёт одно «редакция не заполнена» — по строке не предупреждаем - elsewhere = rules.editions_with(code) if edition is not None and edition.lines else [] - if elsewhere: - build.warn(f"код {code} из редакции {', '.join(elsewhere)}") - name = collapse_whitespace(name_cell.value) if name_cell is not None else "" - # кода нет в выбранной редакции: «иначе min(confidence, 99)» — всегда, имя из OCR со своим confidence - code_confidence = min(code_cell.confidence, CAPPED_CONFIDENCE) - name_confidence = name_cell.confidence if name_cell is not None else 0.0 - if code_confidence < FULL_CONFIDENCE or name_confidence < FULL_CONFIDENCE: - build.fully_recognized = False - # values — по позиции колонки документа (R-3): лишние индексы раскладки строки отбрасываются, недостающие — - # «ячейки нет» - value_idxs = row.layout.value_idxs - values = [ - _value(row_cells.get(value_idxs[position]) if position < len(value_idxs) else None, position, build) - for position in range(column_count) - ] - return StatementLine( - line_code=code, - line_code_confidence=code_confidence, - line_name=name, - line_name_confidence=name_confidence, - values=values, - ) - - -def _statement_header(label: str, header: FileHeader, build: _Build) -> StatementHeader: - if header.inn_confidence != FULL_CONFIDENCE or header.organization_confidence != FULL_CONFIDENCE: - build.fully_recognized = False - if header.report_date is None or not header.report_date_recognized: - build.fully_recognized = False - # form_name в схеме без null: нет label — поле не передаётся (явный None контрактная модель отвергает) - form_name = {"form_name": label} if label else {} - return StatementHeader( - **form_name, - organization_name=header.organization_name, - organization_name_confidence=header.organization_confidence, - inn=header.inn, - inn_confidence=header.inn_confidence, - report_date_text=header.report_date_text, - report_date=header.report_date, - units=header.units, - ) - - -def build_document( - table: Mapping[str, object], index: int, file_name: str, header: FileHeader, rules: OcrRules -) -> BuiltDocument | None: - rows = table.get("rows") - if not isinstance(rows, list): - return None - parsed = parse_table_rows(rows, rules.known_codes()) - if parsed is None or len(parsed.rows) < 2: # noqa: PLR2004 — шапка и хотя бы одна строка данных - return None - build = _Build(warnings=[]) - label = _label_text(table) - document_type = _resolve_type(label, parsed, rules, build) - columns = _columns(parsed, rules.lookalikes, build) - data_rows = [row for row in parsed.rows[1:] if row.kind is not RowKind.HEADER] - if any(len(row.layout.value_idxs) != len(columns) for row in data_rows): - build.warn("повторная шапка изменила число колонок значений") # одно предупреждение на таблицу - # №29: строки, отброшенные разбором молча (мусорный код; пустой код при наличии сумм) — по одному предупреждению - if parsed.skipped_non_numeric_code: - build.warn(f"строк с нечисловым кодом пропущено: {parsed.skipped_non_numeric_code}") - if parsed.skipped_no_code_with_amounts: - build.warn(f"строк без кода с суммами пропущено: {parsed.skipped_no_code_with_amounts}") - # OQ-156: склеенные строки ИС разлеплены — разбивку подтверждает аналитик - if parsed.split_merged_codes: - build.warn(f"строк с несколькими кодами разлеплено: {parsed.split_merged_codes}") - codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows] - edition = _choose_edition(header, columns, codes, rules, build) - lines: list[StatementLine] = [] - seen: set[str] = set() - for row in data_rows: - code = clean_value(cell_text(row.cells, row.layout.code_idx)) - if code in seen: # решение №15: DBML уникален по (meta_id, line_code), как §12 п. 5 у Excel-пути - build.warn(f"код {code} повторяется: оставлена первая строка") - continue - seen.add(code) - lines.append(_line(row, len(columns), edition, rules, build)) - statement_header = _statement_header(label, header, build) - document = StatementDocument( - document_type=document_type, - locator=f"file:{file_name};table:{index}", - header=statement_header, - columns=columns, - lines=lines, - ) - return BuiltDocument(document, build.warnings, build.fully_recognized, edition.id if edition else None) diff --git a/api/src/normalizer/outcome.py b/api/src/normalizer/outcome.py new file mode 100644 index 0000000..2305836 --- /dev/null +++ b/api/src/normalizer/outcome.py @@ -0,0 +1,44 @@ +"""Итог нормализации `NormalizeOutcome` (отдельный модуль: им пользуются и `src.normalizer`, и `OcrNormalizer`).""" + +from typing import Self + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from src.contract import FileWarning, MessageError, Metrics, ResultStatus, StatementDocument + +__all__ = ["NormalizeOutcome"] + + +class NormalizeOutcome(BaseModel): + """Итог нормализации: статус, документы, ошибка, метрики, предупреждения файла. + + `warnings` — структуры `metrics.warnings` конверта и файла результата (P5-10): воркер опускает поле, если + список пуст. Инварианты: `status == error` <=> `error` задана; для done/needs_check `documents` непусты (как в + схеме файла результата) и `statements_version` задан (P5-11), для error — документы пусты. Статус `processing` + исключён: он не бывает итогом. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + status: ResultStatus + documents: list[StatementDocument] = Field(default_factory=list) + error: MessageError | None = None + metrics: Metrics | None = None + warnings: list[FileWarning] = Field(default_factory=list) + statements_version: str | None = None # версия копии arm_statements; нужна, когда есть документы + + @model_validator(mode="after") + def _check_invariants(self) -> Self: + if self.status is ResultStatus.ERROR: + if self.error is None: + raise ValueError("status=error требует error") + if self.documents: + raise ValueError("status=error не может содержать documents") + else: + if self.error is not None: + raise ValueError(f"status={self.status.value} не может содержать error") + if not self.documents: + raise ValueError(f"status={self.status.value} требует непустой documents") + if self.documents and self.statements_version is None: + raise ValueError("documents требуют statements_version") + return self diff --git a/api/tests/golden_update.py b/api/tests/golden_update.py index a60c8f9..4fa60d4 100644 --- a/api/tests/golden_update.py +++ b/api/tests/golden_update.py @@ -9,15 +9,16 @@ import sys from pathlib import Path from typing import Any +from arm_statements import default_rules from src.gateway.hotfolder import AnswerFile from src.gateway.merge import merge_answers from src.normalizer import NormalizeOutcome, OcrNormalizer -from src.normalizer.rules import load_rules API_DIR = Path(__file__).resolve().parents[1] REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" GOLDEN_DIR = REAL_FIXTURES / "expected_normalize" JOB = "3f1c2b7a-0000-4000-8000-000000000001" +GOLDEN_VERSION = "0" * 40 # фиксированная версия копии: golden от раскладки копии не зависит def golden_cases() -> list[str]: @@ -44,7 +45,7 @@ def real_set(name: str) -> dict[str, Any]: def outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]: return { "status": outcome.status.value, - "warnings": list(outcome.warnings), + "warnings": [w.to_wire() for w in outcome.warnings], "metrics": outcome.metrics.model_dump(mode="json", exclude_none=True) if outcome.metrics else None, "error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None, "documents": [doc.model_dump(mode="json", exclude_none=True) for doc in outcome.documents], @@ -55,10 +56,10 @@ def main() -> int: if not REAL_FIXTURES.is_dir(): print(f"нет каталога {REAL_FIXTURES}", file=sys.stderr) return 2 - rules = load_rules(API_DIR / "rules") + rules = default_rules() GOLDEN_DIR.mkdir(exist_ok=True) for name in golden_cases(): - payload = outcome_to_json(OcrNormalizer(rules).normalize(real_set(name))) + payload = outcome_to_json(OcrNormalizer(rules, version=GOLDEN_VERSION).normalize(real_set(name))) (GOLDEN_DIR / f"{name}.json").write_text( json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) diff --git a/api/tests/unit/normalizer/test_contract_boundary.py b/api/tests/unit/normalizer/test_contract_boundary.py new file mode 100644 index 0000000..fd43ee2 --- /dev/null +++ b/api/tests/unit/normalizer/test_contract_boundary.py @@ -0,0 +1,88 @@ +"""Граница «пакет arm_statements → модели контракта шлюза»: `dict` пакета проходит модели и схему v2.0. + +Правила перевода проверяет пакет; здесь — что его результат принимают модели шлюза и JSON Schema `result`. +""" + +import json +import uuid + +import pytest + +import src.normalizer.ocr as ocr_module +from arm_statements import WarningCode, default_rules, make_warning, warning_to_contract +from src.contract import ErrorCode, FileWarning, ServiceInfo, ServiceName +from src.core.worker import build_result +from src.normalizer import NullNormalizer, OcrNormalizer +from tests.unit.normalizer.ocr_builders import BAD, BALANCE, INCOME, merged, ocr_table, response +from tests.unit.test_contract import VALIDATORS + +SERVICE = ServiceInfo(name=ServiceName.SCAN_GATEWAY, version="test") + + +def test_mapped_document_passes_schema_v2() -> None: + outcome = OcrNormalizer(default_rules(), version="abc1234").normalize( + merged( + ( + "j.json", + response( + ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME) + ), + ) + ) + ) + result = build_result( + outcome, job_id=uuid.uuid4(), file_id=uuid.uuid4(), service=SERVICE, raw_s3_key="raw/x.json", metrics=None + ) + payload = result.to_wire() + VALIDATORS["result"].validate(payload) + assert payload["schema_version"] == "2.0" and payload["service"]["statements_version"] == "abc1234" + head = payload["documents"][0]["header"] + assert (head["form_name"], head["inn"], head["inn_text"], head["inn_confidence"]) == ( + "Бухгалтерский баланс", + "7700000000", + "7700000000", + 100.0, + ) + assert "column_key" not in json.dumps(payload) + + +def test_package_warning_fits_file_warning_model() -> None: + w = make_warning(WarningCode.ROW_SPLIT, "разлеплено", default_rules(), line_refs=("row:3#1", "row:3#2")) + assert FileWarning.model_validate(warning_to_contract(w, file_level=True)).to_wire() == { + "code": "ROW_SPLIT", + "severity": "info", + "message": "разлеплено", + "line_refs": ["row:3#1", "row:3#2"], + } + + +def test_empty_target_relation_passes_model() -> None: + rows = [ + ("Наименование показателя", "Код", "На 31 декабря 2024 г."), + ("Запасы", "1210", "60"), + ("Итого II", "1200", "60"), + ] + outcome = OcrNormalizer(default_rules(), version="abc1234").normalize( + merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows)))) + ) + failed = outcome.documents[0].checks.relations_failed # type: ignore[union-attr] + assert any(f.relation == "1600 = 1100 + 1200" and f.expected is None for f in failed) + + +def test_null_normalizer_structured_json_failed() -> None: + outcome = NullNormalizer().normalize(merged(("j.json", response()), ("j_ofr.json", response(error=BAD)))) + assert outcome.error is not None and outcome.error.code is ErrorCode.NOT_IMPLEMENTED + assert [w.to_wire() for w in outcome.warnings] == [ + { + "code": "JSON_FAILED", + "severity": "error", + "message": "INVALID_DOCUMENT: Не удалось определить тип документа.", + "locator": "file:j_ofr.json", + } + ] + + +def test_normalizer_requires_statements_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ocr_module, "statements_version", lambda path=None: None) + with pytest.raises(ValueError, match="без VERSION"): + OcrNormalizer(default_rules()) diff --git a/api/tests/unit/normalizer/test_document.py b/api/tests/unit/normalizer/test_document.py deleted file mode 100644 index 508acdb..0000000 --- a/api/tests/unit/normalizer/test_document.py +++ /dev/null @@ -1,678 +0,0 @@ -"""Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7).""" - -import json -import time -from dataclasses import replace -from datetime import date -from decimal import Decimal -from pathlib import Path - -import pytest - -from src.normalizer.ocr.document import BuiltDocument, build_document -from src.normalizer.ocr.header import FileHeader -from src.normalizer.rules import Edition, OcrRules, load_rules - -API_DIR = Path(__file__).resolve().parents[3] -RULES = load_rules(API_DIR / "rules") -HEADER_2024 = FileHeader( - "ООО Ромашка", 100.0, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", date(2024, 12, 31), True -) - - -def _row(index: int, *cells: tuple[int, str, float]) -> dict: - return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} - - -def _table(label: str, *rows: dict) -> dict: - return {"label": {"form_name": label, "confidence": 100}, "rows": list(rows)} - - -CLEAN_BALANCE = _table( - "Бухгалтерский баланс", - _row( - 0, - (0, "Наименование показателя", 100), - (1, "Код", 100), - (2, "На 31 декабря 2024 г.", 100), - (3, "На 31 декабря 2023 г.", 100), - ), - _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1 234", 100), (3, "(56)", 100)), - _row(2, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1 234", 100), (3, "-", 100)), -) - - -def _build(table: dict, header: FileHeader = HEADER_2024, index: int = 0) -> BuiltDocument: - built = build_document(table, index, "job.json", header, RULES) - assert built is not None - return built - - -class TestCleanTable: - def test_document_fields(self) -> None: - built = _build(CLEAN_BALANCE) - doc = built.document - assert doc.document_type == "BALANCE" - assert doc.locator == "file:job.json;table:0" - assert doc.header.form_name == "Бухгалтерский баланс" - assert doc.header.inn == "7700000000" and doc.header.inn_confidence == 100 - assert doc.header.report_date == date(2024, 12, 31) and doc.header.report_date_text == "на 31 декабря 2024 г." - assert doc.header.units == "тыс. руб." and doc.header.okud is None - assert [(c.column_index, c.label, c.period_date) for c in doc.columns] == [ - (0, "На 31 декабря 2024 г.", date(2024, 12, 31)), - (1, "На 31 декабря 2023 г.", date(2023, 12, 31)), - ] - assert doc.checks is None - - def test_lines_and_values(self) -> None: - doc = _build(CLEAN_BALANCE).document - assert [line.line_code for line in doc.lines] == ["1150", "1100"] - first = doc.lines[0] - assert ( - first.line_name == "Основные средства" - and first.line_code_confidence == 100 - and first.line_name_confidence == 100 - ) - values = first.values - assert [v.column_index for v in values] == [0, 1] - assert ( - values[0].amount == Decimal("1234") and values[0].string_value is None and values[0].is_recognized is True - ) - assert values[1].amount == Decimal("-56") and values[1].is_recognized is True - blank = doc.lines[1].values[1] - assert blank.amount is None and blank.string_value is None and blank.is_recognized is True - - def test_fully_recognized_and_edition(self) -> None: - built = _build(CLEAN_BALANCE) - assert built.fully_recognized is True and built.warnings == [] and built.edition_id == "66n" - - -class TestNoise: - def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100)), - ) - built = _build(table) - doc = built.document - # кода нет в справочнике — имя из OCR, уверенность кода не выше 99 (spec: «иначе min(confidence, 99)») - assert doc.lines[0].line_code == "11501" and doc.lines[0].line_name == "Расшифровка" - assert doc.lines[0].line_code_confidence == 99 and doc.lines[0].line_name_confidence == 100 - assert doc.lines[1].line_code == "1150" and doc.lines[1].line_code_confidence == 90 # уже < 100, не поднимается - assert doc.lines[1].line_name == "Основные средства" - assert built.fully_recognized is False - - def test_non_integer_code_row_is_dropped(self) -> None: - """Перенос `_classify_row`: «1150 :» не целое число → строка считается повторным заголовком и пропускается.""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "НМА", 100), (1, "1110", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100)), - ) - assert [line.line_code for line in _build(table).document.lines] == ["1110"] - - def test_duplicate_code_keeps_first(self) -> None: - """Решение №15: DBML уникален по (meta_id, line_code) — повтор кода даёт одну строку и предупреждение.""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100), (3, "8", 100)), - ) - built = _build(table) - assert [line.line_code for line in built.document.lines] == ["1150"] - assert built.document.lines[0].values[0].amount == Decimal("1") - assert "код 1150 повторяется: оставлена первая строка" in built.warnings and built.fully_recognized is False - - def test_missing_cells_keep_column_count(self) -> None: - """Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок.""" - table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100))) - values = _build(table).document.lines[0].values - assert [(v.column_index, v.amount, v.string_value, v.confidence, v.is_recognized) for v in values] == [ - (0, None, None, 0, False), - (1, None, None, 0, False), - ] - - def test_unverified_amount_is_not_recognized(self) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "(7744536}", 100), (3, "x", 100)), - ) - values = _build(table).document.lines[0].values - assert ( - values[0].amount == Decimal("7744536") - and values[0].is_recognized is False - and values[0].string_value is None - ) - assert values[1].amount is None and values[1].is_recognized is False and values[1].string_value == "x" - - def test_synthetic_header_columns_without_dates(self) -> None: - table = _table( - "Бухгалтерский баланс", - _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)), - _row(1, (1, "1110", 100), (0, "НМА", 100), (2, "2", 100)), - ) - built = _build(table) - assert [(c.column_index, c.label, c.period_date) for c in built.document.columns] == [(0, "", None)] - assert "шапка синтезирована" in " ".join(built.warnings) and built.fully_recognized is False - - -class TestTypeAndEdition: - def test_type_by_codes_when_label_unknown(self) -> None: - built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"])) - assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings - - def test_unanimous_codes_win_over_label_with_warning(self) -> None: - """#30: label «Бухгалтерский баланс» от ИС при кодах только 2xxx — тип по кодам, label в предупреждении.""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1 182 108", 100), (3, "807 029", 100)), - _row(2, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(618 060)", 100), (3, "(371 193)", 100)), - ) - built = _build(table) - assert built.document.document_type == "INCOME" - assert built.document.header.form_name == "Бухгалтерский баланс" # первичка не мутируется - assert "тип по label (Бухгалтерский баланс) не совпал с кодами: взят INCOME" in built.warnings - assert built.fully_recognized is False - - def test_label_wins_over_mixed_codes_with_warning(self) -> None: - """Коды обеих групп (шум OCR) — кодам не верим, тип по label.""" - table = _table( - "Отчет о финансовых результатах", - *CLEAN_BALANCE["rows"], - _row(3, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (3, "2", 100)), - ) - built = _build(table) - assert built.document.document_type == "INCOME" - assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings) - assert built.fully_recognized is False - - def test_other_when_nothing_known(self) -> None: - table = _table( - "Приложение", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "b", 100), (1, "5200", 100), (2, "1", 100), (3, "2", 100)), - ) - built = _build(table) - assert built.document.document_type == "OTHER" and "тип не определён: OTHER" in built.warnings - - def test_edition_2025_is_empty(self) -> None: - header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False) - table = _table( - "Бухгалтерский баланс", - _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня 2025 г.", 100)), - _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100)), - ) - built = _build(table, header) - assert built.edition_id == "fsbu4" and "редакция fsbu4 не заполнена" in built.warnings - assert built.document.lines[0].line_name == "Основные средства" # из OCR - assert built.document.lines[0].line_name_confidence == 100 # confidence ячейки наименования - assert built.fully_recognized is False - - def test_edition_by_codes_when_no_dates(self) -> None: - header = FileHeader(None, None, None, None, None, None, None, False) - table = _table("Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100))) - built = _build(table, header) - assert built.edition_id == "66n" and "редакция определена по кодам: 66n" in built.warnings - - def test_unparsed_table_is_none(self) -> None: - assert ( - build_document(_table("x", _row(0, (0, "a", 100), (1, "b", 100))), 0, "job.json", HEADER_2024, RULES) - is None - ) - - -def test_missing_label_omits_form_name() -> None: - """Контракт: `form_name` без `null` — при отсутствии label поле не задаётся, сборка не падает.""" - table = {"rows": CLEAN_BALANCE["rows"]} - built = _build(table) - assert built.document.header.form_name is None - assert "form_name" not in built.document.to_wire()["header"] - - -def test_table_rows_not_list_is_none() -> None: - assert build_document({"label": {"form_name": "x"}, "rows": "oops"}, 0, "job.json", HEADER_2024, RULES) is None - - -REPEATED_HEADER = "повторная шапка изменила число колонок значений" - - -def _head(*dates: str) -> dict: - cells = [(0, "Наименование показателя", 100), (1, "Код", 100)] - cells += [(2 + i, text, 100) for i, text in enumerate(dates)] - return _row(0, *cells) - - -class TestRepeatedHeader: - """R-3 (design #21): `columns` — по первой шапке, `values` каждой строки — по позиции этой колонки.""" - - def test_wider_first_header_pads_missing_values(self) -> None: - table = _table( - "Бухгалтерский баланс", - _head("На 31 декабря 2024 г.", "На 31 декабря 2023 г.", "На 31 декабря 2022 г."), - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100), (4, "3", 100)), - _row( - 2, - (0, "Наименование показателя", 100), - (1, "Код", 100), - (2, "На 31 декабря 2024 г.", 100), - (3, "На 31 декабря 2023 г.", 100), - ), - _row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100)), - ) - built = _build(table) - assert [line.line_code for line in built.document.lines] == ["1150", "1110"] - assert all(len(line.values) == 3 for line in built.document.lines) - last = built.document.lines[1].values[2] - assert (last.amount, last.string_value, last.confidence, last.is_recognized) == (None, None, 0, False) - assert built.document.lines[1].values[1].amount == Decimal("5") - assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False - - def test_narrower_first_header_drops_extra_indexes(self) -> None: - table = _table( - "Бухгалтерский баланс", - _head("На 31 декабря 2024 г.", "На 31 декабря 2023 г."), - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), - _row( - 2, - (0, "Наименование показателя", 100), - (1, "Код", 100), - (2, "На 31 декабря 2024 г.", 100), - (3, "На 31 декабря 2023 г.", 100), - (4, "На 31 декабря 2022 г.", 100), - ), - _row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100), (4, "6", 100)), - ) - built = _build(table) - assert [c.column_index for c in built.document.columns] == [0, 1] - for line in built.document.lines: - assert [v.column_index for v in line.values] == [0, 1] # нет «сироты» с column_index = 2 - assert built.document.lines[1].values[1].amount == Decimal("5") - assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False - - def test_same_layout_after_repeated_header_is_silent(self) -> None: - table = _table( - "Бухгалтерский баланс", - *CLEAN_BALANCE["rows"][:2], - _row( - 5, - (0, "Наименование показателя", 100), - (1, "Код", 100), - (2, "На 31 декабря 2024 г.", 100), - (3, "На 31 декабря 2023 г.", 100), - ), - _row(6, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1", 100), (3, "2", 100)), - ) - built = _build(table) - assert [line.line_code for line in built.document.lines] == ["1150", "1100"] - assert built.warnings == [] and built.fully_recognized is True - - -def test_latin_o_in_code_drops_row() -> None: - """R-4 (design #22): лукалайки применяются только к датам — «11O0» не целое число, строка отбрасывается.""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "Итого", 100), (1, "11O0", 100), (2, "1", 100), (3, "2", 100)), - ) - assert [line.line_code for line in _build(table).document.lines] == ["1150"] - - -class TestEditionWarnings: - def test_empty_edition_gives_single_warning(self) -> None: - """R-5 (design #23): пустая fsbu4 — одно «не заполнена», без «код N из редакции 66n» по строкам.""" - header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False) - table = _table( - "Бухгалтерский баланс", - _head("На 30 июня 2025 г"), - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)), - _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "2", 100)), - ) - built = _build(table, header) - assert built.edition_id == "fsbu4" - assert built.warnings == ["редакция fsbu4 не заполнена"] - assert not any("из редакции" in w for w in built.warnings) - - def test_code_from_other_nonempty_edition_is_reported(self) -> None: - rules = OcrRules( - editions=[ - Edition(id="old", valid_until=date(2024, 12, 31), lines={"1150": "Основные средства"}), - Edition(id="new", valid_from=date(2025, 1, 1), lines={"1110": "Нематериальные активы"}), - ], - lookalikes=RULES.lookalikes, - labels=RULES.labels, - ) - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "3", 100), (3, "4", 100)), - ) - built = build_document(table, 0, "job.json", HEADER_2024, rules) - assert built is not None and built.edition_id == "old" - assert built.warnings == ["код 1110 из редакции new"] - assert built.document.lines[1].line_name == "НМА" and built.document.lines[1].line_code_confidence == 99 - - -def test_table_without_value_columns() -> None: - """R-6 (design #25): нет колонок значений — предупреждение и needs_check; values пустые.""" - table = _table( - "Бухгалтерский баланс", - _row(0, (0, "Наименование показателя", 100), (1, "Код", 100)), - _row(1, (0, "ОС", 100), (1, "1150", 100)), - ) - built = _build(table) - assert built.document.columns == [] and built.document.lines[0].values == [] - assert "нет колонок значений" in built.warnings and built.fully_recognized is False - assert REPEATED_HEADER not in built.warnings - - -class TestHeaderConditions: - """D-6: каждое отклонение шапки файла лишает документ статуса «всё распознано».""" - - @pytest.mark.parametrize( - "changes", - [ - {"inn_confidence": 90.0}, - {"inn": None, "inn_confidence": None}, - {"organization_confidence": 90.0}, - {"organization_name": None, "organization_confidence": None}, - {"report_date": None, "report_date_recognized": False}, - {"report_date_recognized": False}, - ], - ids=["inn-low", "inn-none", "org-low", "org-none", "no-date", "date-not-recognized"], - ) - def test_header_deviation_is_not_fully_recognized(self, changes: dict) -> None: - assert _build(CLEAN_BALANCE, replace(HEADER_2024, **changes)).fully_recognized is False - - def test_baseline_is_fully_recognized(self) -> None: - assert _build(CLEAN_BALANCE, HEADER_2024).fully_recognized is True - - -def test_column_without_date_warning() -> None: - table = _table( - "Бухгалтерский баланс", - _head("На отчетную дату"), - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)), - ) - built = _build(table) - assert "колонка 0 без даты: На отчетную дату" in built.warnings - assert built.document.columns[0].period_date is None and built.fully_recognized is False - - -def test_edition_not_determined_warning() -> None: - header = FileHeader(None, None, None, None, None, None, None, False) - table = _table( - "Приложение", - _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 1 января", 100)), - _row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100)), - _row(2, (0, "b", 100), (1, "5200", 100), (2, "2", 100)), - ) - built = _build(table, header) - assert built.edition_id is None and "редакция не определена" in built.warnings - assert built.document.lines[0].line_name == "a" and built.fully_recognized is False - - -class TestKnownCodeConfidence: - """R-2, ветка «код в выбранной редакции»: confidence ячейки, если текст чистый, иначе не выше 99.""" - - def test_clean_code_keeps_cell_confidence(self) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 80), (1, "1150", 80), (2, "1", 100), (3, "2", 100)), - ) - line = _build(table).document.lines[0] - assert line.line_code_confidence == 80 and line.line_name_confidence == 80 - assert line.line_name == "Основные средства" - - def test_dirty_code_is_capped_at_99(self) -> None: - """«+1150» проходит `int()`, `clean_value` убирает «+» — текст не равен сырому, уверенность ≤ 99.""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "+1150", 100), (2, "1", 100), (3, "2", 100)), - ) - built = _build(table) - line = built.document.lines[0] - assert line.line_code == "1150" and line.line_name == "Основные средства" - assert line.line_code_confidence == 99 and line.line_name_confidence == 99 - assert built.fully_recognized is False - - -class TestMoneyPrecision: - """Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу.""" - - @pytest.mark.parametrize("text", ["12,3456", "0,125", "12.3456", "(0,125)"]) - def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), - ) - built = _build(table) - value = built.document.lines[0].values[0] - assert value.amount is None and value.string_value == text and value.is_recognized is False - assert value.confidence == 100 - assert built.fully_recognized is False - - def test_neighbours_stay_intact_and_document_is_built(self) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "0,125", 100), (3, "56,78", 100)), - _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)), - ) - built = _build(table) - first, second = built.document.lines - assert (first.values[0].amount, first.values[0].string_value) == (None, "0,125") - assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True) - assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True) - assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True) - built.document.to_wire() # контрактная модель сериализуется без ValidationError - - @pytest.mark.parametrize( - ("text", "amount"), - [("4,200", Decimal("4200")), ("(4,200)", Decimal("-4200")), ("1,234,567", Decimal("1234567"))], - ) - def test_thousands_comma_gives_amount(self, text: str, amount: Decimal) -> None: - """AURORA-1405, scan-gateway#35: «4,200» в PDF — это 4200, а не 4.200 (отсекаемое №30).""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), - ) - value = _build(table).document.lines[0].values[0] - assert value.amount == amount and value.is_recognized is True and value.string_value is None - - @pytest.mark.parametrize( - ("text", "amount"), - [ - ("4.200", Decimal("4200")), - ("837.994", Decimal("837994")), - ("(4.200)", Decimal("-4200")), - ("1.234.567", Decimal("1234567")), - ("1234.567", Decimal("1234567")), - ("1 234.567", Decimal("1234567")), - ], - ) - def test_thousands_dot_gives_amount(self, text: str, amount: Decimal) -> None: - """AURORA-1405, scan-gateway#37: ИС вернула точку как разделитель тысяч — «4.200» это 4200, не текст (№30).""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), - ) - value = _build(table).document.lines[0].values[0] - assert value.amount == amount and value.is_recognized is True and value.string_value is None - - def test_thousands_dot_low_confidence_is_not_recognized(self) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "4.200", 90), (3, "2", 100)), - ) - value = _build(table).document.lines[0].values[0] - assert value.amount == Decimal("4200") and value.is_recognized is False - - @pytest.mark.parametrize("text", ["4.20", "4.2", "1.2345"]) - def test_dot_fraction_is_not_thousands(self, text: str) -> None: - """Не три цифры после точки — дробь, как раньше: до двух знаков принимается, больше — текст (№30).""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), - ) - value = _build(table).document.lines[0].values[0] - if text == "1.2345": - assert value.amount is None and value.string_value == text - else: - assert value.amount == Decimal(text) - - @pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"]) - def test_up_to_two_decimals_is_kept(self, text: str) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), - ) - value = _build(table).document.lines[0].values[0] - assert value.amount is not None and value.string_value is None and value.is_recognized is True - - -class TestConfidenceRange: - """Решение №30: confidence ячеек приводится к [0, 100], NaN — в 0 (контракт требует 0..100).""" - - @pytest.mark.parametrize( - ("raw", "expected"), [(float("nan"), 0), (150, 100), (-5, 0), (float("inf"), 0), (float("-inf"), 0)] - ) - def test_out_of_range_cell_confidence(self, raw: float, expected: float) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "5", raw), (3, "2", 100)), - ) - built = _build(table) - assert built.document.lines[0].values[0].confidence == expected - built.document.to_wire() - - def test_header_with_nan_confidence_builds_document(self) -> None: - header = replace(HEADER_2024, inn_confidence=0.0, organization_confidence=0.0) - built = _build(CLEAN_BALANCE, header) - assert built.document.header.inn_confidence == 0 and built.fully_recognized is False - - -class TestSkippedRowsWarnings: - """Решение №29: отброшенные строки — по одному табличному предупреждению каждого вида, needs_check.""" - - def test_both_warnings_and_not_fully_recognized(self) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "НМА", 100), (1, "1110 :", 100), (2, "3", 100), (3, "4", 100)), - _row(3, (0, "Мусор", 100), (1, "11x0", 100), (2, "3", 100), (3, "4", 100)), - _row(4, (0, "БАЛАНС", 100), (1, "", 100), (2, "5", 100), (3, "6", 100)), - _row(5, (0, "Итого", 100), (1, "", 100), (2, "7", 100), (3, "", 100)), - _row(6, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), # без сумм — без счёта - ) - built = _build(table) - assert [line.line_code for line in built.document.lines] == ["1150"] - assert built.warnings == [ - "строк с нечисловым кодом пропущено: 2", - "строк без кода с суммами пропущено: 2", - ] - assert built.fully_recognized is False - - def test_group_titles_without_amounts_are_silent(self) -> None: - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "I. Внеоборотные активы", 100), (1, "", 100)), - _row(2, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), - _row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), - ) - built = _build(table) - assert built.warnings == [] and built.fully_recognized is True - - def test_aep_like_table_with_synthetic_header(self) -> None: - """Как в aep_3: пустой код у «Выручка» (мусор «211(1 |» — в колонке значения) и мусорный код «2.10».""" - table = _table( - "Отчет о финансовых результатах", - _row(0, (0, "Выручка", 100), (1, "", 100), (2, "211(1 |", 71), (3, "8096738", 100)), - _row(1, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(5)", 100), (3, "(6)", 100)), - _row(2, (0, "Доходы от участия", 100), (1, "2.10", 100), (2, "5060", 100), (3, "*", 100)), - _row(3, (0, "Валовая прибыль", 100), (1, "2100", 100), (2, "1", 100), (3, "2", 100)), - ) - built = _build(table) - assert [line.line_code for line in built.document.lines] == ["2120", "2100"] - assert "строк с нечисловым кодом пропущено: 1" in built.warnings - assert "строк без кода с суммами пропущено: 1" in built.warnings - assert "шапка синтезирована: колонки без дат" in built.warnings - - def test_merged_codes_are_split_with_warning(self) -> None: - """OQ-156: склеенная строка ИС «1500\\n1700» даёт две строки документа и предупреждение таблицы.""" - table = _table( - "Бухгалтерский баланс", - CLEAN_BALANCE["rows"][0], - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), - _row(2, (0, "Итого V\nБАЛАНС", 100), (1, "1500\n1700", 100), (2, "3\n4", 100), (3, "5\n6", 100)), - ) - built = _build(table) - assert [line.line_code for line in built.document.lines] == ["1150", "1500", "1700"] - assert "строк с несколькими кодами разлеплено: 1" in built.warnings - assert built.fully_recognized is False - - -@pytest.mark.fixtures -class TestSkippedRowsRealFixtures: - @pytest.mark.parametrize( - ("name", "index", "expected"), - [ - ("aep_3.json", 0, ["строк с нечисловым кодом пропущено: 8", "строк без кода с суммами пропущено: 2"]), - ("aep_3.json", 1, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 2"]), - ("aep_6.json", 0, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 19"]), - ("aep_6.json", 1, ["строк с нечисловым кодом пропущено: 1", "строк без кода с суммами пропущено: 2"]), - ], - ) - def test_warnings_on_real_tables(self, name: str, index: int, expected: list[str]) -> None: - real = Path(API_DIR).parent.parent / "fixtures" / "ocr" / name - if not real.is_file(): - pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") - data_ocr = json.loads(real.read_text(encoding="utf-8"))["data_ocr"] - built = build_document(data_ocr["table_structure"][index], index, "job.json", HEADER_2024, RULES) - assert built is not None - skipped = [w for w in built.warnings if w.startswith(("строк с нечисловым", "строк без кода"))] - assert skipped == expected - assert built.fully_recognized is False - - -@pytest.mark.parametrize( - "label", - [ - "на 2024" + " " * 4000 + "b", - "на 2024" + "\n" * 4000 + "b", - "на 2024" + "О" * 5000, - ], # «на » — признак колонки значений - ids=["spaces", "nl", "look"], -) -def test_long_column_label_is_fast_and_has_no_date(label: str) -> None: - """Решение №31: подпись колонки длиннее даты не разбирается и не проходит `fix_lookalikes` (нет квадратичного - роста через `_columns`).""" - table = _table( - "Бухгалтерский баланс", - _head(label), - _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)), - ) - start = time.perf_counter() - built = _build(table) - assert time.perf_counter() - start < 1.0 - assert built.document.columns[0].period_date is None and built.fully_recognized is False - assert any(w.startswith("колонка 0 без даты") for w in built.warnings) diff --git a/api/tests/unit/normalizer/test_golden.py b/api/tests/unit/normalizer/test_golden.py index 0c8357d..033b4a9 100644 --- a/api/tests/unit/normalizer/test_golden.py +++ b/api/tests/unit/normalizer/test_golden.py @@ -8,12 +8,12 @@ from pathlib import Path import pytest +from arm_statements import default_rules from src.normalizer import OcrNormalizer -from src.normalizer.rules import load_rules -from tests.golden_update import GOLDEN_DIR, REAL_FIXTURES, golden_cases, outcome_to_json, real_set +from tests.golden_update import GOLDEN_DIR, GOLDEN_VERSION, REAL_FIXTURES, golden_cases, outcome_to_json, real_set API_DIR = Path(__file__).resolve().parents[3] -RULES = load_rules(API_DIR / "rules") +RULES = default_rules() pytestmark = pytest.mark.fixtures @@ -25,8 +25,9 @@ def _cases() -> list[str]: def test_matches_golden(name: str) -> None: if not REAL_FIXTURES.is_dir(): pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + pytest.skip("golden v2 пересобирается в задаче 6 (эталоны в fixtures пока прежнего формата)") expected_path = GOLDEN_DIR / f"{name}.json" assert expected_path.is_file(), f"нет эталона {expected_path}: выполните make golden-update и проверьте diff" expected = json.loads(expected_path.read_text(encoding="utf-8")) - actual = outcome_to_json(OcrNormalizer(RULES).normalize(real_set(name))) + actual = outcome_to_json(OcrNormalizer(RULES, version=GOLDEN_VERSION).normalize(real_set(name))) assert actual == expected, f"эталон {name} разошёлся: make golden-update и git diff в fixtures" diff --git a/api/tests/unit/normalizer/test_ocr_normalizer.py b/api/tests/unit/normalizer/test_ocr_normalizer.py index 19e0a15..eeedf37 100644 --- a/api/tests/unit/normalizer/test_ocr_normalizer.py +++ b/api/tests/unit/normalizer/test_ocr_normalizer.py @@ -1,220 +1,109 @@ -"""`OcrNormalizer` (spec «Статус результата нормализации», «Результат по контракту», D-6).""" +"""`OcrNormalizer`: набор json ИС → `NormalizeOutcome` (статус, ошибка набора, предупреждения файла, метрики, документы +контракта 2.0). Реальные ответы ИС — `test_golden.py` / `test_real_answers.py` (задача 6).""" -import copy -import json -from pathlib import Path from typing import Any import pytest import src.normalizer.ocr as ocr_module -from src.contract import ErrorCode, ResultStatus -from src.gateway.hotfolder import AnswerFile -from src.gateway.merge import merge_answers -from src.normalizer import NO_TABLES_MESSAGE, OcrNormalizer -from src.normalizer.rules import load_rules +import src.normalizer.ocr.answer_set as answer_set_module +from arm_statements import default_rules +from src.contract import ErrorCode, FileWarning, ResultStatus, Severity, StatementDocument +from src.normalizer import OcrNormalizer +from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE +from tests.unit.normalizer.ocr_builders import BAD, BALANCE, HEAD, INCOME, OK, merged, ocr_table, response from tests.worker_helpers import SUCCESS_ANSWER -API_DIR = Path(__file__).resolve().parents[3] -REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" -JOB = "3f1c2b7a-0000-4000-8000-000000000001" -RULES = load_rules(API_DIR / "rules") +VERSION = "abc1234" -def _merged(*files: tuple[str, Any]) -> dict[str, Any]: - answers = [AnswerFile(name, json.dumps(body, ensure_ascii=False).encode()) for name, body in files] - return dict(merge_answers(JOB, answers)) +def normalizer() -> OcrNormalizer: + return OcrNormalizer(default_rules(), version=VERSION) -def _real_set(*names: str) -> dict[str, Any]: - if not REAL_FIXTURES.is_dir(): - pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") - files = [] - for name in names: - suffix = name.removesuffix(".json").partition("_")[2].partition("_")[2] - target = f"{JOB}_{suffix}.json" if suffix else f"{JOB}.json" - files.append(AnswerFile(target, (REAL_FIXTURES / name).read_bytes())) - return dict(merge_answers(JOB, files)) +def two_forms() -> dict[str, Any]: + return response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)) -CLEAN: dict[str, Any] = { - "file_uid": JOB, - "error": {"code": "", "message": ""}, - "data_ocr": { - "label": {"form_name": "Бухгалтерская отчетность", "confidence": 100}, - "organization": {"form_name": "ООО Ромашка", "confidence": 100}, - "inn": {"form_name": "7700000000", "confidence": 100}, - "units": {"form_name": "тыс. руб.", "confidence": 100}, - "date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100}, - "table_structure": [ - { - "label": {"form_name": "Бухгалтерский баланс", "confidence": 100}, - "rows": [ - { - "row_index": 0, - "cells": [ - {"column_index": 0, "value": "Наименование показателя", "confidence": 100}, - {"column_index": 1, "value": "Код", "confidence": 100}, - {"column_index": 2, "value": "На 31 декабря 2024 г.", "confidence": 100}, - ], - }, - { - "row_index": 1, - "cells": [ - {"column_index": 0, "value": "Основные средства", "confidence": 100}, - {"column_index": 1, "value": "1150", "confidence": 100}, - {"column_index": 2, "value": "1 234", "confidence": 100}, - ], - }, - ], - } - ], - }, -} +def test_done_with_documents_metrics_and_version() -> None: + outcome = normalizer().normalize(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))))) + assert outcome.status is ResultStatus.DONE and outcome.error is None and outcome.warnings == [] + assert [d.document_type for d in outcome.documents] == ["BALANCE"] + assert all(isinstance(d, StatementDocument) for d in outcome.documents) + assert outcome.metrics is not None + assert outcome.metrics.rows == sum(len(d.lines) for d in outcome.documents) > 0 + assert outcome.statements_version == VERSION -class TestStatus: - def test_done_when_everything_recognized(self) -> None: - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN))) - assert outcome.status is ResultStatus.DONE and outcome.warnings == [] - assert len(outcome.documents) == 1 and outcome.documents[0].document_type == "BALANCE" - assert outcome.metrics is not None and outcome.metrics.rows == 1 - - def test_needs_check_with_table_warning(self) -> None: - noisy = copy.deepcopy(CLEAN) - noisy["data_ocr"]["table_structure"][0]["rows"][1]["cells"][2]["confidence"] = 90 - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy))) - assert outcome.status is ResultStatus.NEEDS_CHECK - - def test_no_tables_is_recognition_failed(self) -> None: - empty = copy.deepcopy(CLEAN) - empty["data_ocr"]["table_structure"] = [] - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", empty))) - assert outcome.status is ResultStatus.ERROR - assert outcome.error is not None and outcome.error.code is ErrorCode.RECOGNITION_FAILED - assert outcome.error.message == NO_TABLES_MESSAGE - - def test_skipped_table_is_warned(self) -> None: - bad = copy.deepcopy(CLEAN) - empty_table = {"label": {"form_name": "x"}, "rows": [{"row_index": 0, "cells": []}]} - bad["data_ocr"]["table_structure"].append(empty_table) - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", bad))) - assert outcome.status is ResultStatus.NEEDS_CHECK - assert f"{JOB}.json: table:1: нет шапки и столбца кода" in outcome.warnings - - def test_exception_in_one_table_becomes_warning(self, monkeypatch: pytest.MonkeyPatch) -> None: - real = ocr_module.build_document - - def boom(table: Any, index: int, *args: Any, **kwargs: Any) -> Any: - if index == 1: - raise RuntimeError("сломалось") - return real(table, index, *args, **kwargs) - - monkeypatch.setattr(ocr_module, "build_document", boom) - two = copy.deepcopy(CLEAN) - two["data_ocr"]["table_structure"].append(copy.deepcopy(two["data_ocr"]["table_structure"][0])) - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", two))) - assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 - assert f"{JOB}.json: table:1: BACKEND_PARSING_ERROR: RuntimeError: сломалось" in outcome.warnings - - def test_file_level_warnings(self) -> None: - noisy = copy.deepcopy(CLEAN) - noisy["data_ocr"]["inn"] = {"form_name": "+", "confidence": 0} - noisy["data_ocr"]["date"] = {"form_name": "Общество", "confidence": 100} - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy))) - assert f"{JOB}.json: ИНН не распознан" in outcome.warnings - assert not any("ИНН распознан неуверенно" in w for w in outcome.warnings) - assert f"{JOB}.json: дата отчёта не разобрана" in outcome.warnings - - def test_inn_found_but_unsure_is_worded_differently(self) -> None: - """F-B: «не распознан» — только когда ИНН не извлечён; извлечённый с confidence < 100 — «неуверенно».""" - unsure = copy.deepcopy(CLEAN) - unsure["data_ocr"]["inn"] = {"form_name": "7700000000", "confidence": 75} - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", unsure))) - assert outcome.status is ResultStatus.NEEDS_CHECK - assert f"{JOB}.json: ИНН распознан неуверенно" in outcome.warnings - assert not any("ИНН не распознан" in w for w in outcome.warnings) - - def test_inn_with_noise_is_unsure(self) -> None: - """ИНН извлечён из мусора (confidence ограничивается 99) — тоже «неуверенно», а не «не распознан».""" - noisy = copy.deepcopy(CLEAN) - noisy["data_ocr"]["inn"] = {"form_name": "77 0000 0000 |", "confidence": 100} - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy))) - assert outcome.warnings == [f"{JOB}.json: ИНН распознан неуверенно"] - - def test_inn_missing_field_is_not_recognized(self) -> None: - missing = copy.deepcopy(CLEAN) - del missing["data_ocr"]["inn"] - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", missing))) - assert outcome.warnings == [f"{JOB}.json: ИНН не распознан"] +def test_one_document_needs_check_makes_set_needs_check() -> None: + """Синтетический ОФР без строки 2300 не сходится по §7: документ needs_check, набор — тоже.""" + outcome = normalizer().normalize(merged(("j.json", two_forms()))) + assert [d.document_type for d in outcome.documents] == ["BALANCE", "INCOME"] + assert [d.status for d in outcome.documents] == ["done", "needs_check"] + assert outcome.status is ResultStatus.NEEDS_CHECK and outcome.warnings == [] -class TestErrorFilesStillHandled: - def test_all_files_failed_keeps_first_code(self) -> None: - failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}} - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", failed))) - assert outcome.status is ResultStatus.ERROR and outcome.error is not None - assert outcome.error.code is ErrorCode.INVALID_DOCUMENT - assert outcome.warnings == [f"{JOB}.json: INVALID_DOCUMENT: нет"] - - def test_mixed_files(self) -> None: - failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}} - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN), (f"{JOB}_ofr.json", failed))) - # №28: потерянный ошибочный файл не остаётся под `done`, даже если успешный файл распознан полностью - assert outcome.status is ResultStatus.NEEDS_CHECK - assert outcome.error is None and len(outcome.documents) == 1 - assert outcome.warnings == [f"{JOB}_ofr.json: INVALID_DOCUMENT: нет"] - assert OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN))).status is ResultStatus.DONE - - def test_mixed_files_error_first_in_order(self) -> None: - """Порядок файлов не важен: ошибочный первым, успешный вторым — тоже needs_check с документом.""" - failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}} - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}_a.json", failed), (f"{JOB}_b.json", CLEAN))) - assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 - - def test_not_merged_structure(self) -> None: - outcome = OcrNormalizer(RULES).normalize({"job_id": JOB}) - assert outcome.status is ResultStatus.ERROR and outcome.error is not None - assert outcome.error.code is ErrorCode.INVALID_FORMAT - - def test_success_answer_helper_from_worker_tests(self) -> None: - outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", SUCCESS_ANSWER))) - assert outcome.status is ResultStatus.NEEDS_CHECK # confidence < 100 у шапки +def test_lost_json_keeps_documents_and_gives_needs_check() -> None: + outcome = normalizer().normalize( + merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), ("j_ofr.json", response(error=BAD))) + ) + assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 + [warning] = outcome.warnings + assert isinstance(warning, FileWarning) + assert (warning.code, warning.severity, warning.locator) == ("JSON_FAILED", Severity.ERROR, "file:j_ofr.json") + assert warning.message == "INVALID_DOCUMENT: Не удалось определить тип документа." -@pytest.mark.fixtures -class TestRealFixtures: - def test_aep6(self) -> None: - outcome = OcrNormalizer(RULES).normalize(_real_set("aep_6.json")) - assert outcome.status is ResultStatus.NEEDS_CHECK - types = [d.document_type for d in outcome.documents] - assert types == ["BALANCE", "INCOME"] - balance, income = outcome.documents - assert [(c.column_index, c.label, str(c.period_date)) for c in balance.columns] == [ - (0, "На 30 июня 2025 г", "2025-06-30"), - (1, "На 31 декабря 2024 г.", "2024-12-31"), - (2, "На 31 декабря 2023 г.", "2023-12-31"), - ] - codes = [line.line_code for line in balance.lines] - assert "1105" in codes and "1110" in codes - assert all(line.line_code for line in balance.lines) - assert [str(c.period_date) for c in income.columns] == ["2025-06-30", "2024-06-30"] - revenue = next(line for line in income.lines if line.line_code == "2110") - assert str(revenue.values[0].amount) == "13070" - assert balance.header.units == "тыс. руб." and balance.header.inn is None and balance.header.report_date is None +def test_all_json_failed_takes_first_code() -> None: + outcome = normalizer().normalize(merged(("j.json", response(error=BAD)), ("j_ofr.json", "не объект"))) + assert outcome.status is ResultStatus.ERROR and outcome.error is not None + assert (outcome.error.code, outcome.error.message) == (ErrorCode.INVALID_DOCUMENT, BAD["message"]) + assert outcome.documents == [] and outcome.metrics is None and outcome.statements_version is None + assert [w.locator for w in outcome.warnings] == ["file:j.json", "file:j_ofr.json"] - def test_aep3(self) -> None: - outcome = OcrNormalizer(RULES).normalize(_real_set("aep_3.json")) - assert outcome.status is ResultStatus.NEEDS_CHECK - balance = next(d for d in outcome.documents if d.document_type == "BALANCE") - assert all(c.period_date is None for c in balance.columns) - assert balance.header.inn == "3121200351" - assert balance.header.inn_confidence is not None and balance.header.inn_confidence < 100 - assert str(balance.header.report_date) == "2024-12-31" - assert any(line.line_code == "1120" for line in balance.lines) - assert any("шапка синтезирована" in w for w in outcome.warnings) - def test_aep3_with_failed_ofr(self) -> None: - outcome = OcrNormalizer(RULES).normalize(_real_set("aep_3.json", "aep_3_ofr.json")) - assert outcome.status is ResultStatus.NEEDS_CHECK - assert any(w.startswith(f"{JOB}_ofr.json: INVALID_DOCUMENT:") for w in outcome.warnings) +def test_no_tables_is_recognition_failed() -> None: + outcome = normalizer().normalize(merged(("j.json", {"error": OK, "data_ocr": {**HEAD, "table_structure": []}}))) + assert outcome.status is ResultStatus.ERROR and outcome.error is not None + assert (outcome.error.code, outcome.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE) + assert [(w.code, w.message) for w in outcome.warnings] == [("JSON_FAILED", "в ответе ИС нет таблиц")] + + +def test_table_exception_becomes_table_parse_error(monkeypatch: pytest.MonkeyPatch) -> None: + real = answer_set_module.normalize_candidate + + def flaky(candidate: Any, rules: Any = None) -> Any: + if candidate.locator.endswith("table:1"): + raise RuntimeError("сломалось") + return real(candidate, rules) + + monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky) + outcome = normalizer().normalize(merged(("j.json", two_forms()))) + assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 + [warning] = outcome.warnings + assert (warning.code, warning.locator) == ("TABLE_PARSE_ERROR", "file:j.json;table:1") + assert warning.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось" + + +@pytest.mark.parametrize("raw", [None, {"job_id": "j"}, {"job_id": "j", "files": []}]) +def test_not_merged_structure_is_invalid_format(raw: object) -> None: + outcome = normalizer().normalize(raw) + assert outcome.status is ResultStatus.ERROR and outcome.error is not None + assert outcome.error.code is ErrorCode.INVALID_FORMAT and outcome.warnings == [] + + +def test_success_answer_helper_from_worker_tests() -> None: + outcome = normalizer().normalize(merged(("j.json", SUCCESS_ANSWER))) + assert outcome.status is not ResultStatus.ERROR and len(outcome.documents) == 1 + + +def test_default_rules_and_version_from_package() -> None: + created = OcrNormalizer() + assert created.statements_version == ocr_module.statements_version() + + +def test_requires_statements_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ocr_module, "statements_version", lambda path=None: None) + with pytest.raises(ValueError, match="без VERSION"): + OcrNormalizer(default_rules()) + assert OcrNormalizer(default_rules(), version="x").statements_version == "x" diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py index 7f436eb..a9ae04d 100644 --- a/api/tests/unit/test_app.py +++ b/api/tests/unit/test_app.py @@ -278,11 +278,10 @@ async def test_unhandled_error_has_json_500(local_env: Path) -> None: assert response.json() == {"detail": "внутренняя ошибка сервера"} -def test_create_app_fails_on_broken_rules(tmp_path: Path) -> None: - (tmp_path / "statement_lines.yaml").write_text("editions: []\n", encoding="utf-8") - settings = get_settings().model_copy(update={"SCAN_RULES_DIR": str(tmp_path), "SCAN_HOTFOLDER_PATH": str(tmp_path)}) - with pytest.raises(ValueError, match="statement_lines.yaml"): - create_app(settings) +def test_create_app_uses_package_rules() -> None: + app = create_app(get_settings().model_copy(update={"SCAN_HOTFOLDER_PATH": "."})) + expected = (Path(__file__).resolve().parents[2] / "arm_statements" / "VERSION").read_text(encoding="utf-8").strip() + assert app.state.normalizer.statements_version == expected def test_create_app_uses_ocr_normalizer() -> None: diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index 884861c..29a6dfb 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -1,4 +1,4 @@ -"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-contract, slice-0-skeleton). +"""Контракт обмена с сервисами распознавания v2.0, запрос v1.0 (spec recognition-contract, slice-0-skeleton). Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53); каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же @@ -28,6 +28,7 @@ from src.contract import ( SUPPORTED_MAJOR, ContractModel, ErrorCode, + FileWarning, MessageStatus, Metrics, RecognitionRequest, @@ -176,12 +177,11 @@ def test_example_passes_model_and_round_trips(name: str) -> None: def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None: result = RecognitionResult.model_validate(_example("result-statement-minimal.json")) values = result.documents[0].lines[1].values # type: ignore[union-attr] - assert values[0].amount == Decimal("5678.00") - assert values[1].amount == Decimal("-12.30") - assert isinstance(values[1].amount, Decimal) + assert values[0].amount == Decimal("428311") + assert isinstance(values[0].amount, Decimal) wire = result.to_wire() - assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30" - assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло + assert wire["documents"][0]["lines"][1]["values"][0]["amount"] == "428311" # без «.00» — как пришло + assert wire["documents"][0]["lines"][0]["values"][0]["amount"] == "100" def test_osv_amount_keeps_kopecks() -> None: @@ -269,8 +269,10 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ ("message/status-unknown", "result-message-done.json", _set("status", "queued"), False), ("message/no-service", "result-message-processing.json", _drop("service"), False), ("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False), - ("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), False), - ("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True), + ("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), True), + ("message/schema_version-2.7", "result-message-processing.json", _set("schema_version", "2.7"), True), + ("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), False), + ("message/schema_version-3.0", "result-message-processing.json", _set("schema_version", "3.0"), False), ("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False), ("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False), ("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True), @@ -297,7 +299,8 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ True, ), ("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True), - ("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False), + ("message/metrics-warnings-not-structures", "result-message-done.json", _set("metrics.warnings", [1]), False), + ("message/metrics-warnings-strings", "result-message-done.json", _set("metrics.warnings", ["a"]), False), ("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False), ("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True), ("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False), @@ -351,8 +354,10 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ ("result/error-null", "result-error.json", _set("error", None), True), ("result/error-empty-object", "result-error.json", _set("error", {}), True), ("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False), - ("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), False), - ("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True), + ("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), True), + ("result/schema_version-2.3", "result-osv-minimal.json", _set("schema_version", "2.3"), True), + ("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), False), + ("result/schema_version-3.0", "result-osv-minimal.json", _set("schema_version", "3.0"), False), ("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False), ("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False), ("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False), @@ -550,6 +555,21 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ False, ), ("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True), + # --- v2.0: документы ББ/ОФР --- + ("result/statement-type-OTHER", "result-statement-minimal.json", _set("documents.0.document_type", "OTHER"), False), + ("result/statement-no-form_variant", "result-statement-minimal.json", _drop("documents.0.form_variant"), False), + ( + "result/statement-unreadable-with-code", + "result-statement-minimal.json", + _set("documents.0.lines.2.line_code", "1100"), + False, + ), + ( + "result/statement-no-statements_version", + "result-statement-minimal.json", + _drop("service.statements_version"), + False, + ), ] @@ -604,7 +624,7 @@ def test_message_status_enum_values() -> None: def test_contract_version_constant() -> None: - assert CONTRACT_VERSION == "1.0" + assert CONTRACT_VERSION == "2.0" def test_python_float_amount_is_rejected_with_clear_error() -> None: @@ -746,7 +766,7 @@ def test_build_ack_message() -> None: message = build_ack_message(JOB_ID, FILE_ID, SERVICE) wire = message.to_wire() assert wire == { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": str(JOB_ID), "file_id": str(FILE_ID), "status": "processing", @@ -769,13 +789,18 @@ def test_build_error_message_with_raw_s3_key_and_metrics() -> None: ErrorCode.TIMEOUT, "ИС Сканирование не ответила за 300 с", raw_s3_key=f"raw/{JOB_ID}.json", - metrics=Metrics(duration_ms=300000, warnings=["нет ответа"]), + metrics=Metrics( + duration_ms=300000, warnings=[FileWarning(code="JSON_FAILED", severity="error", message="нет ответа")] + ), ) wire = message.to_wire() assert wire["status"] == "error" assert wire["error"] == {"code": "TIMEOUT", "message": "ИС Сканирование не ответила за 300 с"} assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json" - assert wire["metrics"] == {"duration_ms": 300000, "warnings": ["нет ответа"]} + assert wire["metrics"] == { + "duration_ms": 300000, + "warnings": [{"code": "JSON_FAILED", "severity": "error", "message": "нет ответа"}], + } assert "result_s3" not in wire VALIDATORS["message"].validate(wire) assert RecognitionResultMessage.model_validate(wire) == message @@ -805,7 +830,7 @@ def test_build_done_message() -> None: wire = message.to_wire() assert wire["status"] == "done" assert wire["result_s3"] == {"bucket": "arm-ka", "key": f"results/{FILE_ID}/{JOB_ID}/result.json"} - assert wire["result_schema_version"] == "1.0" + assert wire["result_schema_version"] == "2.0" assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json" VALIDATORS["message"].validate(wire) assert RecognitionResultMessage.model_validate(wire) == message diff --git a/api/tests/unit/test_emulator.py b/api/tests/unit/test_emulator.py index b0609c8..f1e778f 100644 --- a/api/tests/unit/test_emulator.py +++ b/api/tests/unit/test_emulator.py @@ -608,7 +608,8 @@ class TestAnswerSet: assert outcome.error.code == "NOT_IMPLEMENTED" assert outcome.warnings is not None assert len(outcome.warnings) == 1 - assert outcome.warnings[0].startswith(f"{job_id}_ofr.json: INVALID_DOCUMENT: ") + assert outcome.warnings[0].locator == f"file:{job_id}_ofr.json" + assert outcome.warnings[0].message.startswith("INVALID_DOCUMENT: ") class TestSettings: diff --git a/api/tests/unit/test_normalizer.py b/api/tests/unit/test_normalizer.py index 8c58e00..ed242d8 100644 --- a/api/tests/unit/test_normalizer.py +++ b/api/tests/unit/test_normalizer.py @@ -16,7 +16,7 @@ from typing import Any import pytest from pydantic import ValidationError -from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument +from src.contract import ErrorCode, FileWarning, MessageError, Metrics, ResultStatus, Severity, StatementDocument from src.gateway.hotfolder import AnswerFile from src.gateway.merge import merge_answers from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, Normalizer, NullNormalizer @@ -79,9 +79,17 @@ def _assert_error(outcome: NormalizeOutcome, code: ErrorCode, message: str | Non assert outcome.metrics is None +def _lines(outcome: NormalizeOutcome) -> list[str]: + """Предупреждения `JSON_FAILED` в прежней записи `<имя>: <код ИС>: <сообщение>` (для краткости сверок).""" + for warning in outcome.warnings: + assert warning.code == "JSON_FAILED" and warning.severity is Severity.ERROR + assert warning.locator is not None and warning.locator.startswith("file:") + return [f"{(w.locator or '').removeprefix('file:')}: {w.message}" for w in outcome.warnings] + + def _assert_not_implemented(outcome: NormalizeOutcome, warnings: list[str]) -> None: _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) - assert outcome.warnings == warnings + assert _lines(outcome) == warnings @pytest.mark.fixtures @@ -103,7 +111,7 @@ class TestRealFixtures: [entry] = merged["files"] outcome = NullNormalizer().normalize(merged) _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, REAL_MESSAGE) - assert outcome.warnings == [f"{entry['name']}: INVALID_DOCUMENT: {REAL_MESSAGE}"] + assert _lines(outcome) == [f"{entry['name']}: INVALID_DOCUMENT: {REAL_MESSAGE}"] class TestFileSuccess: @@ -133,7 +141,7 @@ class TestFileError: def test_known_code_and_message_verbatim(self) -> None: outcome = NullNormalizer().normalize(_one(_failure("FILE_READ_ERROR", "Файл повреждён: стр. 3 «пусто»"))) _assert_error(outcome, ErrorCode.FILE_READ_ERROR, "Файл повреждён: стр. 3 «пусто»") - assert outcome.warnings == [f"{JOB}.json: FILE_READ_ERROR: Файл повреждён: стр. 3 «пусто»"] + assert _lines(outcome) == [f"{JOB}.json: FILE_READ_ERROR: Файл повреждён: стр. 3 «пусто»"] @pytest.mark.parametrize("code", [c.value for c in ErrorCode]) def test_every_contract_code_passes(self, code: str) -> None: @@ -145,14 +153,14 @@ class TestFileError: assert outcome.error is not None assert "SOME_NEW_CODE" in outcome.error.message assert "Превышена квота" in outcome.error.message - assert outcome.warnings == [f"{JOB}.json: SOME_NEW_CODE: Превышена квота"] + assert _lines(outcome) == [f"{JOB}.json: SOME_NEW_CODE: Превышена квота"] def test_lowercase_code_is_unknown(self) -> None: outcome = NullNormalizer().normalize(_one(_failure("invalid_document", "m"))) _assert_error(outcome, ErrorCode.UNKNOWN_ERROR) assert outcome.error is not None assert "invalid_document" in outcome.error.message - assert outcome.warnings == [f"{JOB}.json: invalid_document: m"] + assert _lines(outcome) == [f"{JOB}.json: invalid_document: m"] @pytest.mark.parametrize("code", [5, ["INVALID_DOCUMENT"], {"a": 1}, True]) def test_code_not_a_string_is_unknown_error(self, code: object) -> None: @@ -161,7 +169,7 @@ class TestFileError: assert outcome.error is not None assert repr(code) in outcome.error.message assert "m-text" in outcome.error.message - assert outcome.warnings == [f"{JOB}.json: {code!r}: m-text"] + assert _lines(outcome) == [f"{JOB}.json: {code!r}: m-text"] @pytest.mark.parametrize("message", [None, ""]) def test_missing_or_empty_message_gets_default(self, message: object) -> None: @@ -170,7 +178,7 @@ class TestFileError: _assert_error(outcome, ErrorCode.INVALID_DOCUMENT) assert outcome.error is not None assert "INVALID_DOCUMENT" in outcome.error.message - assert outcome.warnings == [f"{JOB}.json: INVALID_DOCUMENT: {outcome.error.message}"] + assert _lines(outcome) == [f"{JOB}.json: INVALID_DOCUMENT: {outcome.error.message}"] def test_message_absent_gets_default(self) -> None: outcome = NullNormalizer().normalize(_one({"error": {"code": "UNREADABLE"}})) @@ -201,7 +209,7 @@ class TestFileError: _assert_error(outcome, ErrorCode.INVALID_FORMAT) assert outcome.error is not None assert "Expecting value" in outcome.error.message - [warning] = outcome.warnings + [warning] = _lines(outcome) assert warning.startswith(f"{JOB}.json: INVALID_FORMAT: ") assert "JSONDecodeError: Expecting value: line 1 column 1 (char 0)" in warning @@ -210,7 +218,7 @@ class TestFileError: """Сценарий «Корень ответа не объект».""" outcome = NullNormalizer().normalize(_one(response)) _assert_error(outcome, ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE) - assert outcome.warnings == [f"{JOB}.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"] + assert _lines(outcome) == [f"{JOB}.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"] class TestSummary: @@ -223,8 +231,9 @@ class TestSummary: outcome = NullNormalizer().normalize(merged) _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, "нет типа") assert len(outcome.warnings) == 2 - assert outcome.warnings[0] == f"{JOB}.json: INVALID_DOCUMENT: нет типа" - assert outcome.warnings[1].startswith(f"{JOB}_ofr.json: INVALID_FORMAT: ") + lines = _lines(outcome) + assert lines[0] == f"{JOB}.json: INVALID_DOCUMENT: нет типа" + assert lines[1].startswith(f"{JOB}_ofr.json: INVALID_FORMAT: ") def test_first_erroneous_parse_error_gives_invalid_format(self) -> None: merged = _merged( @@ -279,7 +288,7 @@ class TestMergedStructure: merged = {"job_id": JOB, "files": [entry, _file("b.json", _success())]} outcome = NullNormalizer().normalize(merged) _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED) - [warning] = outcome.warnings + [warning] = _lines(outcome) assert warning.startswith("files[0]: INVALID_FORMAT: ") def test_real_merge_output_is_accepted(self) -> None: @@ -293,13 +302,15 @@ class TestNormalizeOutcome: assert outcome.status is ResultStatus.ERROR assert outcome.documents == [] assert outcome.metrics is None - assert outcome.warnings == [] + assert _lines(outcome) == [] def test_warnings_are_kept(self) -> None: outcome = NormalizeOutcome( - status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"), warnings=["a: B: c"] + status="error", + error=MessageError(code=ErrorCode.UNREADABLE, message="m"), + warnings=[FileWarning(code="JSON_FAILED", severity=Severity.ERROR, message="B: c", locator="file:a")], ) - assert outcome.warnings == ["a: B: c"] + assert _lines(outcome) == ["a: B: c"] def test_error_requires_error(self) -> None: with pytest.raises(ValidationError): @@ -311,6 +322,7 @@ class TestNormalizeOutcome: NormalizeOutcome( status=status, documents=[_statement()], + statements_version="abc1234", error=MessageError(code=ErrorCode.UNREADABLE, message="m"), ) @@ -319,10 +331,17 @@ class TestNormalizeOutcome: with pytest.raises(ValidationError): NormalizeOutcome(status=status) + @pytest.mark.parametrize("status", ["done", "needs_check"]) + def test_documents_require_statements_version(self, status: str) -> None: + with pytest.raises(ValidationError): + NormalizeOutcome(status=status, documents=[_statement()]) + @pytest.mark.parametrize("status", ["done", "needs_check"]) def test_success_outcome(self, status: str) -> None: metrics = Metrics(pages=2, rows=10) - outcome = NormalizeOutcome(status=status, documents=[_statement()], metrics=metrics) + outcome = NormalizeOutcome( + status=status, documents=[_statement()], statements_version="abc1234", metrics=metrics + ) assert outcome.status is ResultStatus(status) assert outcome.error is None assert outcome.metrics == metrics @@ -333,6 +352,7 @@ class TestNormalizeOutcome: NormalizeOutcome( status="error", documents=[_statement()], + statements_version="abc1234", error=MessageError(code=ErrorCode.UNREADABLE, message="m"), ) @@ -373,7 +393,7 @@ class TestProtocol: def test_custom_implementation_is_accepted(self) -> None: class Custom: def normalize(self, raw: object) -> NormalizeOutcome: - return NormalizeOutcome(status="done", documents=[_statement()]) + return NormalizeOutcome(status="done", documents=[_statement()], statements_version="abc1234") normalizer: Normalizer = Custom() assert normalizer.normalize({}).status is ResultStatus.DONE @@ -383,7 +403,10 @@ def _statement() -> StatementDocument: return StatementDocument.model_validate( { "document_type": "BALANCE", - "locator": "pages:1-2", + "form_variant": "full", + "locator": "file:a.json;table:0", + "status": "done", + "warnings": [], "header": {}, "columns": [], "lines": [], diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index 0be8fc7..d1272fd 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -18,9 +18,11 @@ from jsonschema import Draft202012Validator, FormatChecker from src.contract import ( ErrorCode, + FileWarning, MessageError, Metrics, ResultStatus, + Severity, StatementDocument, ) from src.core.clients import JSON_CONTENT_TYPE @@ -28,7 +30,6 @@ from src.core.worker import MAX_ERROR_MESSAGE_CHARS, MAX_WARNING_CHARS, MAX_WARN from src.gateway.hotfolder import AnswerFile from src.gateway.merge import dump_merged, merge_answers from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, OcrNormalizer -from src.normalizer.rules import load_rules from tests.worker_helpers import ( API_DIR, BUCKET, @@ -57,6 +58,11 @@ INVALID_DOCUMENT_ANSWER = { } +def _failed(name: str, message: str) -> dict[str, str]: + """Ожидаемая структура `JSON_FAILED` в `metrics.warnings` (ошибочный json набора, P5-10).""" + return {"code": "JSON_FAILED", "severity": "error", "message": message, "locator": f"file:{name}"} + + def _json(obj: object) -> bytes: return json.dumps(obj, ensure_ascii=False).encode("utf-8") @@ -228,7 +234,7 @@ async def test_real_fixture_error_is_passed_through(make_env: EnvFactory, tmp_pa assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) assert body["error"]["message"] == expected["message"] job_id = request["job_id"] - assert body["metrics"] == {"warnings": [f"{job_id}.json: INVALID_DOCUMENT: {expected['message']}"]} + assert body["metrics"] == {"warnings": [_failed(f"{job_id}.json", f"INVALID_DOCUMENT: {expected['message']}")]} assert _raw(env, job_id)["files"][0]["response"]["error"] == expected @@ -244,7 +250,7 @@ async def test_unknown_name_gets_emulator_invalid_document(make_env: EnvFactory) @pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK]) async def test_successful_outcome_writes_result_file(make_env: EnvFactory, status: ResultStatus) -> None: document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) - normalizer = StubNormalizer(NormalizeOutcome(status=status, documents=[document])) + normalizer = StubNormalizer(NormalizeOutcome(status=status, documents=[document], statements_version="abc1234")) env = await make_env(normalizer=normalizer) request = env.request() async with emulator(env): @@ -253,7 +259,8 @@ async def test_successful_outcome_writes_result_file(make_env: EnvFactory, statu key = f"results/{file_id}/{job_id}/result.json" assert body["status"] == status.value assert body["result_s3"] == {"bucket": BUCKET, "key": key} - assert body["result_schema_version"] == "1.0" + assert body["result_schema_version"] == "2.0" + assert "statements_version" not in body.get("service", {}) # в конверте — только {name, version} (P5-11) assert body["raw_s3_key"] == f"raw/{job_id}.json" assert "error" not in body result = json.loads(env.storage.objects[(BUCKET, key)].decode("utf-8")) @@ -264,6 +271,7 @@ async def test_successful_outcome_writes_result_file(make_env: EnvFactory, statu assert result["service"]["name"] == "scan-gateway" assert result["raw_s3_key"] == f"raw/{job_id}.json" assert result["documents"] == STATEMENT_EXAMPLE["documents"] + assert result["schema_version"] == "2.0" and result["service"]["statements_version"] == "abc1234" assert env.storage.content_types[(BUCKET, key)] == JSON_CONTENT_TYPE # нормализатор получает склеенный ответ — тот же объект, что записан в raw/ assert normalizer.calls == [merged_raw(job_id, (f"{job_id}.json", {**SUCCESS_ANSWER, "file_uid": job_id}, None))] @@ -273,7 +281,8 @@ async def test_successful_outcome_writes_result_file(make_env: EnvFactory, statu async def test_result_file_write_failure_is_unknown_error_with_raw_key(make_env: EnvFactory) -> None: document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) - env = await make_env(normalizer=StubNormalizer(NormalizeOutcome(status=ResultStatus.DONE, documents=[document]))) + outcome = NormalizeOutcome(status=ResultStatus.DONE, documents=[document], statements_version="abc1234") + env = await make_env(normalizer=StubNormalizer(outcome)) env.storage.fail_put_prefixes = ("results/",) request = env.request() async with emulator(env): @@ -323,8 +332,9 @@ async def test_answer_not_json_is_parse_error_and_invalid_format(make_env: EnvFa assert entry["response"] is None assert entry["parse_error"] [warning] = body["metrics"]["warnings"] - assert warning.startswith(f"{job_id}.json: INVALID_FORMAT: ") - assert entry["parse_error"] in warning + assert (warning["code"], warning["severity"], warning["locator"]) == ("JSON_FAILED", "error", f"file:{job_id}.json") + assert warning["message"].startswith("INVALID_FORMAT: ") + assert entry["parse_error"] in warning["message"] async def test_invalid_json_after_settle_is_not_reread(make_env: EnvFactory) -> None: @@ -374,7 +384,7 @@ async def test_one_erroneous_file_does_not_fail_the_job(make_env: EnvFactory) -> body = await env.run(request) assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE - assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} + assert body["metrics"] == {"warnings": [_failed(f"{job_id}_ofr.json", f"INVALID_DOCUMENT: {REAL_MESSAGE}")]} async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) -> None: @@ -391,8 +401,8 @@ async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) -> body = await env.run(request) assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) bad, listed = body["metrics"]["warnings"] - assert bad.startswith(f"{job_id}_bad.json: INVALID_FORMAT: ") - assert listed == f"{job_id}_list.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}" + assert bad["locator"] == f"file:{job_id}_bad.json" and bad["message"].startswith("INVALID_FORMAT: ") + assert listed == _failed(f"{job_id}_list.json", f"INVALID_FORMAT: {NOT_OBJECT_MESSAGE}") files = _raw(env, job_id)["files"] assert [f["name"] for f in files] == [f"{job_id}.json", f"{job_id}_bad.json", f"{job_id}_list.json"] assert files[1]["response"] is None and files[1]["parse_error"] @@ -411,8 +421,8 @@ async def test_all_files_erroneous_take_code_of_first(make_env: EnvFactory) -> N assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) assert body["error"]["message"] == REAL_MESSAGE first, second = body["metrics"]["warnings"] - assert first == f"{job_id}.json: INVALID_DOCUMENT: {REAL_MESSAGE}" - assert second.startswith(f"{job_id}_ofr.json: INVALID_FORMAT: ") + assert first == _failed(f"{job_id}.json", f"INVALID_DOCUMENT: {REAL_MESSAGE}") + assert second["locator"] == f"file:{job_id}_ofr.json" and second["message"].startswith("INVALID_FORMAT: ") async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env: EnvFactory) -> None: @@ -424,7 +434,7 @@ async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env: body = await env.run(request) assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True) assert "SOME_NEW_CODE" in body["error"]["message"] - assert body["metrics"] == {"warnings": [f"{job_id}.json: SOME_NEW_CODE: новое"]} + assert body["metrics"] == {"warnings": [_failed(f"{job_id}.json", "SOME_NEW_CODE: новое")]} async def _unserialisable_depth() -> bytes: @@ -484,7 +494,7 @@ async def test_real_aep_3_with_ofr_is_not_implemented_with_one_warning(make_env: env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", main), AnswerFile(f"{job_id}_ofr.json", ofr)]] body = await env.run(request) assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) - assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} + assert body["metrics"] == {"warnings": [_failed(f"{job_id}_ofr.json", f"INVALID_DOCUMENT: {REAL_MESSAGE}")]} assert _raw(env, job_id) == merged_raw( job_id, (f"{job_id}.json", json.loads(main), None), (f"{job_id}_ofr.json", json.loads(ofr), None) ) @@ -493,14 +503,20 @@ async def test_real_aep_3_with_ofr_is_not_implemented_with_one_warning(make_env: @pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK]) async def test_outcome_warnings_join_metrics_in_envelope_and_result(make_env: EnvFactory, status: ResultStatus) -> None: document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) + own = FileWarning(code="OWN", severity=Severity.INFO, message="свои") + joined = FileWarning(code="JSON_FAILED", severity=Severity.ERROR, message="X: y", locator="file:a.json") outcome = NormalizeOutcome( - status=status, documents=[document], metrics=Metrics(pages=2, warnings=["свои"]), warnings=["a.json: X: y"] + status=status, + documents=[document], + metrics=Metrics(pages=2, warnings=[own]), + warnings=[joined], + statements_version="abc1234", ) env = await make_env(normalizer=StubNormalizer(outcome)) request = env.request() async with emulator(env): body = await env.run(request) - assert body["metrics"] == {"pages": 2, "warnings": ["свои", "a.json: X: y"]} + assert body["metrics"] == {"pages": 2, "warnings": [own.to_wire(), joined.to_wire()]} result = json.loads(env.storage.objects[(BUCKET, f"results/{request['file_id']}/{request['job_id']}/result.json")]) RESULT_VALIDATOR.validate(result) assert result["metrics"] == body["metrics"] @@ -517,22 +533,55 @@ async def test_error_outcome_without_warnings_keeps_its_metrics(make_env: EnvFac async def test_huge_warnings_are_bounded(make_env: EnvFactory) -> None: - """R-30: число строк warnings и длина каждой ограничены — конверт остаётся меньше лимита NATS.""" - lines = [f"f{i}.json: INVALID_DOCUMENT: " + "я" * 50_000 for i in range(MAX_WARNINGS * 3)] + """P5-10: 300 предупреждений (100 error и 200 info вперемешку) — в конверте ровно 100, все error среди них, порядок + исходный, `message` не длиннее MAX_WARNING_CHARS; файл результата несёт тот же список.""" + warnings = [ + FileWarning( + code="JSON_FAILED" if i % 3 == 0 else "ROW_SPLIT", + severity=Severity.ERROR if i % 3 == 0 else Severity.INFO, + message=f"w{i}: " + "я" * 50_000, + ) + for i in range(MAX_WARNINGS * 3) + ] + document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) outcome = NormalizeOutcome( - status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message="m"), warnings=lines + status=ResultStatus.NEEDS_CHECK, documents=[document], warnings=warnings, statements_version="abc1234" + ) + env = await make_env(normalizer=StubNormalizer(outcome)) + request = env.request() + async with emulator(env): + body = await env.run(request) + sent = body["metrics"]["warnings"] + assert len(sent) == MAX_WARNINGS + assert all(w["severity"] == "error" for w in sent) # все 100 error — ровно предел, info не остаётся + assert all(len(w["message"]) <= MAX_WARNING_CHARS for w in sent) + indexes = [int(w["message"].split(":")[0][1:]) for w in sent] + assert indexes == sorted(indexes) + result = json.loads(env.storage.objects[(BUCKET, f"results/{request['file_id']}/{request['job_id']}/result.json")]) + assert result["metrics"]["warnings"] == sent + assert all(len(payload) < 900_000 for _, payload, _ in env.client.published) + + +async def test_all_errors_survive_when_warnings_overflow(make_env: EnvFactory) -> None: + """P5-10: error — 40 штук среди 150 — остаются все, остальное место занимают первые info.""" + warnings = [ + FileWarning( + code="JSON_FAILED" if i % 15 < 4 else "ROW_SPLIT", + severity=Severity.ERROR if i % 15 < 4 else Severity.INFO, + message=f"w{i}", + ) + for i in range(MAX_WARNINGS + 50 + 5) + ] + errors = [w.message for w in warnings if w.severity is Severity.ERROR] + outcome = NormalizeOutcome( + status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message="m"), warnings=warnings ) env = await make_env(normalizer=StubNormalizer(outcome)) async with emulator(env): body = await env.run(env.request()) - warnings = body["metrics"]["warnings"] - assert len(warnings) == MAX_WARNINGS - assert all(len(line) <= MAX_WARNING_CHARS for line in warnings) - assert warnings[0].startswith("f0.json: INVALID_DOCUMENT: я") - assert warnings[-2].startswith(f"f{MAX_WARNINGS - 2}.json") - assert str(MAX_WARNINGS * 3 - (MAX_WARNINGS - 1)) in warnings[-1] - assert f"raw/{body['job_id']}.json" in warnings[-1] - assert all(len(payload) < 900_000 for _, payload, _ in env.client.published) + sent = body["metrics"]["warnings"] + assert len(sent) == MAX_WARNINGS + assert [w["message"] for w in sent if w["severity"] == "error"] == errors async def test_answer_ready_only_after_settle(make_env: EnvFactory) -> None: @@ -842,8 +891,8 @@ async def test_lone_surrogate_in_is_error_message_does_not_break_publishing(make assert body["error"]["message"].startswith("bad ") assert body["error"]["message"].endswith(" tail") [warning] = body["metrics"]["warnings"] - assert warning.startswith(f"{job_id}.json: INVALID_DOCUMENT: bad ") - assert warning.endswith(" tail") + assert warning["locator"] == f"file:{job_id}.json" + assert warning["message"].startswith("INVALID_DOCUMENT: bad ") and warning["message"].endswith(" tail") # сырой ответ записан валидным JSON, суррогат сохранён экранированным assert _raw(env, job_id)["files"][0]["response"]["error"]["message"] == "bad \ud800 tail" @@ -964,7 +1013,7 @@ class TestSlice3a: assert env.inbox() == [f"{body['job_id']}.pdf"] async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None: - env = await make_env(normalizer=OcrNormalizer(load_rules(API_DIR / "rules"))) + env = await make_env(normalizer=OcrNormalizer(version="abc1234")) body = env.request() async with emulator(env): result = await env.run(body) @@ -972,5 +1021,9 @@ class TestSlice3a: key = result["result_s3"]["key"] stored = json.loads(env.storage.objects[(BUCKET, key)]) assert stored["documents"][0]["document_type"] == "BALANCE" - assert stored["documents"][0]["lines"][0]["line_code"] == "1600" + # в SUCCESS_ANSWER одна строка (без шапки таблицы): столбец кодов не найден, строк у документа нет + assert stored["documents"][0]["lines"] == [] + assert "CODE_COLUMN_NOT_FOUND" in [w["code"] for w in stored["documents"][0]["warnings"]] + assert stored["schema_version"] == "2.0" and stored["service"]["statements_version"] == "abc1234" + RESULT_VALIDATOR.validate(stored) assert result["raw_s3_key"] == f"raw/{body['job_id']}.json" diff --git a/api/tests/worker_helpers.py b/api/tests/worker_helpers.py index dbdbe5e..013f69d 100644 --- a/api/tests/worker_helpers.py +++ b/api/tests/worker_helpers.py @@ -253,7 +253,7 @@ class Env: body = json.loads(payload.decode("utf-8")) MESSAGE_VALIDATOR.validate(body) assert body["service"]["name"] == "scan-gateway" - assert body["schema_version"] == "1.0" + assert body["schema_version"] == "2.0" out.append((headers[NATS_MSG_ID_HEADER], body)) return out