feat: контракт результата 2.0 — документы и предупреждения файла общего кода arm_statements (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:08:44 +04:00
parent 3b9e1f6e19
commit ae2922eb30
33 changed files with 1066 additions and 1586 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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": []
}
]
}

View File

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

View File

@ -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": []
}

View File

@ -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": [] }
}
]
}

View File

@ -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:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме 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" }
}
}
},

View File

@ -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:<row_index ИС>[#k]; Excel — <лист>!<A1> по правилу 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"] } }
}
]
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
# пустая редакция уже даёт одно «редакция <id> не заполнена» — по строке не предупреждаем
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": [],

View File

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

View File

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