diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 95b41c3..4c2419f 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -60,8 +60,11 @@ NATS без JetStream: сообщение доставляется один ра **успех файла — `error.code == ""`** (реальный успешный ответ ИС несёт `error = {"code": "", "message": ""}`, поэтому проверка «`error` не `null`» неверна; отсутствующий или `null` `error` ошибкой не считается). Если ошибочны **все** файлы — `status = error`, `error.code` — код первого ошибочного файла по имени (коды Content AI совпадают с перечнем -контракта; для `parse_error` — `INVALID_FORMAT`; код вне перечня — `UNKNOWN_ERROR` с исходным кодом в сообщении), -`error.message` — его сообщение; `metrics.warnings` — по строке на каждый ошибочный файл вида `<имя>: <код>: <сообщение>`. +контракта; для `parse_error` — `INVALID_FORMAT`; код вне перечня — `UNKNOWN_ERROR` с исходным кодом в сообщении; +корень ответа файла не объект JSON — ошибка этого файла `INVALID_FORMAT`), +`error.message` — его сообщение; `metrics.warnings` — по строке на каждый ошибочный файл вида `<имя>: <код>: <сообщение>`, +где `<код>` — исходный код ИС как получен (для `parse_error` и корня ответа, не являющегося объектом, — `INVALID_FORMAT`); +если ошибочных файлов нет, поле `warnings` опускается. Если есть хотя бы один успешный файл — `status = error`, `error.code = NOT_IMPLEMENTED` с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3», ошибочные файлы — в `metrics.warnings` так же. Срез 3 подменяет реализацию (§11, OQ-13/14), транспорт не меняется. @@ -72,8 +75,9 @@ NATS без JetStream: сообщение доставляется один ра через `SCAN_EMULATOR_DELAY` запись набора в `Output//` под именами `.json` для `.json` и `_.json` для `_.json`, с подстановкой `file_uid = `. Набор появляется в `Output//` атомарно целиком: пишется во временный каталог эмулятора, затем каталог переименовывается. Нет пары → один ответ -Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": []}` -как раньше. Обработанные входящие файлы переименовываются в +Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": {...}}`, где `data_ocr` — +объект с пустыми полями, как у реальных ответов-ошибок `aep_5.json` и `aep_8_form4.json` (`label`, `organization`, `inn`, +`units`, `date` — `{"form_name": "", "confidence": 0}`, `table_structure: []`; R-28). Обработанные входящие файлы переименовываются в `_..done`, чтобы не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его. ### D-7. Ошибки транспорта — результат, не молчание @@ -95,7 +99,7 @@ Unit: `LocalHotFolder` на `tmp_path` (в `Input` нет `.part`; `poll` — н D-7 и таймаут (с укороченными настройками). `SmbHotFolder` — unit с подменённым `smbclient`. Интеграционный (маркер `integration`): настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (R-7; контейнера `scan-emulator` в compose пока нет) — `request-image.json` для `aep_3.pdf` → ack, затем `NOT_IMPLEMENTED` с -`metrics.warnings` про `aep_3_ofr.json` и `raw/{job_id}.json` с двумя файлами; `aep_6.pdf` → `NOT_IMPLEMENTED` без +`metrics.warnings` про `_ofr.json` и `raw/{job_id}.json` с двумя файлами; `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/` в MinIO. Синтетический успешный ответ (R-10) не нужен: реальные успешные образцы `aep_3.json`, `aep_6.json` добавлены человеком 04.10.2026 (OQ-76). @@ -162,10 +166,10 @@ warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/ | 29 | 2026-10-04 | Задача 4.2: `inbox_name` заменяет символы, недопустимые в Windows/SMB (`: < > " \| ? *`, управляющие), на `_` и отбрасывает завершающие точки/пробелы; `put` остаётся строгим (T8.1) | допустимое имя пользователя (`Отчёт: 2024.pdf`) не должно проваливать задание; для ИС имя ничего не значит | имя во входящем каталоге отличается от загруженного | | 30 | 2026-10-04 | Задача 4.2: `SMB_PATH` — относительный (`share/dir`, склеивается с хостом) или полный UNC; учётные данные передаются в каждый вызов `smbclient`, если задан логин; `poll` возвращает `None` на `STATUS_SHARING_VIOLATION`/`STATUS_DELETE_PENDING`; монопольное открытие ответа сохранено; `mkdir` существующего `Input` с `ACCESS_DENIED` не роняет `put`, если каталог существует | повторяет вызовы старого `PDFWorker`; ИС может ещё писать ответ; урезанные ACL шары | проверить на реальной шаре в Срезе 3 | | 31 | 2026-10-04 | Задача 4.2: временный файл `.part` лежит прямо в `Input`; читается один `.json` (старый код читал все файлы `Output//`) **[заменено №54–55 и №59]** | спека; реальная ИС может забирать файлы без маски — риск вынесен в `open-questions-proposal.md` | запись `.part` в служебный каталог, чтение всех файлов подкаталога | -| 32 | 2026-10-04 | R-10 (наблюдение O-1): оба реальных ответа `fixtures/ocr/aep_5.json` и `aep_8_form4.json` сами являются ответами-ошибками ИС (`error = INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями (`form_name` пусто, `confidence 0.0`) и `table_structure: []`), поэтому путь «успех -> `NOT_IMPLEMENTED`» проверяется на синтетическом успешном ответе (копия `aep_5.json` с `error = null` и одной строкой таблицы) — в unit-тестах 5.1 и интеграционном тесте 6.1 (временный каталог фикстур с `ok_doc.json`); реальные фикстуры используются для сценария «ошибка ИС проброшена» | невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется | если владелец ждёт, что `aep_5.json` — успешный образец, нужны реальные успешные ответы (открытый вопрос Среза 3) | +| 32 | 2026-10-04 | R-10 (наблюдение O-1): оба реальных ответа `fixtures/ocr/aep_5.json` и `aep_8_form4.json` сами являются ответами-ошибками ИС (`error = INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями (`form_name` пусто, `confidence 0.0`) и `table_structure: []`), поэтому путь «успех -> `NOT_IMPLEMENTED`» проверяется на синтетическом успешном ответе (копия `aep_5.json` с `error = null` и одной строкой таблицы) — в unit-тестах 5.1 и интеграционном тесте 6.1 (временный каталог фикстур с `ok_doc.json`); реальные фикстуры используются для сценария «ошибка ИС проброшена» **[заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76]** | невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется | если владелец ждёт, что `aep_5.json` — успешный образец, нужны реальные успешные ответы (открытый вопрос Среза 3) | | 33 | 2026-10-04 | R-11: эмулятор читает обычные переменные окружения через собственные настройки (`SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`, `SCAN_EMULATOR_POLL_SECONDS`), а не `Settings` шлюза; `process_once` синхронный, `delay` спится один раз за проход, ответ пишется атомарно (`.part` + rename), затем входящий файл переименовывается в `.done` **[запись ответа — частично заменено №62: набор целиком через временный каталог]** | контейнеру эмулятора не нужны переменные MinIO/NATS | нет | | 34 | 2026-10-04 | Задача 4.3: без аргументов `from_env` подмешивает `.env`/`ENV_FILE` под `os.environ` (чтобы `make emulator` работал с `api/.env`); сообщение «нет пары» — `<имя без uuid-префикса, с расширением>: нет эталонного ответа в фикстурах`; битая фикстура -> ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре, вход получает `.done`; нет `Input/` — не ошибка, нет корня при старте — код выхода 2, нет каталога фикстур — warning | удобство dev; защита от зацикливания | нет | -| 35 | 2026-10-04 | R-12: `Normalizer.normalize(raw: object)` получает уже разобранный JSON ответа ИС; `json.loads` и отображение ошибки разбора в `INVALID_FORMAT` — на стороне воркера (D-7); `NullNormalizer` возвращает `INVALID_FORMAT` для не-объекта JSON, пробрасывает код ошибки ИС, если он входит в `ErrorCode` контракта (регистр значим), иначе `UNKNOWN_ERROR` с исходным кодом в сообщении; пустой `message` = отсутствует (текст по умолчанию) **[частично заменено №57]** | D-5 писал `raw: dict`, а «не JSON» из плана 5.1 нельзя разобрать, не имея байтов | если воркер захочет отдать декодирование нормализатору — перенести `json.loads` | +| 35 | 2026-10-04 | R-12: `Normalizer.normalize(raw: object)` получает уже разобранный JSON ответа ИС; `json.loads` и отображение ошибки разбора в `INVALID_FORMAT` — на стороне воркера (D-7); `NullNormalizer` возвращает `INVALID_FORMAT` для не-объекта JSON, пробрасывает код ошибки ИС, если он входит в `ErrorCode` контракта (регистр значим), иначе `UNKNOWN_ERROR` с исходным кодом в сообщении; пустой `message` = отсутствует (текст по умолчанию) **[частично заменено №57, №60]** | D-5 писал `raw: dict`, а «не JSON» из плана 5.1 нельзя разобрать, не имея байтов | если воркер захочет отдать декодирование нормализатору — перенести `json.loads` | | 36 | 2026-10-04 | Задача 5.1: `NormalizeOutcome` (pydantic, frozen) — инвариант: `done`/`needs_check` требуют непустой `documents`, `error` запрещает `documents`, `error` обязателен тогда и только тогда, когда `status = error` | повторяет `minItems: 1` схемы `recognition-result` для успешных статусов; ошибка конструктора раньше, чем ошибка сборки `result.json` | нет | | 37 | 2026-10-04 | R-13: `PUBLISH_RETRIES=3` — число повторов после первой попытки (до 4 попыток всего); `PublishFailed.attempts` — фактическое число попыток; tabular-parser должен толковать так же | буквальный текст спеки «повторить до трёх раз» и D-7 | одна попытка больше/меньше | | 38 | 2026-10-04 | Задача 5.2: если задан только один из `NATS_SEED`/`NATS_JWT` — `NatsConfigError` (как в arm-core); шаблонный `nats_callback.py` удалён; ошибки S3: `S3ObjectNotFoundError` ⊂ `S3ReadError` ⊂ `S3StorageError`, `S3WriteError`; после `publish`/`subscribe` вызывается `flush`; `connect()` блокирует до доступности NATS — вызывать в фоновой задаче | fail-fast на неполные учётные данные; без flush nats-py буферизует публикации при переподключении | нет | @@ -173,12 +177,12 @@ warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/ | 40 | 2026-10-04 | R-15: `ValueError` из `inbox_name` (непригодное `original_name`) -> `RECOGNITION_FAILED` «недопустимое имя файла» | в D-7 нет кода для этого случая | выбор кода | | 41 | 2026-10-04 | R-16: запрос не по схеме / MAJOR ≠ 1 / `pipeline` ≠ `image` -> `SCHEMA_MISMATCH` без ack; конверт ошибки публикуется только если из сырого JSON извлекаются валидные `job_id` и `file_id` (`attempt` из тела, если целое ≥ 1, иначе 1), иначе — лог и отбрасывание | схема конверта требует `job_id`/`file_id`; ядро закроет задание по `reply_deadline` | ядро не узнает об ошибке неразбираемого тела | | 42 | 2026-10-04 | R-17: ключ дедупликации — `job_id` (по спеке); повторный запрос публикует ack с `attempt` повторного запроса, второй файл не пишется; LRU размером `DONE_CACHE_SIZE` | спека, D-2, OQ-15 | более высокий `attempt` того же `job_id` не перезапускает обработку | -| 43 | 2026-10-04 | R-18: ошибка записи `raw/{job_id}.json` -> `UNKNOWN_ERROR` без `raw_s3_key`; ответ не JSON — сырые байты сохраняются как есть, затем `INVALID_FORMAT` с `raw_s3_key`; исключение нормализатора -> `UNKNOWN_ERROR` с `raw_s3_key` | D-4, D-7, сценарий «Нормализатор упал» | нет | +| 43 | 2026-10-04 | R-18: ошибка записи `raw/{job_id}.json` -> `UNKNOWN_ERROR` без `raw_s3_key`; ответ не JSON — сырые байты сохраняются как есть, затем `INVALID_FORMAT` с `raw_s3_key`; исключение нормализатора -> `UNKNOWN_ERROR` с `raw_s3_key` **[частично заменено №55–56: файл ответа не JSON даёт `parse_error` в склейке, `INVALID_FORMAT` — только если ошибочны все файлы]** | D-4, D-7, сценарий «Нормализатор упал» | нет | | 44 | 2026-10-04 | R-19: один бакет (глобальное ограничение): `request.s3.bucket` ≠ настроенного -> `FILE_READ_ERROR` после ack; `raw/` и `results/` — в настроенном бакете | «один бакет S3» | развёртывания с несколькими бакетами отвергаются | | 45 | 2026-10-04 | R-20: дедлайн = `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, `старт` — момент записи файла в горячую папку (после получения слота); `on_request` не блокируется: валидирует, регистрирует `job_id` и запускает задачу задания (ack -> семафор -> обработка) | ожидание слота входит в `reply_deadline` через `min`; колбэк NATS должен возвращаться быстро (T11.4) | нет | | 46 | 2026-10-04 | Задача 5.3 (подробно — 15 решений в отчёте исполнителя): задание, получившее слот после `reply_deadline`, отвечает `TIMEOUT` без записи файла и чтения S3 (ack уже опубликован); ответ ИС, не являющийся JSON, перечитывается через `SCAN_POLL_SECONDS` и принимается, когда стал JSON или перестал меняться (на дедлайне — последний прочитанный; затем `INVALID_FORMAT`); дедупликация в двух структурах (`_active` не вытесняется, `_done` — LRU `DONE_CACHE_SIZE`); `SCHEMA_MISMATCH` не регистрирует `job_id`; `error.message` ≤ 4000 символов, суррогаты заменяются; `raw/` пишется с `application/json` (не-JSON — `application/octet-stream`); нормализатор вызывается через `to_thread`; `stop()` не публикует конверты отменённых заданий (D-2); сбой SMB-сессии виден через `healthy()` (listdir с теми же учётными данными), без флага; проба `hotfolder`: таймаут 2 с, кэш 5 с, одна проба за раз; NATS подключается в фоновой задаче lifespan, ошибка `connect`/`subscribe` -> `nats = down` до перезапуска; порядок остановки: отмена подключения -> `worker.stop()` -> drain NATS -> S3 -> SMB; `/healthcheck` всегда 200 **[в части перечитывания не-JSON ответа заменено №56]** | экономия ИС; недописанный ответ на шаре без атомарного rename; ограничения размера публикации | поздний результат отброшен; неподвижная ошибка NATS требует перезапуска пода; зависший `poll` по SMB (до 60 с) не прерывается дедлайном (T12.2) | | 47 | 2026-10-04 | Задача 5.4: общая обвязка тестов воркера — `api/tests/worker_helpers.py` и `api/tests/unit/conftest.py`; ответ ИС для освобождения одного слота пишет тест (не `process_once`); отсутствие утечки слотов проверяется поведением (повторный `start()`); отмена воркера во время `to_thread(hotfolder.put)` не прерывает запись в потоке (файл уже в ИС, конверта нет, D-2) | эмулятор ответил бы на все четыре файла сразу; образец arm-core | возможна повторная отправка в ИС после рестарта (OQ-15) | -| 48 | 2026-10-04 | Задача 6.1: интеграционный тест поднимает настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (tmp-каталог горячей папки); синтетический успешный ответ `ok_doc.json` строится по форме §11 `05-domain-rules.md`; сценарии 1–2 пропускаются без `../fixtures`, недоступная инфраструктура — падение; тест ждёт подписку шлюза по фоновой задаче `nats-connect`; `ResourceWarning` aiohttp о теле > 1 МиБ отфильтрован в одном модуле (загрузка скана 1,29 МБ) | контейнера `scan-emulator` ещё нет (R-7); fixtures не содержат успешных ответов (R-10) | интеграционный тест может забрать чужие запросы из общей queue group `scan-gateway` на общем NATS (T14.1) | +| 48 | 2026-10-04 | Задача 6.1: интеграционный тест поднимает настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (tmp-каталог горячей папки); синтетический успешный ответ `ok_doc.json` строится по форме §11 `05-domain-rules.md`; сценарии 1–2 пропускаются без `../fixtures`, недоступная инфраструктура — падение; тест ждёт подписку шлюза по фоновой задаче `nats-connect`; `ResourceWarning` aiohttp о теле > 1 МиБ отфильтрован в одном модуле (загрузка скана 1,29 МБ) **[заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76]** | контейнера `scan-emulator` ещё нет (R-7); fixtures не содержат успешных ответов (R-10) | интеграционный тест может забрать чужие запросы из общей queue group `scan-gateway` на общем NATS (T14.1) | | 49 | 2026-10-04 | R-21: финальная волна исправлений = ровно список финального ревью (C-1, I-1, T12.1, T12.4, T7.3+T7.5, T2.2, неточности документов, строки в предложении для docs); I-2 (compose-фрагмент не применён) и I-3 (replay с `attempt+1` для `job_id` из LRU) оставлены человеку | второй волны нет; I-2 и I-3 меняют спеку/другой репозиторий | остальные находки — follow-up в `open-questions-proposal.md` | | 50 | 2026-10-04 | C-1: пакет `src` не создаёт приложение при импорте (`api/src/__init__.py` без импортов; `run_server.py` импортирует `src.app`); эмулятор стартует без `OPENBAO__*` и завершается кодом 2 при отсутствии корня горячей папки | R-11: контейнеру эмулятора не нужны настройки шлюза; обнаружено финальным ревью, тесты работали с полным `.env.test` | любой код, импортировавший `src:app`, должен перейти на `src.app:app` | | 51 | 2026-10-04 | I-1: `make up` после `up -d --wait postgres nats minio` выполняет `run --rm minio-init` (идемпотентно) | на свежем томе бакет `arm-ka` иначе не создаётся и `make check-full` падает на `NoSuchBucket` | `tabular-parser` — тот же пробел (чужой репозиторий, в предложении) | @@ -186,11 +190,14 @@ warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/ | 53 | 2026-10-04 | T7.3: `inbox_name` укорачивает основу имени так, чтобы `._<имя>.part` ≤ 240 байт UTF-8 (по code points, расширение ≤ 16 символов сохраняется, `surrogatepass`); `put` подавляет `OSError` при удалении `.part` | ENAMETOOLONG на ext4/NTFS давал ложный `RECOGNITION_FAILED` на допустимое имя; имя для ИС значения не имеет | эмулятор не найдёт фикстуру по усечённому имени (> ~200 байт — нереалистично) | | 54 | 2026-10-04 | Готовность набора ответов: `poll` отдаёт список, когда есть хотя бы один `*.json` и набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (2); таймаут задания по-прежнему `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, ненастоявшийся набор на дедлайне — `TIMEOUT` | OQ-78 (решение человека: читать все json, готовность — по стабилизации) не задаёт момента готовности; ИС кладёт файлы без общего маркера завершения | ИС пишет набор с паузой > 2 с — часть документов потеряна, повторов нет (OQ-15); значение настраивается | | 55 | 2026-10-04 | Склеенный ответ `{"job_id", "files": [{"name", "response", "parse_error"}]}`, файлы по возрастанию имени, в `files` — все `*.json` каталога (другие игнорируются); `raw/{job_id}.json` = склеенный ответ; `raw_s3_key` остаётся одной строкой, контракт v1.0 не меняется | OQ-78 требует «склеить в один ответ»; структура — наша; сохранение имён файлов нужно для разбора инцидентов и `metrics.warnings` | Срез 3 нормализатор читает склеенную структуру, а не одиночный ответ ИС — единственный вход, переделка локальна | -| 56 | 2026-10-04 | R-24: файл, не разобранный `json.loads` после настаивания набора, не перечитывается и не роняет задание: `response = null`, `parse_error` заполнен; при ошибочности всех файлов — `INVALID_FORMAT` | набор уже стабилен — повторное чтение не изменит файл; перечитывание «не-JSON» из решения №46 имело смысл для единственного файла без атомарного rename и заменено settle | недописанный, но стабильный 2 с файл будет принят как `parse_error`, а не дождётся конца записи | +| 56 | 2026-10-04 | R-24 (design #56): файл, не разобранный `json.loads` после настаивания набора, не перечитывается и не роняет задание: `response = null`, `parse_error` заполнен; при ошибочности всех файлов — `INVALID_FORMAT` | набор уже стабилен — повторное чтение не изменит файл; перечитывание «не-JSON» из решения №46 имело смысл для единственного файла без атомарного rename и заменено settle | недописанный, но стабильный 2 с файл будет принят как `parse_error`, а не дождётся конца записи | | 57 | 2026-10-04 | Успех файла — `error.code == ""`; ошибочный — непустой `error.code` либо `parse_error`; отсутствующий/`null` `error` ошибкой не считается; не-объект на корне склейки → `INVALID_FORMAT`; код вне перечня контракта → `UNKNOWN_ERROR` с исходным кодом в сообщении (R-12 сохранён) | образцы `aep_3.json`, `aep_6.json` (OQ-77): у успеха `error = {"code": "", "message": ""}`; проверка `error != null` принимала успех за ошибку | ИС, возвращающая `error` строкой/другого типа, будет трактоваться по правилу «нет `error.code` — успех» | | 58 | 2026-10-04 | Сводка по файлам в `NullNormalizer`: все файлы ошибочны → `status = error`, `error.code`/`message` первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`); есть хотя бы один успешный → `error.code = NOT_IMPLEMENTED`; каждый ошибочный файл — строка `<имя>: <код>: <сообщение>` в `metrics.warnings` в обоих случаях | OQ-78: ошибка одного документа не отменяет остальные; `NOT_IMPLEMENTED` — единственный честный статус, пока нормализации нет (Срез 3); `metrics.warnings` уже есть в контракте (`array`), расширять контракт не нужно | потребитель, ждущий `error.code` ошибочного файла при частичном успехе, увидит `NOT_IMPLEMENTED` — до Среза 3 различия не нужны | | 59 | 2026-10-04 | `SCAN_TMP_DIR` (`Tmp`) — служебный каталог на корне горячей папки; `.part` создаётся в `<корень>/Tmp/`, каталог создаётся при записи, `rename` в `Input/`; обе реализации (`LocalHotFolder`, `SmbHotFolder`) | OQ-79 (решение человека: временный файл — вне `Input`); корень остаётся единственной шарой, чтобы `rename` был атомарным | `Tmp` на другой ФС/шаре — `rename` не атомарен; ИС с маской, захватывающей `Tmp`, — неизвестно, проверить на стенде | | 60 | 2026-10-04 | `Normalizer.normalize(raw)` получает склеенный ответ (D-4), а `json.loads` каждого файла выполняет функция склейки `merge_answers` (на стороне воркера); воркер больше не отображает «не JSON» в `INVALID_FORMAT` сам — это делает нормализатор по `parse_error` | R-12 писал вход как «ответ ИС» (один файл); с несколькими файлами решение «ошибка или нет» принимается по набору | нет | | 61 | 2026-10-04 | Правило выбора фикстур эмулятора: `.json` и `_*.json` (не префикс `*`); имена в каталоге ответа — `.json` и `_.json`; `aep_8.pdf` → `aep_8_form4.json` покрывается `_*` | `*` забрал бы `aep_30*` при запросе `aep_3`; набор из нескольких файлов нужен для `aep_3.json` + `aep_3_ofr.json` | фикстура с именем вида `-x.json` не будет найдена | -| 62 | 2026-10-04 | R-26: набор ответов эмулятор записывает во временный каталог и переименовывает каталог в `Output//` целиком; «готовность по settle» в шлюзе проверяется отдельным тестом (файл дописывается во время опроса) | шлюз не должен видеть частичный набор от эмулятора; поведение settle всё равно нужно тестировать на подмене | rename каталога на нестандартной ФС (compose-том) может быть неатомарным — тогда набор виден по частям, settle закрывает | - +| 62 | 2026-10-04 | R-26 (design #62): набор ответов эмулятор записывает во временный каталог и переименовывает каталог в `Output//` целиком; «готовность по settle» в шлюзе проверяется отдельным тестом (файл дописывается во время опроса) | шлюз не должен видеть частичный набор от эмулятора; поведение settle всё равно нужно тестировать на подмене | rename каталога на нестандартной ФС (compose-том) может быть неатомарным — тогда набор виден по частям, settle закрывает | +| 63 | 2026-10-04 | R-22 (design #63): задача 7.1 переходно адаптирует вызов `poll` в воркере и фейки в тестах к `list[AnswerFile]`; вся склейка и нормализация — задача 7.2 | `poll` меняет тип результата, воркер не должен ломаться между задачами | небольшой переходный код, удаляемый в 7.2 | +| 64 | 2026-10-04 | R-23 (design #64): существующие тесты воркера, таймаута и параллелизма ставят `SCAN_SETTLE_SECONDS=0`; settle тестируется отдельно с внедряемыми часами | не удлинять тесты на 2 с каждый и не делать их зависимыми от реального времени | если 0 не отключает проверку стабилизации полностью, тесты таймаута флапают | +| 65 | 2026-10-04 | R-25 (design #65): `NormalizeOutcome` получает необязательное поле `warnings`, которое воркер кладёт в `metrics.warnings` конверта; поле опускается, когда ошибочных файлов нет | `metrics.warnings` в контракте есть (`array`), а нормализатор — единственное место, знающее об ошибках по файлам | внутренний тип, контракт v1.0 не меняется; Срез 3 может заменить | +| 66 | 2026-10-04 | R-28 (design #66): ответ эмулятора «нет пары» — `error.code = INVALID_DOCUMENT` и `data_ocr` — **объект** с пустыми полями (`label`/`organization`/`inn`/`units`/`date` — `{form_name: "", confidence: 0}`, `table_structure: []`) как у реальных ответов-ошибок `aep_5.json`, `aep_8_form4.json`, а не `[]` | OQ-77: `data_ocr` — объект; эмулятор должен воспроизводить реальную форму | нет | diff --git a/openspec/changes/slice-0-skeleton/open-questions-proposal.md b/openspec/changes/slice-0-skeleton/open-questions-proposal.md index d27e3c4..5fbb60f 100644 --- a/openspec/changes/slice-0-skeleton/open-questions-proposal.md +++ b/openspec/changes/slice-0-skeleton/open-questions-proposal.md @@ -138,9 +138,10 @@ python -m src.scan_emulator` завершается кодом 2 с сообще Строки `Observation` из `progress.md` (только пересказ, статусы — в ledger): -- **O-1** — обе реальные фикстуры (`aep_5.json`, `aep_8_form4.json`) сами являются ответами-ошибками ИС - (`INVALID_DOCUMENT`, `data_ocr` — объект, не список); пути `NOT_IMPLEMENTED` для реальных фикстур недостижимы; успех - тестируется синтетическим ответом (R-10). Пункты 3 и 4 таблицы. +- **O-1** — **[закрыто 04.10.2026: образцы `aep_3.json`, `aep_6.json` (OQ-76), пункты 3 и 4 таблицы]**. Исходно: обе + реальные фикстуры (`aep_5.json`, `aep_8_form4.json`) сами являлись ответами-ошибками ИС (`INVALID_DOCUMENT`, `data_ocr` + — объект, не список), поэтому путь `NOT_IMPLEMENTED` проверялся синтетическим ответом (R-10, design №32, заменено D-9); + теперь он проверяется на реальных успешных ответах. - **O-2** — исполнитель T11 вопреки инструкции поставил трейлер харнесса; история локальных коммитов переписана (`filter-branch`), в remote ничего не уходило. Процессное, к docs не относится. - **O-a..O-e** (по итогам Task 3) — `docs/dev/README.md` устарел (пункт 10); порт 8002 против 8000 (пункт 10 и diff --git a/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md b/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md index b81fca8..75bfc31 100644 --- a/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md @@ -70,8 +70,8 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO - **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR` #### Scenario: Склеенный сырой ответ -- **WHEN** ИС положила `aep_3.json` и `aep_3_ofr.json` (имена с префиксом `job_id`) -- **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов по возрастанию имени, у каждого `response` — разобранный ответ, `parse_error = null` +- **WHEN** ИС положила в `Output//` файлы `.json` и `_ofr.json` +- **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов (`.json`, затем `_ofr.json`), у каждого `response` — разобранный ответ, `parse_error = null` #### Scenario: Файл не JSON - **WHEN** один из файлов ответа не разбирается как JSON @@ -83,28 +83,43 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO результата в `results/{file_id}/{job_id}/result.json` и `result_s3`; при неудаче публикации SHALL повторить её до трёх раз. В Срезе 0 нормализатор получает склеенный ответ и SHALL считать файл успешным, если `error.code == ""` (поле `error` реального успешного ответа — `{"code": "", "message": ""}`, а не `null`; отсутствующий или `null` `error` ошибкой не -считается), и ошибочным, если `error.code` непуст либо `parse_error` не `null`; успех MUST NOT определяться проверкой -«`error` не `null`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у первого -ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`). Если есть хотя бы один успешный файл — -`status = error`, `error.code = NOT_IMPLEMENTED`. В обоих случаях `metrics.warnings` SHALL содержать по одной строке -`<имя>: <код>: <сообщение>` на каждый ошибочный файл. +считается), и ошибочным, если `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`. Если есть хотя бы один успешный +файл — `status = error`, `error.code = NOT_IMPLEMENTED`. В обоих случаях `metrics.warnings` SHALL содержать по одной +строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как получен (для +`parse_error` и не-объекта — `INVALID_FORMAT`); если ошибочных файлов нет, поле `warnings` SHALL быть опущено. #### Scenario: Успешный ответ ИС в Срезе 0 -- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (`aep_6.json`) -- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/.json`, `metrics.warnings` пуст или отсутствует +- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`) +- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/.json`, поле `metrics.warnings` отсутствует #### Scenario: Ошибка ИС -- **WHEN** единственный файл ответа содержит `error.code = INVALID_DOCUMENT` -- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `<имя>: INVALID_DOCUMENT: <сообщение>` +- **WHEN** единственный файл ответа `.json` содержит `error.code = INVALID_DOCUMENT` +- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `.json: INVALID_DOCUMENT: <сообщение>` #### Scenario: Ошибка одного из документов -- **WHEN** ответ состоит из `aep_3.json` (успех) и `aep_3_ofr.json` (`INVALID_DOCUMENT`) -- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `metrics.warnings` содержит ровно одну строку про `aep_3_ofr.json` +- **WHEN** ответ состоит из `.json` (успех) и `_ofr.json` (`INVALID_DOCUMENT`) — как у фикстур `aep_3.json` и `aep_3_ofr.json` +- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `metrics.warnings` содержит ровно одну строку `_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` содержит одну строку `.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` — `.json: SOME_NEW_CODE: <сообщение>` + ### Requirement: Таймаут ожидания ответа Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`; повторная отправка файла в ИС MUST NOT выполняться. @@ -113,6 +128,10 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO - **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется - **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге +#### Scenario: Набор не успел настояться до дедлайна +- **WHEN** в `Output//` есть `*.json`, но набор имён или размеров менялся позже, чем `SCAN_SETTLE_SECONDS` до дедлайна +- **THEN** ответ не считается готовым, опубликован конверт `TIMEOUT`, недостающие файлы не ожидаются + ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не diff --git a/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md b/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md index 70e3eec..3201c7e 100644 --- a/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md @@ -45,12 +45,14 @@ - **THEN** `aep_30.json` в набор не попадает ### Requirement: Неизвестный файл — ошибка в формате Content AI -Если ни одного файла набора не найдено, эмулятор SHALL записать один ответ (`.json`) `{"file_uid": "", "error": {"code": "INVALID_DOCUMENT", -"message": "<имя>: нет эталонного ответа в фикстурах"}, "data_ocr": []}`. +Если ни одного файла набора не найдено, эмулятор SHALL записать один ответ `.json` в формате реальных ответов-ошибок +ИС (`aep_5.json`, `aep_8_form4.json`): `file_uid = `, `error = {"code": "INVALID_DOCUMENT", "message": "<имя>: нет +эталонного ответа в фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, +`date` — `{"form_name": "", "confidence": 0}`) и `table_structure: []`. #### Scenario: Нет пары - **WHEN** входящий файл — `…_unknown.pdf` -- **THEN** ответ содержит `error.code = INVALID_DOCUMENT` и пустой `data_ocr`; `scan-gateway` публикует конверт с тем же кодом +- **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом ### Requirement: Эмулятор запускается из того же образа В `docs/dev/docker-compose.yml` сервис `scan-emulator` SHALL использовать образ `scan-gateway` с командой diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 8da70d9..f95e444 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -73,7 +73,11 @@ ## 7. Доработка по реальным ответам ИС Основание: решения человека 04.10.2026 (OQ-76/77/78/79), план `docs/superpowers/plans/2026-10-04-multi-file-response.md`, -design №54–62. Контракт v1.0 не меняется; нормализация в `statementDocument` — Срез 3. +design №54–66. Контракт v1.0 не меняется; нормализация в `statementDocument` — Срез 3. + +Формулировки уже закрытых задач 4.3, 5.1, 5.3, 6.1 (поиск фикстуры по префиксу `*`, `aep_5.pdf` как единственный +сценарий, единственный файл `.json`, «не JSON → `INVALID_FORMAT`» без склейки) заменены задачами 7.x и +обновлёнными спеками; действует группа 7. - [x] 7.0 Привести change в соответствие с решениями человека: `proposal.md`, `design.md` (D-1, D-3–D-6, решения №54–62), `specs/image-recognition-gateway/spec.md`, `specs/scan-emulator/spec.md`, `open-questions-proposal.md` @@ -87,15 +91,16 @@ design №54–62. Контракт v1.0 не меняется; нормализ - [ ] 7.2 Склейка, сырой файл, нормализатор, воркер: `merge_answers(job_id, files)`; `raw/{job_id}.json` = склеенный ответ; `NullNormalizer` по правилам D-5 (успех = `error.code == ""`, per-file ошибки в `metrics.warnings`, `NOT_IMPLEMENTED` при хотя бы одном успешном файле); проверка: unit-тесты `merge_answers` (порядок, невалидный json → `parse_error`), - нормализатор на реальных `aep_6.json`, `aep_3.json` (не ошибка), `aep_3_ofr.json`, `aep_5.json` (`INVALID_DOCUMENT`), - `aep_3.json` + `aep_3_ofr.json` → `NOT_IMPLEMENTED` с одной строкой warnings, воркер с фейками; `make check` зелёный + нормализатор на реальных фикстурах `aep_6.json`, `aep_3.json` (не ошибка), `aep_3_ofr.json`, `aep_5.json` + (`INVALID_DOCUMENT`), `aep_3.json` + `aep_3_ofr.json` в склейке под именами `.json` / `_ofr.json` → + `NOT_IMPLEMENTED` с одной строкой warnings `_ofr.json: INVALID_DOCUMENT: …`, воркер с фейками; `make check` зелёный - [ ] 7.3 Эмулятор: набор файлов на задание — `.json` и `_*.json` в `Output//` как `.json` / `_.json` с `file_uid = `, атомарная запись каталога; проверка: unit-тесты (маркер `fixtures`) — `aep_3.pdf` → два файла, `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `_form4.json`, `aep_30*` не попадает в набор `aep_3`, неизвестное имя → `INVALID_DOCUMENT`; сквозной «put → эмулятор → poll (с settle) → склейка» на `aep_3`; `make check` зелёный - [ ] 7.4 Интеграционные тесты и документация: реальные NATS и MinIO — `aep_3.pdf` → `NOT_IMPLEMENTED` с warnings про - `aep_3_ofr.json` и двумя файлами в `raw/`, `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings, `aep_5.PDF` → `INVALID_DOCUMENT`; + `_ofr.json` и двумя файлами в `raw/`, `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings, `aep_5.PDF` → `INVALID_DOCUMENT`; `README.md`, `CLAUDE.md`, `.env.example`, `.env.test` актуализированы (`SCAN_TMP_DIR`, `SCAN_SETTLE_SECONDS`, склеенный raw); проверка: `make check-full` (при поднятом `make up`) — exit 0, вывод в отчёт - [ ] 7.5 Проверка в реальном compose (без правок `../docs`): сервисы `scan-gateway` и `scan-emulator` собираются и стартуют,