Срез 0 [7.2]: спека и design — R-29 (settle от первого опроса), D-5 с warnings, решения №67–74

- 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 14:28:26 +04:00
parent 973cf2717f
commit 2a0d0a8939
4 changed files with 14 additions and 5 deletions

View File

@ -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] Эмулятор: набор файлов на задание

View File

@ -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<string>`), а нормализатор — единственное место, знающее об ошибках по файлам | внутренний тип, контракт 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: <parse_error>»; вход не dict или `files` не список — `INVALID_FORMAT` «склеенный ответ ИС не соответствует структуре…»; `files` пуст — `INVALID_FORMAT` «в ответе ИС нет ни одного файла»; элемент `files` не объект или без непустого `name` — ошибочный файл `files[<i>]` с `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/<job_id>` → `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` |

View File

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

View File

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