Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
33 KiB
Spec Delta: image-recognition-gateway
Purpose
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream,
передаёт файл через горячие папки по раскладке проверенного в контуре PDFWorker (RSHB/armka_file_worker), ждёт ответ
не дольше таймаута, сохраняет сырой ответ в S3, нормализует data_ocr в statementDocument контракта v1.2 по §11 и
§11.1 05-domain-rules.md и отвечает ядру. Без БД и фронта.
MODIFIED Requirements
Requirement: Подтверждение запроса до обработки
Сервис SHALL быть подписан на recognition.image.request в queue group scan-gateway. Получив запрос, проходящий схему,
сервис SHALL до чтения файла из S3 опубликовать в recognition.result конверт status = processing
(service.name = "scan-gateway", Nats-Msg-Id = ack:<job_id>:<attempt>). Повторный запрос с job_id, который уже
обрабатывается или обработан этим процессом, SHALL получить повторный ack, и второй файл во входящий каталог MUST NOT записываться.
Scenario: Ack раньше файла
- WHEN получен запрос для
aep_5.pdf - THEN конверт
processingопубликован раньше, чем файл появился во входящем каталоге
Scenario: Повторный запрос
- WHEN тот же запрос доставлен повторно во время ожидания ответа ИС
- THEN публикуется ещё один ack, во входящем каталоге остаётся один файл
<job_id>.pdf
Requirement: Передача файла в ИС Сканирование через горячие папки
После подтверждения сервис SHALL прочитать исходник из S3 (s3.bucket/s3.key) и записать его во входящий каталог как
<job_id>.<ext>, где <ext> — расширение original_name запроса в нижнем регистре; запрос без расширения SHALL
завершаться конвертом RECOGNITION_FAILED без записи в горячую папку. Временный файл записи SHALL лежать в служебном
каталоге <корень>/<SCAN_TMP_DIR>/ (по умолчанию Tmp, на той же шаре/ФС, что Input; создаётся при записи) и
переименовываться в Input атомарно без замены: если <Input>/<job_id>.<ext> уже существует, файл SHALL считаться
доставленным, временный файл MUST NOT его перезаписывать. Во входящем каталоге MUST NOT появляться недописанные и
временные файлы. Затем сервис SHALL опрашивать исходящий каталог <SCAN_OUTPUT_DIR>/<job_id>/ каждые
SCAN_POLL_SECONDS до min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS). ИС кладёт в этот каталог все распознанные
документы задания — по одному *.json на документ, набор появляется целиком и больше не меняется; пустой или
отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один *.json, а
набор имён и размеров *.json не менялся SCAN_SETTLE_SECONDS (по умолчанию 2); готовый набор SHALL включать все
*.json каталога по возрастанию имени, остальные файлы игнорируются.
Scenario: Файл положен во входящий каталог
- WHEN получен запрос для
aep_5.PDFсjob_id = 3f1c… - THEN во входящем каталоге появляется
3f1c….pdf, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, аTmp/существует
Scenario: Назначение уже существует
- WHEN запрос для
aep_5.pdfсjob_id = 3f1c…обрабатывается, аInput/3f1c….pdfуже лежит (задание повторено ядром после потери ack в другом процессе) - THEN существующий файл не перезаписан, задание продолжается опросом
Output/3f1c…/, конвертRECOGNITION_FAILEDне публикуется
Scenario: Запрос без расширения
- WHEN получен запрос с
original_name = "scan"без точки - THEN опубликован конверт
error,error.code = RECOGNITION_FAILED, сообщение о расширении; во входящем каталоге файла нет
Scenario: Ответ из одного файла
- WHEN в
Output/3f1c…/появляется один*.jsonи набор не меняетсяSCAN_SETTLE_SECONDS - THEN опрос прекращается не позднее чем через
SCAN_SETTLE_SECONDS+SCAN_POLL_SECONDSпосле первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру
Scenario: Ответ из нескольких файлов
- WHEN в
Output/3f1c…/лежат3f1c….jsonи3f1c…_ofr.json, посторонний файлreadme.txt, и набор не меняетсяSCAN_SETTLE_SECONDS - THEN дальше по конвейеру передаются оба
*.jsonпо возрастанию имени,readme.txtигнорируется
Scenario: Набор ещё пишется
- WHEN размер файла или число файлов в
Output/3f1c…/менялось менееSCAN_SETTLE_SECONDSназад - THEN ответ не считается готовым, опрос продолжается до дедлайна
Requirement: Результат по контракту
Сервис SHALL публиковать в recognition.result конверт по recognition-result-message.schema.json с
service.name = "scan-gateway", Nats-Msg-Id = result:<job_id>:<attempt>; при status ∈ {done, needs_check} — файл
результата (recognition-result.schema.json, schema_version = "1.0" — CONTRACT_VERSION сервиса, решение №14; схема допускает любой MINOR) в results/{file_id}/{job_id}/result.json и result_s3;
при неудаче публикации SHALL повторить её до трёх раз. Нормализатор получает склеенный ответ и SHALL считать файл
успешным, если error.code == "" (поле error реального успешного ответа — {"code": "", "message": ""}, а не null;
отсутствующий или null error ошибкой не считается), и ошибочным, если error.code непуст, parse_error не null
либо корень ответа файла не является объектом JSON (ошибка этого файла — INVALID_FORMAT); успех MUST NOT определяться
проверкой «error не null». Если ошибочны все файлы — status = error, error.code и error.message берутся у
первого ошибочного файла по имени (для parse_error и не-объекта — INVALID_FORMAT); если код ИС этого файла не
входит в перечень кодов контракта, error.code = UNKNOWN_ERROR, а исходный код SHALL быть указан в error.message.
Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС»,
а статус определяется по требованию «Статус результата нормализации». В обоих случаях metrics.warnings SHALL
содержать по одной строке <имя файла>: <код>: <сообщение> на каждый ошибочный файл, где <код> — исходный код ИС как
получен (для parse_error и не-объекта — INVALID_FORMAT); если предупреждений нет, поле warnings SHALL быть опущено.
Scenario: Успешный ответ ИС в Срезе 0
- WHEN ответ из одного файла с
error = {"code": "", "message": ""}и непустымdata_ocr(по образцуaep_6.json) - THEN начиная со Среза 3a конверт больше не
NOT_IMPLEMENTED: поведение — по сценарию «Успешный ответ ИС» ниже
Scenario: Успешный ответ ИС
- WHEN ответ из одного файла с
error = {"code": "", "message": ""}и непустымdata_ocr(aep_6.json) - THEN конверт
status = needs_check,raw_s3_key = raw/<job_id>.json,result_s3указывает на файл результата с двумя документами (BALANCE,INCOME)
Scenario: Ошибка ИС
- WHEN единственный файл ответа
<job_id>.jsonсодержитerror.code = INVALID_DOCUMENT - THEN конверт
status = error,error.code = INVALID_DOCUMENT,error.messageиз ответа,raw_s3_keyзаполнен,metrics.warningsсодержит одну строку<job_id>.json: INVALID_DOCUMENT: <сообщение>
Scenario: Ошибка одного из документов
- WHEN ответ состоит из
<job_id>.json(успех,aep_3.json) и<job_id>_ofr.json(INVALID_DOCUMENT,aep_3_ofr.json) - THEN конверт
status = needs_check, документы собраны из<job_id>.json,metrics.warningsсодержит строку<job_id>_ofr.json: INVALID_DOCUMENT: <сообщение>и предупреждения нормализации
Scenario: Все документы ошибочны
- WHEN оба файла ответа ошибочны (первый по имени —
INVALID_DOCUMENT, второй — не JSON) - THEN конверт
status = error,error.code = INVALID_DOCUMENT(код первого ошибочного файла по имени),metrics.warningsсодержит две строки; файл не JSON в warnings несёт кодINVALID_FORMAT
Scenario: Все файлы не JSON
- WHEN единственный файл ответа не разбирается как JSON
- THEN конверт
status = error,error.code = INVALID_FORMAT,metrics.warningsсодержит одну строку<job_id>.json: INVALID_FORMAT: <сообщение разбора>
Scenario: Корень ответа не объект
- WHEN файл ответа — корректный JSON, но корень — массив
- THEN этот файл ошибочен с кодом
INVALID_FORMAT; остальные файлы оцениваются по общим правилам
Scenario: Код ошибки ИС вне перечня контракта
- WHEN единственный файл ответа содержит
error.code = SOME_NEW_CODE - THEN конверт
status = error,error.code = UNKNOWN_ERROR,error.messageсодержитSOME_NEW_CODE, строка вmetrics.warnings—<job_id>.json: SOME_NEW_CODE: <сообщение>
Requirement: Ошибки транспорта превращаются в результат
Сервис SHALL отвечать конвертом status = error с кодом по таблице: запрос не по схеме или MAJOR ≠ 1 →
SCHEMA_MISMATCH (ack не публикуется); объект S3 не читается → FILE_READ_ERROR; запись во входящий каталог не
удалась или у исходника нет расширения → RECOGNITION_FAILED; файл ответа ИС не разбирается как JSON → parse_error
этого файла, а если ошибочны все файлы — INVALID_FORMAT; ни одной таблицы не разобрано нормализатором →
RECOGNITION_FAILED; иное исключение → UNKNOWN_ERROR. Ошибки очистки горячей папки MUST NOT влиять на конверт.
Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться.
Scenario: Горячая папка недоступна
- WHEN каталог
SCAN_HOTFOLDER_PATHне существует в момент обработки - THEN конверт
RECOGNITION_FAILEDс сообщением о недоступности горячей папки;/healthcheck.hotfolder = down
ADDED Requirements
Requirement: Очистка горячей папки под флагом
При SCAN_CLEANUP=true после того, как готовый набор ответа прочитан и склеен в raw/{job_id}.json (независимо от
исхода нормализации), сервис SHALL удалить всё содержимое <SCAN_OUTPUT_DIR>/<job_id>/ (json-файлы ответа и любые прочие) и сам каталог <job_id>, входящий файл
<Input>/<job_id>.<ext>, его вариант <Input>/<job_id>.<ext>.done и временный <Tmp>/.<job_id>.<ext>.part, если они
существуют. При SCAN_CLEANUP=false (по умолчанию) сервис MUST NOT удалять ничего, включая осиротевшие временные файлы (.part, ставший
целью успешной публикации, снимается самой записью — это завершение «rename без замены», а не очистка; ошибка его снятия
запись не прерывает, решение №26). При
TIMEOUT, FILE_READ_ERROR, RECOGNITION_FAILED записи очистка MUST NOT выполняться. Ошибка удаления SHALL быть
записана в лог одной строкой на задание и MUST NOT менять конверт, metrics.warnings и /healthcheck.
Scenario: Очистка включена
- WHEN
SCAN_CLEANUP=true, ответ на3f1c….pdfпрочитан изOutput/3f1c…/ - THEN после публикации конверта в горячей папке нет
Output/3f1c…/,Input/3f1c….pdf,Input/3f1c….pdf.doneиTmp/.3f1c….pdf.part
Scenario: Очистка выключена
- WHEN
SCAN_CLEANUP=false, ответ прочитан, а вTmp/остался осиротевший.part - THEN все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27)
Scenario: Нет прав на удаление
- WHEN
SCAN_CLEANUP=true, удалениеOutput/3f1c…/3f1c….jsonзавершаетсяPermissionError - THEN конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления;
/healthcheck.hotfolder = ok
Scenario: Таймаут не чистит
- WHEN
SCAN_CLEANUP=true, ответ не появился до дедлайна - THEN опубликован
TIMEOUT, входящий файл и каталогOutput/<job_id>/(если есть) не тронуты
Requirement: Нормализация ответа ИС в statementDocument
Для каждого успешного файла ответа нормализатор SHALL разобрать data_ocr.table_structure[i] в отдельный
statementDocument по §11 и §11.1 05-domain-rules.md:
document_type— по нормализованномуlabel.form_nameтаблицы черезstatement_labels.yaml; еслиlabelне сопоставлен — по первой цифре кодов строк данных (1→BALANCE,2→INCOME); иначеOTHER. Еслиlabelи коды дают разный тип — тип поlabel, предупреждение;locator = "file:<имя json>;table:<i>".- Раскладка колонок — по строке с ячейкой «код…» (
build_layout); повторная шапка перезаписывает раскладку; без шапки —infer_layoutпо данным (столбец с наибольшим числом кодов из справочника, наименование — левее, значения — правее, где суммы не реже мусора или столбец пуст), шапка синтезируется, предупреждение. Ни шапки, ни столбца кода — таблица пропускается с предупреждением. columns— колонки значений раскладки по возрастаниюcolumn_indexисходной таблицы, перенумерованные с 0;label— текст ячейки шапки (\n/\t→ пробел; у синтетической шапки — пустая строка),period_date—parse_dateтекста (квартал/полугодие/N месяцев/диапазон → последний день последнего месяца),null, если не разобрался.lines— только строки данных с кодом:line_code— очищенный текст ячейки кода (clean_value); строка без кода и строка, чей код после обрезки пробелов не является целым числом (1150 :— перенос_classify_row: считается повторным заголовком), пропускаются. Повтор кода в документе: остаётся первая строка, предупреждениекод <код> повторяется: оставлена первая строка, документneeds_check(DBML:financial_statement_lineуникален по(meta_id, line_code); так же поступает Excel-путь, §12 п. 5).line_name— из справочника редакции по коду; если кода нет в справочниках — текст ячейки наименования.line_code_confidence—confidenceячейки кода, если очищенный текст равен сырому и код есть в справочнике, иначеmin(confidence, 99);line_name_confidence— то же для словарного имени, иначеconfidenceячейки наименования.values— по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок — недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25):amount—parse_amount(запятая → точка, скобки → минус, пробелы и прочие символы отбрасываются, несколько точек — разделители тысяч; прочерк/пусто →null; сумма с более чем двумя знаками после запятой (1,234) не помещается вMoneyконтракта —amount = null,string_value— текст ячейки,is_recognized = false, решение №30);string_value— текст ячейки как распознан (\n/\t→ пробел, края обрезаны) только еслиamount = nullи текст не прочерк (DBML: «как распознано, если не число»), иначеnull;confidence— ячейки (значения вне 0–100 и NaN приводятся к диапазону, №30);is_recognized = confidence == 100 и разбор не отбрасывал символов; ячейки под колонкой нет →amount = null,string_value = null,confidence = 0,is_recognized = false.header—form_nameизlabelтаблицы;organization_nameиorganization_name_confidenceизdata_ocr.organization(\n→ пробел);inn— только цифры изdata_ocr.inn.form_name(null, если цифр нет),inn_confidence = confidence, если сырой текст состоял только из цифр, иначеmin(confidence, 99);units—clean_value(units.form_name);report_date_text— текстdate.form_nameс обрезанными краями,report_date—parse_dateпослеfix_lookalikes(null, если текст не дата);okud,okpo,okopf,okfs,address— не передаются (в проводе отсутствуют, у потребителяnull; ИС их не отдаёт).checksMUST NOT присутствовать. Строка с непустым нечисловым кодом (1150 :,изо :) пропускается, на таблицу выдаётся одно предупреждениестрок с нечисловым кодом пропущено: <N>; строка с пустым кодом и хотя бы одной разобранной суммой в колонках значений пропускается с одним предупреждением на таблицустрок без кода с суммами пропущено: <N>(как в Excel-пути, OQ-141); строки с пустым кодом без сумм (заголовки групп, «в том числе») — без предупреждения (решение №29). Двойники букв и цифр (fix_lookalikes) SHALL применяться к тексту дат (дата отчёта и подписи колонок) только вплотную к цифрам;|SHALL считаться разделителем клеток, если первая строка значения состоит из цифр, пробелов и знаков пунктуации. Коды очищаютсяclean_value, суммы —parse_amount, ИНН — выделением цифр (как в проверенном на стенде старом ядре, §11.1 п. 1; решение №22design.md).
Scenario: Чистый скан с шапкой (aep_6.json)
- WHEN нормализуется
aep_6.json(две таблицы: «Бухгалтерский баланс» 122 строки со строкой-шапкой «Наименование показателя / Код / На 30 июня 2025 г / На 31 декабря 2024 г. / На 31 декабря 2023 г.» и «Отчет о финансовых результатах» 49 строк) - THEN два документа:
BALANCEсcolumns[{0, "На 30 июня 2025 г", 2025-06-30}, {1, "На 31 декабря 2024 г.", 2024-12-31}, {2, "На 31 декабря 2023 г.", 2023-12-31}], строка1105и1110присутствуют, строка «в том числе: …» без кода отсутствует;INCOMEс колонками «За январь - июнь 2025 г.» →2025-06-30и «За январь - июнь 2024 г.» →2024-06-30, строка2110сvalues[0].amount = 13070;header.units = "тыс. руб.",header.inn = null(текст+),header.report_date = null(текст — название организации); статусneeds_check
Scenario: Зашумлённый скан без шапки (aep_3.json)
- WHEN нормализуется
aep_3.json(таблица «Бухгалтерский баланс» без строки-шапки, коды вcolumn_index = 1с мусором видаизо :, ИНН3|1 21 2| о! О' 0 0; 3: 51, дата2|0|2|4|) - THEN столбец кода выведен по данным, документ
BALANCEс синтетической шапкой,columnsбезperiod_dateи предупреждение «шапка синтезирована»;header.inn = "3121200351",inn_confidence < 100;header.report_date = 2024-12-31; строка1120присутствует; статусneeds_check
Scenario: Суммы в скобках и с пробелами (aep_3.json, ОФР)
- WHEN ячейка значения содержит
(7744536}сconfidence 88.9, другая —(6032409)сconfidence 100, третья —13 070сconfidence 100 - THEN
amountсоответственно7744536(закрывающая скобка не распознана, минус не выставлен,is_recognized = false),-6032409(is_recognized = true),13070(is_recognized = true);string_value = nullу всех трёх (суммы разобраны)
Scenario: Нечисловая ячейка значения
- WHEN ячейка значения содержит
не ****сconfidence 5.6 - THEN
amount = null,string_value = "не ****",is_recognized = false
Scenario: Повтор кода строки
- WHEN в таблице баланса две строки с кодом
1150 - THEN в документе одна строка
1150(первая по порядку), предупреждение<имя json>: table:<i>: код 1150 повторяется: оставлена первая строка, статусneeds_check
Scenario: Тип по кодам при зашумлённом label
- WHEN
label.form_nameтаблицы —Бvxгалтерский баланс(не сопоставлен), коды строк1110…1700 - THEN
document_type = BALANCE, предупреждение «тип определён по кодам»
Scenario: Таблица без столбца кода
- WHEN в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника
- THEN таблица пропущена, предупреждение
<имя json>: table:<i>: нет шапки и столбца кода; остальные таблицы файла разобраны
Requirement: Справочники нормализации — данные с редакциями форм
Правила SHALL лежать в api/rules/ (SCAN_RULES_DIR) и загружаться один раз при старте с pydantic-валидацией; битый
файл SHALL прерывать старт сервиса. statement_lines.yaml SHALL содержать список editions с полями id,
valid_from/valid_until (даты отчёта) и lines (код → наименование); редакция 66n (до 2024-12-31) заполнена,
fsbu4 (с 2025-01-01) присутствует и может быть пустой. Редакция документа SHALL выбираться по header.report_date,
при его отсутствии — по самой поздней period_date колонок, при отсутствии обеих — по числу совпадений кодов с
предупреждением «редакция определена по кодам». Пустая выбранная редакция → предупреждение «редакция не
заполнена», имена строк — из OCR. Код, отсутствующий в выбранной непустой редакции, но присутствующий в другой → предупреждение
«код <код> из редакции » (для пустой редакции не выдаётся — достаточно «редакция не заполнена»; решение №23). ocr_lookalikes.yaml SHALL задавать таблицу двойников; statement_labels.yaml —
нормализованные подстроки label → BALANCE/INCOME. Коды справочника SHALL быть строками из 4–5 цифр без дублей.
Scenario: Редакция по дате отчёта
- WHEN
header.report_date = 2024-12-31, строка с кодом1150 - THEN
line_name = "Основные средства"из редакции66n, предупреждений о редакции нет
Scenario: Редакция 2025 не заполнена
- WHEN
report_date = null, самая поздняяperiod_date = 2025-06-30, редакцияfsbu4пуста - THEN имена строк взяты из ячеек OCR, предупреждение
редакция fsbu4 не заполнена, статусneeds_check
Scenario: Битый справочник
- WHEN в
statement_lines.yamlкод11A0 - THEN сервис не стартует, в логе ошибка валидации с именем файла и кодом
Requirement: Статус результата нормализации
Если из успешных файлов не собран ни один документ — status = error, error.code = RECOGNITION_FAILED,
error.message = "ни одной таблицы не разобрано", причины по таблицам — в metrics.warnings. Если документы есть —
status = done только когда у всех документов inn_confidence, organization_name_confidence равны 100,
report_date и все period_date заполнены, все line_code_confidence и line_name_confidence равны 100, все значения
is_recognized, тип совпал с label, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы;
иначе status = needs_check. Если среди файлов набора есть ошибочный (при хотя бы одном успешном), статус не может быть
done — needs_check (потерян документ набора; решение №28). Предупреждения уровня таблицы SHALL иметь вид <имя json>: table:<i>: <текст>, уровня
файла — <имя json>: <текст>; metrics.rows — сумма строк всех документов.
Scenario: Ни одной таблицы
- WHEN единственный успешный файл содержит
table_structure: [] - THEN конверт
status = error,error.code = RECOGNITION_FAILED,raw_s3_keyзаполнен
Scenario: Всё распознано
- WHEN синтетический ответ, где все
confidence = 100, тексты чистые, шапка есть, даты колонок и отчёта разобраны,report_date = 2024-12-31, коды из редакции66n - THEN
status = done, полеmetrics.warningsотсутствует
Scenario: Исключение в одной таблице
- WHEN разбор
table_structure[1]бросает исключение,table_structure[0]разбирается - THEN один документ, предупреждение
<имя json>: table:1: BACKEND_PARSING_ERROR: <класс>: <сообщение>, статусneeds_check