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