From 2a0d0a893961d5a3ec8e4ec4419c1864c7806493 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 14:28:26 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[7.2]:=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D0=B0=20=D0=B8=20design=20=E2=80=94=20R-29?= =?UTF-8?q?=20(settle=20=D0=BE=D1=82=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B0),=20D-5=20?= =?UTF-8?q?=D1=81=20warnings,=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=E2=84=9667=E2=80=9374?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec «Ответ из одного файла»: набор передаётся не позднее SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS после первого опроса, увидевшего окончательный набор (R-29). - design D-5: сигнатура NormalizeOutcome с warnings; воркер дописывает их в metrics.warnings. - Decisions made without discussion: №67 R-29, №68 решения исполнителя Task 2, №69–72 R-30..R-33 (предел warnings, склейка в src/gateway/merge.py, крайние случаи NullNormalizer, слияние warnings с metrics), №73 T2.1/T2.2/T2.4, №74 R-34 (адаптация test_roundtrip.py). - Отметки: Task 3 в плане, 7.2 в tasks.md. Co-Authored-By: Claude Sonnet 5.5 --- .../plans/2026-10-04-multi-file-response.md | 2 +- openspec/changes/slice-0-skeleton/design.md | 13 +++++++++++-- .../specs/image-recognition-gateway/spec.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-10-04-multi-file-response.md b/docs/superpowers/plans/2026-10-04-multi-file-response.md index 4c6b885..4a0040d 100644 --- a/docs/superpowers/plans/2026-10-04-multi-file-response.md +++ b/docs/superpowers/plans/2026-10-04-multi-file-response.md @@ -41,7 +41,7 @@ ### Task 3: [R3] Склейка ответа, сырой файл, нормализатор, воркер -- [ ] `src/gateway/` (или `src/normalizer/`): функция склейки `merge_answers(job_id, files) -> MergedResponse` по структуре из Global Constraints (разбор каждого файла `json.loads`, невалидный json → `parse_error`); воркер: опрос `poll` → `raw/{job_id}.json` в S3 = склеенный ответ (до нормализации; `raw_s3_key` в конверте) → `Normalizer.normalize(merged)`; `NullNormalizer` по правилам Global Constraints (успех = `error.code == ""`; **регрессионный тест на реальных `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 про `aep_3_ofr.json`; все файлы ошибочные → код первого); `metrics.warnings` в конверте результата. Тесты: unit на `merge_answers` (порядок, невалидный json), нормализатор на реальных фикстурах (маркер `fixtures`), воркер с фейками (склеенный raw в S3 содержит все файлы; ошибка одного файла не роняет задание; `parse_error` → warnings). Проверка: `make check` зелёный. +- [x] `src/gateway/` (или `src/normalizer/`): функция склейки `merge_answers(job_id, files) -> MergedResponse` по структуре из Global Constraints (разбор каждого файла `json.loads`, невалидный json → `parse_error`); воркер: опрос `poll` → `raw/{job_id}.json` в S3 = склеенный ответ (до нормализации; `raw_s3_key` в конверте) → `Normalizer.normalize(merged)`; `NullNormalizer` по правилам Global Constraints (успех = `error.code == ""`; **регрессионный тест на реальных `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 про `aep_3_ofr.json`; все файлы ошибочные → код первого); `metrics.warnings` в конверте результата. Тесты: unit на `merge_answers` (порядок, невалидный json), нормализатор на реальных фикстурах (маркер `fixtures`), воркер с фейками (склеенный raw в S3 содержит все файлы; ошибка одного файла не роняет задание; `parse_error` → warnings). Проверка: `make check` зелёный. ### Task 4: [R4] Эмулятор: набор файлов на задание diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 4c2419f..0c8e93b 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -54,7 +54,7 @@ NATS без JetStream: сообщение доставляется один ра стабилен). Сохраняется **до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3. ### D-5. Нормализатор — интерфейс; в Срезе 0 сводит ошибки по файлам и отвечает `NOT_IMPLEMENTED` -`Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics)` (`raw` — склеенный ответ, +`Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics, warnings)` (`raw` — склеенный ответ, разобранная структура D-4; JSON каждого файла разобран при склейке, не-объект на входе -> `INVALID_FORMAT`). `NullNormalizer` относит файл к «ошибочным», если `parse_error` не `null` либо `response.error.code` — непустая строка; **успех файла — `error.code == ""`** (реальный успешный ответ ИС несёт `error = {"code": "", "message": ""}`, поэтому @@ -64,7 +64,8 @@ NATS без JetStream: сообщение доставляется один ра корень ответа файла не объект JSON — ошибка этого файла `INVALID_FORMAT`), `error.message` — его сообщение; `metrics.warnings` — по строке на каждый ошибочный файл вида `<имя>: <код>: <сообщение>`, где `<код>` — исходный код ИС как получен (для `parse_error` и корня ответа, не являющегося объектом, — `INVALID_FORMAT`); -если ошибочных файлов нет, поле `warnings` опускается. +если ошибочных файлов нет, поле `warnings` опускается. `NormalizeOutcome.warnings: list[str]` (по умолчанию пуст, +R-25) — внутреннее поле: воркер дописывает его в конец `metrics.warnings` конверта и файла результата (R-30, R-33). Если есть хотя бы один успешный файл — `status = error`, `error.code = NOT_IMPLEMENTED` с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3», ошибочные файлы — в `metrics.warnings` так же. Срез 3 подменяет реализацию (§11, OQ-13/14), транспорт не меняется. @@ -201,3 +202,11 @@ warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/ | 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` — объект; эмулятор должен воспроизводить реальную форму | нет | +| 67 | 2026-10-04 | R-29 (design #67): готовность набора по settle отсчитывается от первого опроса, при котором наблюдается окончательный набор (не по mtime); сценарий «Ответ из одного файла»: набор передаётся не позднее `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после этого опроса | часы SMB-сервера и шлюза могут расходиться, mtime ненадёжен; внедряемые монотонные часы делают поведение тестируемым; прежняя формулировка «не позднее `SCAN_POLL_SECONDS`» недостижима без mtime (Concern Task 2) | ответ приходит до settle + poll позже стабилизации набора (боевые 2 + 5 с) | +| 68 | 2026-10-04 | Решения исполнителя Task 2 (7.1): состояние settle ограничено 1024 заданиями с вытеснением самых давних и сбрасывается при выдаче набора или пустом каталоге, `forget` в протокол `HotFolder` не входит; `*.json` сравнивается без учёта регистра; dot-файлы с расширением `.json` входят в набор; settle — от первого наблюдения в этом процессе; файл изменился между листингом и чтением (размер) или исчез → `None` и набор настаивается заново; в `put` `Tmp` создаётся раньше `Input`; сигнатуры: `tmp_dir` — четвёртый позиционный, `settle_seconds`/`clock` — только именованные, публичные `answer_dir`, `settle_seconds`, `pending_jobs`, `answer_path` сохранён | ИС на Windows/SMB (маски регистронезависимы); память не растёт от заданий, ушедших по таймауту; не трогать протокол и фейки ради редкого случая; защита от гонки «листинг → чтение» | вытесненное активное задание настаивается заново (ответ позже на settle); служебный `X.JSON` или `.tmp.json` ИС попадёт в набор | +| 69 | 2026-10-04 | R-30 (design #69): `metrics.warnings` конверта ограничено: не больше 100 строк (`MAX_WARNINGS`) по 1000 символов (`MAX_WARNING_CHARS`, `clean_text`: суррогаты заменяются, длинная строка обрезается с «…»); при избытке первые 99 строк + сводная «ещё предупреждений: N; все ошибки файлов — в raw/{job_id}.json» | конверт NATS ≤ 1 МБ при любом числе файлов и длине сообщений ИС (как T10.1 для `error.message`); полные данные остаются в сыром ответе | потребитель не увидит в конверте ошибки файлов сверх 99-го и хвост длинного сообщения — только в `raw/` | +| 70 | 2026-10-04 | R-31 (design #70): склейка — `src/gateway/merge.py` (`merge_answers`, `MergedResponse`/`MergedFile` — `TypedDict`, т. е. обычный dict); тот же объект пишется в `raw/{job_id}.json` (`dump_merged`: UTF-8 с читаемой кириллицей, при одиночном суррогате — ASCII-экранирование, всегда `application/json`) и передаётся нормализатору; `parse_error` — `<тип исключения>: <текст>` ≤ 300 символов; `merge_answers` сам сортирует файлы по имени | склейка — транспортная часть (вход — файлы горячей папки), не зависит от реализации нормализатора, которую подменит Срез 3; нормализатор видит ровно то, что сохранено в `raw/` | Срез 3 может захотеть типизированную модель вместо dict — правка локальна | +| 71 | 2026-10-04 | R-32 (design #71): крайние случаи `NullNormalizer`: `error.code` не строка и не `null`/`""` (число, список, `true`, `0`) — ошибка `UNKNOWN_ERROR`, в warnings — `repr` кода; пустое/отсутствующее сообщение ошибки ИС — «ИС Сканирование вернула ошибку <код> без сообщения» (и в `error.message`, и в warnings); `parse_error` — сообщение «ответ ИС не разбирается как JSON: »; вход не dict или `files` не список — `INVALID_FORMAT` «склеенный ответ ИС не соответствует структуре…»; `files` пуст — `INVALID_FORMAT` «в ответе ИС нет ни одного файла»; элемент `files` не объект или без непустого `name` — ошибочный файл `files[]` с `INVALID_FORMAT`; «первый по имени» — первый в порядке `files` (склейка уже отсортировала) | спека задаёт только «непустой `error.code`»; нормализатор не бросает ни на каком входе; Срез 3 заменит реализацию | ИС, кодирующая успех как `0`/`false`, будет понята как ошибка `UNKNOWN_ERROR` (реальные образцы — строка `""`) | +| 72 | 2026-10-04 | R-33 (design #72): `warnings` исхода дописываются в конец `metrics.warnings` исхода; итоговые `metrics` одинаковы в конверте и в файле результата (done/needs_check); пустой список не порождает `metrics` | одно место правды для предупреждений; файл результата и конверт не расходятся | нет | +| 73 | 2026-10-04 | T2.1, T2.2, T2.4 (design #73): поправки по ревью Task 2 — T2.1: после чтения набора каталог ответа листается повторно (Local и SMB), снимок отличается от настоявшегося → `None` (набор настаивается заново); T2.2: `SmbHotFolder` — ENOTDIR из листинга `Output/` → `None`; T2.4: тест сброса состояния settle при ENOENT/SHARING_VIOLATION/ENOTDIR | файл, появившийся или пропавший за время чтения, иначе дал бы неполный или устаревший набор; поведение SMB = Local | один лишний листинг на каждый выданный набор | +| 74 | 2026-10-04 | R-34 (design #74): интеграционный `tests/integration/test_roundtrip.py` минимально переведён на формат склейки `raw/` и успех `error = {"code": "", "message": ""}` (без новых сценариев — они в 7.4) | иначе он проверял бы старый формат `raw/`; полноценная переработка — задача 7.4 | не прогнан: в поднятом compose нет бакета `arm-ka` (`NoSuchBucket`), проверка — 7.4 после `make up` | 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 75bfc31..fc85cd9 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 @@ -48,7 +48,7 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO #### Scenario: Ответ из одного файла - **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS` -- **THEN** опрос прекращается не позднее чем через `SCAN_POLL_SECONDS` после этого, ответ из одного файла передаётся дальше по конвейеру +- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру #### Scenario: Ответ из нескольких файлов - **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index f3e0970..7519da2 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -88,7 +88,7 @@ design №54–66. Контракт v1.0 не меняется; нормализ (`LocalHotFolder`, `SmbHotFolder`), в `Input` — только итоговый файл; проверка: unit-тесты на `tmp_path` (нет `.part` в `Input`; нет каталога → `None`; один json; два json по имени; дописываемый файл → `None` до стабилизации; посторонний файл игнорируется; `SmbHotFolder` с подменённым `smbclient`), `make check` зелёный -- [ ] 7.2 Склейка, сырой файл, нормализатор, воркер: `merge_answers(job_id, files)`; `raw/{job_id}.json` = склеенный ответ; +- [x] 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`