2026-11-07 04:01:45 +04:00

268 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Дизайн: метрики качества первички (AURORA-1759)
## 1. Зачем и для кого
**Заказчик** (пользователи АРМ в банке) сам открывает экран в веб-интерфейсе и видит, какую работу снял АРМ: доля
документов, прошедших без участия аналитика или подтверждённых без правок, доля правленых, технические отказы — за
период, по ГК, по типу документа и по источнику. Цифры подтверждают эффект инициативы.
**Владелец проекта** использует те же цифры как аргумент о качестве сервиса разбора PDF (ИС Сканирование): если они
плохие — довод за переход на другой сервис. Поэтому каждая ошибка относится к источнику: **сервис разбора**, **наш
разбор** (нормализация `arm_statements`, маппинг ядра, tabular-parser) или **документ клиента** (контрольные цифры не
сходятся в оригинале). Без такого разделения цифра «30 % правят» против сервиса не работает.
Позже метрики переедут в отдельное решение на платформе банка (там точно есть Superset, хранилище неизвестно) — перенос
не должен требовать доработки ядра.
## 2. Цели, не-цели, анти-цели
**Цели**
- Факты качества по каждому документу ББ / ОФР / ОСВ, загруженному после выкладки, и по каждому техническому отказу.
- Отнесение каждой правки аналитика к источнику ошибки по сырому тексту сервиса.
- Экран: матрица «источник × причина» за период + динамика по месяцам; переход из ячейки в список документов и далее
в документ (правки, комментарии, сам файл).
- Факты пригодны для Superset как есть и для переноса в другое хранилище.
- Сохранить вход для будущего эталонного прогона: сырые ответы ИС и исходные xlsx, подтверждённый результат.
**Не-цели** — см. `proposal.md`, раздел Non-goals (в т. ч. ретро-метрики, время аналитика, видимость по филиалам, сам
эталонный прогон).
**Анти-цели** (провал, даже если работает)
- Цифры, которые нельзя проверить: каждая ячейка матрицы раскрывается до конкретных документов.
- Вина сервиса там, где ошиблись мы (или наоборот): правило атрибуции — механическое и записанное (§5), спорные случаи
уходят в «наш разбор», а не в «сервис».
- Лишняя работа аналитика: никаких отметок, анкет и кнопок ради метрик.
- Метрики, которые меняются задним числом: факт записан — не переписывается (append-only), поздние события — новые
факты.
- Связка с рабочими таблицами, из-за которой перенос на платформу требует переписать ядро.
## 3. Ограничения
- Инварианты `../CLAUDE.md`: первичка не мутируется; единственный писатель в БД — arm-core; производные не хранятся.
Факты — не производные консолидации, а журнал событий (как `audit_event` / `ex_snapshot`); метрики — чистая функция
от фактов, не хранятся.
- Поля — только из DBML: таблицы фактов, роль бакета, признак скана, объект права появляются в DBML v5.25 (change в
`../docs`) до кода.
- Нормализацию ББ/ОФР делают сервисы; в ядро приходит нормализованный `result.json`. «Сырой текст сервиса» в ядре — это
`financial_statement_value.source_text`, `financial_statement_line.line_code_text`,
`financial_statement_meta.header_source`, `osv_entry.row_value` (§11.5: `text` — всегда сырой текст источника).
Сырой JSON ИС лежит в S3 `raw/{job_id}.json`, в БД не зарегистрирован.
- Пересчёт после правки перезаписывает `needs_review`, `line_status`, статус документа и `document_issue`
(`relation`/`soft`) — поэтому состояние «как распознано» фиксируется фактом в момент приёма, до любых правок.
- Удаление файла физически удаляет строки документа (`primary_files.delete`) — факты не ссылаются на них FK.
- Сейчас у заказчика все работают под одной учётной записью (до РАИСА/SSO, AURORA-1404).
## 4. Факты
Отдельная схема PostgreSQL (имя — настройка, по умолчанию `dkb_metrics`), миграции — общей цепочкой Alembic. Таблицы
плоские: измерения — обычные столбцы, подробности — `jsonb` с версией схемы (`schema_version`). Без FK на рабочие
таблицы; идентификаторы документов, файлов, заданий, ЮЛ, ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта.
Контракт столбцов и `payload` — `../docs/contracts/metrics/quality-facts.schema.json`. Записи только добавляются.
### 4.1 `quality_intake_fact` — документ «как распознано»
Пишется в транзакции маппинга (`result_mapping._map_candidate`, после `map_file`, **до** `auto_confirm`), по одному на
созданный документ ББ / ОФР / ОСВ (`DISCLOSURE` / `OTHER` — не пишутся).
Измерения: `document_id`, `file_id`, `job_id`, `attempt`, `legal_entity_id` + имя, `group_id` + имя (ГК ЮЛ на момент
факта; ЮЛ вне ГК — пусто), `document_type`, `source_kind` (`pdf` / `excel`), `is_scan` (только PDF), `service_name`,
`service_version`, `statements_version` (ББ/ОФР), `result_schema_version`, `report_date` (как распознано),
`intake_at`, `intake_status` (статус документа после маппинга: `RECOGNIZED` / `NEEDS_CHECK`), счётчики: строк,
значений, значений с `needs_review`, строк `unreadable` / `missing`, нарушений `relation` / `soft`, предупреждений
сервиса; `raw_s3_key` (PDF), `result_s3_key`, `upload_s3_key`.
`payload`: снимок «как распознано» — шапка (`header_source`, распознанные поля), строки (`ref`, `row_index`,
`line_code`, `line_code_text`, `line_status`, `needs_review`), значения (`line ref`, колонка, `amount`, `string_value`,
`source_text`, `confidence`, `is_recognized`, `needs_review`), нарушения и коды предупреждений. Для ОСВ — счётчики
строк и коды предупреждений `OSV_*`, без построчного снимка (правок ОСВ по суммам в ядре нет).
### 4.2 `quality_outcome_fact` — исход документа
Один на документ, пишется при первом из событий:
- **подтверждение** — `documents.confirm` (вручную) и `auto_confirm` (автоматически), в той же транзакции;
- **удаление до подтверждения** — `primary_files.delete`;
- **замена** — автозамена `FILE_REPLACE` (документ вытеснен новым файлом).
Измерения: `document_id` (связь с 4.1), `outcome` (`AUTO_CONFIRMED` / `CONFIRMED` / `DELETED` / `SUPERSEDED`),
`outcome_at`, `primary_cause` (§6), счётчики правок по источнику (§5): значения, коды, добавленные строки, шапка —
`service` / `ours` / `client`; `checks_accepted` (подтверждено с акцептом нарушенных проверок), число нарушений на
момент исхода, `report_date` подтверждённый, `confirmed_by` (пользователь; для агрегатов не используется).
`payload`: подтверждённый результат (то же строение, что снимок приёма) и список правок с отнесением — каждая правка:
что, было (сырой текст / распознано), стало, источник, правило §5, которое сработало.
Подтверждённую ОСВ можно править (правка сбрасывает подтверждение) — повторное подтверждение пишет **новый** факт
исхода; метрики берут последний.
Удаление файла с уже подтверждённым документом (автоподтверждённые при версии «в работе», OQ-157) и автозамена
подтверждённого документа пишут **новый** факт `DELETED_AFTER_CONFIRM` / `SUPERSEDED_AFTER_CONFIRM` с исходом, который
был до него. Метрики берут последний факт: такой документ уходит из своей ячейки в отдельный столбец «удалён / заменён
после подтверждения» (решение владельца 27.10.2026) — в доли не входит, но виден, и из него раскрывается список с
прежним исходом.
### 4.3 `quality_failure_fact` — технический отказ
Пишется при переходе задания распознавания в `error` / `timeout` / `dlq` (`recognition_exchange`) и при неповторяемой
ошибке маппинга (`MappingFatal`, файл → `ERROR`). Измерения: `job_id`, `file_id`, `attempt`, `legal_entity_id`,
`group_id`, `source_kind`, `is_scan`, `service_name`, `failure_kind` (`SERVICE_ERROR` / `TIMEOUT` / `NO_ACK` /
`MAPPING`), `error_code`, `failed_at`. Повторная обработка (`FILE_REPROCESS`) создаёт новое задание: отказ первой
попытки остаётся фактом, документы удачной попытки получают свои 4.1 / 4.2.
## 5. Отнесение правки к источнику
Правка — отличие подтверждённого результата от снимка приёма (4.1). Правило одно: **верный ответ есть в сыром тексте
сервиса — ошибка наша; нет — ошибка сервиса.** Для Excel сервис разбора — наш tabular-parser, поэтому у Excel любая
ошибка распознавания — «наш разбор».
| Что правили | «Наш разбор», если | Иначе |
|---|---|---|
| Сумма | цифры подтверждённой суммы (без знака) совпадают с цифрами сырого текста после замены букв-двойников §11.3 — сервис прочитал верно, а мы неверно разобрали (знак, скобки, разряды, мусор по краям) | сервис |
| Код строки | подтверждённый код есть среди кодов, извлекаемых из `line_code_text` (в т. ч. одна из частей склейки `1250\n1260`) | сервис |
| Добавленная строка (`row_index IS NULL`) | её код есть в наборе кодов сырого ответа (для PDF — четырёхзначные токены, стоящие отдельной строкой в тексте ячеек `raw/{job_id}.json`; набор извлекается при приёме, вне транзакции, рядом с чтением `result.json`, и кладётся в снимок 4.1; для Excel — всегда «наш разбор») | сервис |
| Шапка: период, ИНН, единицы, наименование | подтверждённое значение извлекается из `header_source[*].text` | сервис |
| Дата колонки | подтверждённая дата извлекается из подписи колонки (`label`) | сервис |
| Период / счёт ОСВ | всегда «наш разбор» (Excel) | — |
**Документ клиента** — не правка, а исход: подтверждён **вручную** с акцептом нарушенных **жёстких** проверок
(`checks_accepted`), и нарушенное соотношение не задето правками. Мягкие нарушения (в т. ч. «в пределах округления» и
межформенная 1370↔2400) не учитываются — это в основном шум округления, и подтверждение они не блокируют.
Автоподтверждение жёстких нарушений иметь не может. Если правки чинят часть нарушений, а остальные акцептованы — у
документа и правки, и «документ клиента».
Сравнение — чистая функция `domain/quality_attribution.py` (§14 `05-domain-rules.md`), без БД. Разбор сырого текста —
публичным API `arm_statements` (`fix_lookalikes`, `parse_amount`, `parse_code`, `parse_date`, `default_rules`), тем же
кодом, что разбирал сервис; сам пакет не меняется (и не зависит от того, слита ли AURORA-1677: имя модуля то же).
## 6. Причина документа (ячейка матрицы)
Каждый документ с исходом попадает ровно в одну ячейку — по первому сработавшему правилу:
1. исход `DELETED` / `SUPERSEDED` → «удалён / заменён до подтверждения»; `DELETED_AFTER_CONFIRM` /
`SUPERSEDED_AFTER_CONFIRM` → «удалён / заменён после подтверждения» (оба столбца в доли не входят);
2. есть правки «сервис» → **ошибка сервиса**;
3. есть правки «наш разбор» → **наш разбор**;
4. подтверждён с акцептом нарушений (§5) → **документ клиента**;
5. `AUTO_CONFIRMED` → **без участия**;
6. `CONFIRMED` без правок → **как есть** (распознано верно, но потребовалась проверка аналитика).
Документы без исхода — **в работе** (отдельный столбец, в доли не входит). Технические отказы — отдельный столбец из
4.3, считаются по заданиям. Все счётчики правок по источнику остаются в факте, поэтому глубже матрицы видно и
«сервис + наш разбор» в одном документе.
Знаменатель долей — документы с исходом `AUTO_CONFIRMED` / `CONFIRMED`. Технические отказы считаются **по файлам**
(документов у них нет, тип неизвестен): отдельный столбец со своей долей «отказавших файлов от загруженных файлов того
же источника»; фильтр по типу документа на него не действует (на экране — пометка). Рядом с матрицей — счётчик
«уникальных отчётов» (ЮЛ + подтверждённая отчётная дата + тип), чтобы было видно, сколько повторных загрузок; это
счётчик, а не режим матрицы.
## 7. Расчёт, API, экран
- **Расчёт** — чистая функция `domain/quality_metrics.py` от фактов: фильтры (период по дате приёма, ГК, ЮЛ, тип,
источник, скан/цифровой), матрица и ряд по месяцам. В БД не пишется.
- **API** (`/api/v1/quality-metrics`, право §8): матрица за период; ряд по месяцам по выбранной причине; список
документов ячейки (документ, ЮЛ, дата, причина, счётчики правок, ссылка на документ — если он ещё существует);
выгрузка фактов за период (CSV / JSON) для переноса.
- **Экран** «Качество первички» (`web/`, пункт меню по праву): фильтры, матрица «источник × причина», динамика по
месяцам, переход ячейка → список документов → документ (существующий экран проверки распознавания с правками и
комментариями). Точная раскладка — при реализации (владелец: «главное наличие данных»).
## 8. Доступ
Новый объект права `QUALITY_METRICS` в `permission_object`, право `QUALITY_METRICS_VIEW`; seed выдаёт его всем ролям.
Позже права сужаются данными (`role_permission`), без кода. Видимость по филиалу / ГК не применяется. Список
документов показывает `confirmed_by`, агрегаты — без фамилий.
Superset читает схему фактов отдельной ролью БД только на чтение (создаёт платформа; в репозитории — описание в
`03-architecture.md`).
## 9. Сырые ответы и признак скана
- **Сырой ответ ИС.** Ядро уже получает `raw_s3_key` в сообщении результата — регистрирует объект в `s3_object` с новой
ролью бакета `raw` и ссылкой из `recognition_job`. Удаление файла не ставит `expires_at` сырому ответу и `uploads/`,
на которые ссылаются факты: они нужны эталону. Срок хранения — вопрос платформы (OQ-179), фиксируется в
`07-open-questions.md`.
- **Скан / цифровой PDF** определяется при загрузке по наличию текстового слоя (шрифтов в PDF) и хранится в
`stored_file`. PDF со скрытым OCR-слоем считается цифровым — допущение, записывается в §14.
## 10. Что решено владельцем (27.10.2026)
- Метрики — для заказчика (эффект инициативы) и как аргумент о сервисе разбора PDF.
- Охват — PDF и Excel; разрезы — качество сервиса, нашего разбора, документа клиента, технические отказы.
- Атрибуция по сырому тексту сервиса + сохранить данные для эталонного прогона («оставляем»).
- Экран в веб-интерфейсе, заказчик открывает сам; матрица и динамика; раскладка — при реализации.
- Только данные после выкладки.
- Без счётчика «есть, но не разбирается».
- Время аналитика — собирать в будущем (визиты с привязкой к документу, без нагрузки на аналитика; фамилии — для
разбора, агрегаты — без них); в этом раунде — только технические метрики.
- Отдельное право; сейчас видят все.
- Документ, удалённый или заменённый после подтверждения, — в отдельный столбец «удалён / заменён после
подтверждения» (не остаётся в «без участия» и не считается ошибкой).
- Перенос на платформу без доработки ядра; там есть Superset.
## 11. Оставлено исполнителю
Имена таблиц и столбцов (в рамках DBML v5.25), формат `payload`, раскладка экрана, способ определения текстового слоя
PDF, точный вид выгрузки, разбиение на задачи.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 27.10.2026 | Сам эталонный прогон — не в этом change; здесь только сохранение входа (сырые ответы, xlsx, подтверждённый результат) | прогон требует адаптера шага 1 сервисов (он в scan-gateway / tabular-parser) и отдельного инструмента сравнения; без данных он невозможен, а данные копятся только с выкладки | прогон приедет позже; данные для него уже будут |
| 27.10.2026 | Правки шапки (период, ИНН, единицы, наименование) — ошибка распознавания, атрибуция по `header_source` | шапку тоже распознаёт сервис | неверная классификация части правок |
| 27.10.2026 | Документы без исхода — столбец «в работе», в доли не входят | неподтверждённый документ ещё не оценён | — |
| 27.10.2026 | Повторная обработка: отказ первой попытки — технический отказ; документ — по итоговой попытке | отказ был, но документ в итоге получен | двойной учёт файла в отказах и в исходах — виден по `file_id` |
| 27.10.2026 | Одна ячейка на документ по приоритету §6 (сервис > наш разбор > документ клиента > без участия > как есть) | матрица должна суммироваться в 100 %; приоритет «сервис» — потому что правку сервиса аналитик делает руками всегда | документ с ошибками обеих сторон виден только в «сервисе»; счётчики в факте сохраняют обе |
| 27.10.2026 | Спорное — в «наш разбор» (верный ответ есть в сыром тексте, напр. склейка `1250\n1260`) | анти-цель: не приписывать сервису наши недоработки | метрика сервиса выглядит лучше, чем есть |
| 27.10.2026 | Отдельная схема БД `dkb_metrics` под факты, плоские таблицы + `jsonb` | Superset подключается к схеме ролью только на чтение; перенос — одна схема с контрактом | лишняя сложность миграций со второй схемой |
| 27.10.2026 | Признак скана — по текстовому слою PDF в ядре при загрузке, без изменения scan-gateway | ИС такой признак не отдаёт; ядро видит файл при загрузке | PDF со скрытым OCR-слоем — «цифровой» |
| 27.10.2026 | Период метрик — по дате приёма результата | исход приходит позже, документ остаётся в своей когорте | недавние периоды долго «в работе» |
| 27.10.2026 | Право `QUALITY_METRICS_VIEW` всем ролям в seed | владелец: сейчас видят все, потом — отдельная роль | — |
| 27.10.2026 | «Документ клиента» — только ручное подтверждение с акцептом жёстких нарушений; мягкие и 1370↔2400 не учитываются (builder check) | мягкие — шум округления и не блокируют подтверждение; иначе автоподтверждённые уйдут из «без участия» | реальные дефекты отчётности в пределах мягких соотношений не видны |
| 27.10.2026 | Код добавленной строки ищется среди четырёхзначных токенов сырого ответа ИС, извлечённых при приёме, без переноса адаптера шага 1 в ядро (builder check) | адаптер живёт в scan-gateway; совпадение с суммой даёт «наш разбор» — по правилу «спорное — нам» | метрика сервиса чуть лучше реальной на добавленных строках |
| 27.10.2026 | Технические отказы — по файлам, своей долей; фильтр по типу документа на них не действует; «уникальные отчёты» — счётчик, не режим (builder check) | у отказавшего файла нет документов и типа; смешивать файлы и документы в одном знаменателе нельзя | доля отказов не складывается с долями матрицы в 100 % |
| 27.10.2026 | ОСВ — без построчного снимка, только статусы, предупреждения, отказы, правки периода/счёта | правок сумм ОСВ в ядре нет | при появлении правок ОСВ — доработка снимка |
| 05.11.2026 | Task 1: `api/scripts/dbml_parity.py` (схема таблицы — через явный allowlist `METRICS_TABLES`, не единственная константа `SCHEMA`), `api/scripts/check_dbml_vs_orm.py` (фиксирует `QUALITY_METRICS_SCHEMA=dkb_metrics` в окружении проверки, как уже делает для `DB_SCHEMA`) и `api/alembic/env.py` (`include_name` признаёт обе схемы) — доработаны под вторую схему; план задачи 1 предполагал, что `check_dbml_vs_orm.py` «скорее всего» не потребует правок | без этого `make dbml-check` безусловно падал бы на `quality_intake_fact: схема: ожидалось dkb / фактически dkb_metrics» для всех трёх таблиц фактов; это ровно тот структурный сюрприз, о котором бриф просил не обходить тихо, а чинить или сообщать — чинить оказалось дешевле и находится в рамках задачи (`make dbml-check` — приёмный критерий шага 7) | без правки вторая схема логически невозможна — любой PR с `dkb_metrics` падает на CI/проверке |
| 05.11.2026 | Три новых ORM-класса (`QualityIntakeFact/OutcomeFact/FailureFact`) — на ОБЩЕМ `Base`/`Base.metadata` (как и всё остальное), схема переопределена точечно через `__table_args__ = {"schema": METRICS_SCHEMA}`; второй `DeclarativeBase` НЕ заводился | ровно второй вариант, предложенный самим бри­фом («…или аналог существующего паттерна»); так `Base.metadata` остаётся единой точкой для `check_dbml_vs_orm.py`/`alembic/env.py`/`create_all` — отдельная metadata потребовала бы протащить её через все три места отдельно | при введении третьей схемы в будущем придётся повторить тот же приём, а не скопировать общий механизм |
| 05.11.2026 | Три новых enum-типа (`quality_outcome`, `quality_cause`, `quality_failure_kind`) — значения живут в схеме `SCHEMA` (`dkb`), НЕ в `METRICS_SCHEMA`, хотя используют их только таблицы `dkb_metrics` | `pg_enum()` (src/models/enums.py) без правок — единственный источник схемы enum'а; разводить enum'ы по двум схемам потребовало бы параметра схемы в фабрике без текущей пользы (Superset читает только таблицы, не типы) | таблица `dkb_metrics` зависит от типов в `dkb`; перенос схемы метрик на другой сервер БД без `dkb` невозможен без правки (сейчас design §10 этого не требует) |
| 05.11.2026 | Откат миграции 0012 НЕ удаляет схему `dkb_metrics` (оставляет её пустой), симметрично тому, что `SCHEMA` (`dkb`) никогда не удаляется в `env.py`/0001 | тест `test_upgrade_into_preprovisioned_schema_without_database_create` (api/tests/test_migrations.py) показал конкретный отказ: роль без `CREATE` на базу не могла бы создать `dkb_metrics` заново при повторном `upgrade head`, если бы платформа выдавала схему готовой, а откат её удалял; симметрия с `dkb` — тот же риск, то же решение | если отладочный откат до base оставляет пустую схему `dkb_metrics` — незначительный мусор, не ошибка |
| 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана |
| 06.11.2026 | Task 13: `web/src/api/types.ts` — `'QUALITY_METRICS'` дописан в конец union `PermissionObject` ОДНОЙ строкой (не многострочным форматом), итоговая строка ровно 140 символов — на пределе, но не сверх лимита ширины biome | сохраняет однострочный стиль, которым уже написана эта строка (и соседняя `PermissionAction`); многострочный вариант рассматривался как равноценная по типам альтернатива, выбор между ними непринципиален | нулевая — чисто форматирование, обе формы типобезопасно идентичны |
| 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит |
| 05.11.2026 | `api/contracts/quality-facts.schema.json` НЕ скопирован из `../docs/contracts/` в Task 1 (`test_contract.py::test_vendored_contracts_are_identical_to_docs` остаётся красным на этом файле) — копирование отложено на задачу, которая реально строит `payload` по этой схеме | файл есть в `../docs/contracts/metrics/` (сиблинг-репозиторий arm-ka-docs, ветка feat/202, PR docs #203 — НЕ слита; порядок merge: docs PR #203 сливается раньше PR arm-core; позже файл вендорен — R-16), а в `api/contracts/` копии нет и несовпадение чисто механическое; копия сейчас была бы мёртвой — Task 1 (схема/ORM/миграция) не читает и не пишет `payload`, валидация полезной нагрузки — логика, не схема БД, и не входит в её рамки | следующая задача может не заметить, что это отложено осознанно, и либо повторно принять то же решение не зная о нём, либо оставить контракт невендоренным навсегда |
| 06.11.2026 | Task 7 (`CONFIRMED`): нет сырого текста для сравнения у правки суммы/кода/шапки (`source_text`/`line_code_text`/`header_source[field].text` = `None`) — атрибуция «наш разбор» (`ours`), не пропуск правки. Уточняет направление решения 27.10.2026 «Спорное — в наш разбор» на случай «данных для сравнения нет вообще» — это НЕ тот же случай, что пример `1250\n1260` в исходном решении (там текст есть, но двусмысленный); `attribute_*` (Task 3) типизированы на `str`, случай отсутствия текста не предусматривают. Этим же решением подтверждается (не меняется) уже принятое 27.10.2026 направление для добавленной строки PDF (строка «Код добавленной строки ищется среди четырёхзначных токенов…», та же дата) — черновая реализация задачи 7 свела этот случай к вызову `attribute_added_row(code, raw_line_codes=(), is_excel=False)`, который из-за пустого `raw_line_codes` всегда возвращает «сервис», что противоречило уже принятому там направлению «совпадение с суммой даёт наш разбор»; исправлено прямой атрибуцией «наш разбор» для PDF-добавленных строк без вызова `attribute_added_row` (сырых токенов всё равно нет, инфраструктура их извлечения не построена — тот же пробел, что в строке 27.10.2026) | анти-цель §2/§5 («вина сервиса там, где ошиблись мы; спорные случаи — в наш разбор, не в сервис») распространяется и на случай, когда данных для сравнения нет вовсе: отсутствие доказательства ошибки сервиса не есть доказательство ошибки сервиса | если отсутствие сырого текста окажется частым, а не редким краевым случаем, `OUR_ERROR` может быть систематически завышен, а `SERVICE_ERROR` — недосчитан; метрика ослабнет как рычаг давления на поставщика распознавания (прямая цель §1), хотя остаётся безопасным дефолтом по анти-цели §2 |
| 06.11.2026 | Task 8 (`DELETED`/`DELETED_AFTER_CONFIRM`): (1) документ в статусе, отличном от `CONFIRMED`/`RECOGNIZED`/`NEEDS_CHECK` (практически только `ERROR`), при удалении файла НЕ получает факт исхода — у него никогда не было содержательного состояния «как распознано» для фиксации (нет формы/строк, не проходил ни через `AUTO_CONFIRMED`, ни через intake-факт); (2) `issue_count_at_outcome` вычисляется как `counts.error + counts.info` из уже посчитанного агрегата `issue_counts_by_document` вместо отдельного запроса — тот же смысл «все нарушения документа без фильтра», что `len(issues)` в Task 6/7, дешевле на один запрос; (3) `payload` для DELETED*-фактов — единый минимальный вызов `build_outcome_payload(document, fixes=())` без дополнительных запросов к строкам/значениям формы или ОСВ (эти строки всё равно будут физически удалены этим же вызовом `delete`, повторный их снимок здесь экономически не оправдан — а в матрицу §6 эти факты и не попадают, доступны только как «удалён/заменён», drill-down уже видит прежний исход через более ранний intake/outcome факт того же `document_id`) | бриф задачи 8 явно делегировал все три пункта исполнителю («если неоднозначно — зафиксировать выбор, не угадывать»); исполнитель выбрал и задокументировал в докстрингах кода, перенесено сюда контроллером по правилу маркировки находок (решение агента без обсуждения с человеком — в design.md) | (1): при появлении у `ERROR`-документов содержательного состояния до удаления (гипотетически) — нужно будет расширить список статусов; (2)/(3): минимальный риск — агрегаты эквивалентны, а payload не используется для атрибуции (там `primary_cause=None`, вне матрицы) |
| 06.11.2026 | Task 10 (`quality_failure_fact`): `error_code` для отказов маппинга (`_fail`, `_register_failure`) — константа `MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"` (`result_mapping.py`), не свободный текст из `message`/описания исключения | `QualityFailureFact.error_code` не имеет свободнотекстового столбца-соседа для деталей отказа маппинга (в отличие от `recognition_job.error_message`); деталь остаётся в `metrics.mapping_error`/логе, код — единая константа, отличимая от кодов обмена (`ERROR_NO_ACK`/`ERROR_INVALID_REQUEST`), что проверяет отдельный тест | если деталь отказа маппинга понадобится в матрице причин без обращения к логам — нужен отдельный столбец/payload-поле, не замена константы |
| 06.11.2026 | Task 9 (`SUPERSEDED`/`SUPERSEDED_AFTER_CONFIRM`): документ в статусе `ERROR` НЕ исключён из факта-исхода при автозамене — `outcome = SUPERSEDED_AFTER_CONFIRM if status is CONFIRMED else SUPERSEDED`, без третьей ветки, в отличие от Task 8 (`delete`), где `ERROR`/прочие статусы факт не получают вовсе. Выбор противоположен Task 8-й строке выше, но осознанно: подтверждён построчной проверкой всех мест `Document.status = ...` в `src/` (`documents.py`, `osv_header.py`, `auto_confirm.py`, `domain/osv_mapping.py`) — `DocumentStatus.ERROR` НИГДЕ не присваивается документу в продакшн-коде сегодня, различие с Task 8 необнаружимо на практике | бриф задачи 9 дал буквальную формулу (`CONFIRMED` → `..._AFTER_CONFIRM`, иначе → без суффикса) без явного исключения `ERROR`; поскольку путь недостижим, более простой код (без третьей ветки) предпочтён усложнению ради симметрии с Task 8 | если в будущем появится код, присваивающий `Document.status = ERROR`, `delete()` и `supersede_previous()` разойдутся в поведении (один пропустит факт, другой запишет `SUPERSEDED`) без того, чтобы это было осознанным решением на тот момент — стоит перепроверить эту строку, если `ERROR`-статус когда-нибудь станет достижим |
| 07.11.2026 | Task 14 (`compute_matrix`): по `document_id` берётся последний факт по `outcome_at`; при равенстве `outcome_at` — `*_AFTER_CONFIRM` > `CONFIRMED`/`AUTO_CONFIRMED` > `DELETED`/`SUPERSEDED`, затем поздний во входе | результат не должен зависеть от порядка строк SELECT; design §4.2 — метрики берут последний факт | при редком совпадении времени документ посчитается подтверждённым вместо удалённого или наоборот |
| 07.11.2026 | Task 14: два столбца вне долей — `removed_before_confirm` (DELETED, SUPERSEDED) и `removed_after_confirm` (оба `*_AFTER_CONFIRM`), без разбивки на удалён/заменён; доли — float, при нулевом знаменателе 0.0 (не None); исход из знаменателя с `primary_cause=None` и неизвестный `outcome` — `ValueError`, без молчаливого пропуска | design §6 п.1 называет два столбца; битый факт или новый enum не должен тихо искажать матрицу | разбивку придётся добавлять позже; битый факт даёт 500 на экране вместо искажённой матрицы; фронт отличает 0% от «нет данных» только по `denominator` |
| 07.11.2026 | Task 14 (R-8): ряд по месяцам идёт по месяцу даты приёма (`received_at` = `quality_intake_fact.intake_at`), а не по `outcome_at`; поле добавлено во входную строку `QualityOutcomeFactRow`, Task 15 подставляет его join'ом intake↔outcome по `document_id`; пропущенные месяцы заполняются нулями | design §7 и решение 27.10: период по дате приёма (когорта) | документ попадёт в месяц исхода, а не приёма, метрики недавних периодов исказятся |
| 07.11.2026 | Task 15 (API): последний факт исхода на документ — `DISTINCT ON (document_id) ORDER BY document_id, outcome_at DESC, ранг DESC, id DESC` во всех запросах; ранг (`*_AFTER_CONFIRM` 2, подтверждение 1, остальное 0) совпадает с tie-break домена | SQL и домен должны выбирать один и тот же факт | расхождение выбора даёт разные числа в матрице и в списке ячейки |
| 07.11.2026 | Task 15: выборка идёт от фактов приёма (LEFT JOIN последнего исхода); исход без факта приёма в метрики не входит; границы периода включительные по UTC (`date_to` отсекается в 00:00 UTC следующего дня) | у исхода без приёма нет даты приёма (когорта); UTC — единая зона БД | для пользователей в МСК граница периода сдвинута на 3 часа |
| 07.11.2026 | Task 15: технические отказы — поле `technical_failures {failed_files_count, total_files_count, share}`, период по `failed_at`, `total_files_count` — уникальные `file_id` из приёма и отказов; «в работе» (`in_progress_count`) и `unique_reports_count` (ЮЛ + отчётная дата + тип) — отдельные поля; в ответе отдельно `superseded_after_confirm_count`, `deleted_after_confirm_count` | design §6, решение 27.10 про отказы по файлам | доля отказов считается от другого знаменателя, чем доли причин |
| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` пишется (см. R-12; исходное «нет в `audit_action`» было неверно) | design §7/§8; ячейки шире причин | Excel может показать BOM в чужих импортёрах |
| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3, R-9) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY |
| 07.11.2026 | Task 16 (R-10): на экране нет фильтра по ЮЛ (есть по ГК, периоду, типу, источнику, скану); API параметр `legal_entity_id` поддерживает | нужен отдельный пикер ЮЛ (тысячи записей); владелец: «главное наличие данных» | в UI нельзя посмотреть качество по одному ЮЛ — только через API/выгрузку |
| 07.11.2026 | Task 16 (R-11): матрица «источник × причина» — три параллельных запроса (`pipeline=image`, `pipeline=tabular`, без `pipeline`), колонки «PDF / Excel / Всего»; при выбранном источнике — одна колонка; «Всего» берётся с бэка, не суммой колонок | design §2/§7 требуют сравнения PDF и Excel; бэкенд отдаёт источник только фильтром | три запроса вместо одного на каждую смену фильтров |
| 07.11.2026 | Task 16: «Правки: сервис / наш разбор» в списке документов — суммы полей `*_service` и `*_ours`, `osv_period_account` не входит | в задаче названы только эти два семейства | правки периода и счёта ОСВ в колонке не видны |
| 07.11.2026 | Task 16: `Sidebar` снова фильтрует пункты по `permission` (поле было в типе, но не применялось после слияния репозиториев); пункт «Пользователи и роли» скрыт без `USERS_ADMIN`; `web/CLAUDE.md` исправлен | это восстановление прежнего поведения (коммит d3c2f8a); сервер и раньше отдавал 403 на эти страницы | у ролей без `USERS_ADMIN` пункт меню пропадает (раньше вёл на экран 403) |
| 07.11.2026 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge |
| 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 |
| 07.11.2026 | R-12 (final review M-2): `GET /quality-metrics/export` пишет `audit_event` (`action=EXPORT`, `section=EXPORT`, `object_type=quality_metrics`, `object_id=null`, `after_value={report, format, filters, rows}`) в транзакции запроса; `AuditAction.EXPORT` существует (как у экспорта сводной ОСВ, `consolidated_osv.py`); права — прежние `QUALITY_METRICS_VIEW` | выгрузка содержит названия ЮЛ и массовая; обоснование «нет в `audit_action`» в Task 15 было ошибочным | без записи выгрузка не оставляла следа; при записи в аудит каждый экспорт добавляет строку журнала |
| 07.11.2026 | R-13 (final review I-3, рулинг владельца): `stored_file.is_scan` и `recognition_job.raw_s3_object_id` — последние колонки таблиц, и в ORM, и в DBML v5.25 (docs); версия DBML не меняется. Тест миграции 0006 проверяет «последняя колонка» на ревизии `0006_dbml_v5_18`, а не на head | миграция 0012 добавляет колонки `ADD COLUMN` (в конец), `test_migrated_schema_equals_orm_create_all` и 0006-тест требуют порядок ORM = порядок миграции; прецедент v5.18 `consolidation_id` | порядок колонок в DBML v5.25 не совпадает с черновиками аналитики; смена порядка в ORM требует правки docs PR #203 (merge вместе) |
| 07.11.2026 | R-14 (final review I-4): при удалении файла, у которого есть `quality_intake_fact` с `file_id`, объект `uploads` файла не получает `expires_at` (исключён из `object_ids` и из префикса `uploads/{file_id}/`); определение — один запрос `has_intake_fact_for_file` по `file_id`, а не сравнение `upload_s3_key` | spec quality-metrics-model: объект нужен эталонному прогону (OQ-179); сравнение по `file_id` дешевле и покрывает все факты файла; объект `raw` в `object_ids` не входит и раньше | если у файла несколько документов и факт есть не у всех, объект сохраняется целиком (бакет растёт на размер файла); если `file_id` в факте ошибочен — объект экспирится и эталон потеряется |
| 07.11.2026 | R-15 (final review M-3): `has_text_layer` вызывается через `asyncio.to_thread` и разбирает не больше `MAX_PAGES_CHECKED = 10` первых страниц | pypdf синхронен и работал в event loop под advisory-lock загрузки; текстовый слой, если есть, виден на первых страницах формы отчётности (ББ/ОФР — 1-3 листа), 10 — запас | PDF, у которого текст только после 10-й страницы (титулы-сканы), будет помечен `is_scan = true` — метрика «скан» чуть завысит долю сканов |
| 07.11.2026 | R-16 (final review I-2, рулинг владельца-сессии): Контракт `quality-facts.schema.json` перенесён в docs из `docs/contracts/` в `docs/contracts/metrics/` (git mv, коммит в docs-ветке feat/202); копия в arm-core — `api/contracts/metrics/quality-facts.schema.json` (cp, байт-в-байт); ссылки в docs (DBML-заметки, design/proposal/spec/план change) и arm-core обновлены. `contract_identity.py` НЕ менялся | для пути глубины 2 не из `examples/` `_is_required_in_copy` копию не требует, а `docs_pair` сверяет копию arm-core по тому же относительному пути; `tabular-parser` и `scan-gateway` остаются зелёными без копии (проверено в arm-core: `test_contract*` 296 passed с копией). Схема не привязана к NATS-контракту распознавания, подкаталог это подчёркивает | если docs PR #203 сольётся без переноса, тесты идентичности в двух сервисах покраснеют на `quality-facts.schema.json` в корне; обратный перенос потребует правки ссылок в обоих репозиториях |
| 07.11.2026 | R-17 (I-2): Payload `values[]` по контракту: `line_id`/`column_id` → `line_ref` (= `ref` строки в `lines[]`; нет строки в снимке — `str(line_id)`) и `column` (= `str(column_id)`); `confidence` — числом (`float(Decimal)`, не деньги); `lines[].ref` = `source_ref`, при `null` — `str(id)`; `Decimal` сериализуется `format(d, 'f')` (без экспоненты, как требует `$defs/money`) | схема требует `line_ref`/`column`/`ref` строкой и `confidence` number; `column` — идентификатор колонки, а не её подпись/индекс: у значения есть только `column_id`, подпись требовала бы дополнительной выборки колонок в каждом хуке | `column` — UUID, а не читаемая дата колонки: аналитик по payload колонку не опознаёт без join; смена на индекс колонки потребует передавать колонки во все хуки |
| 07.11.2026 | R-18 (I-2): Запись `fixes[]` по контракту: `kind`→`field`, `attribution`→`source`, новые `rule` (строка таблицы §14.2: «Сумма», «Код строки», «Добавленная строка», «Шапка», «Период / счёт ОСВ»), `before_raw` (сырой текст сервиса: `source_text` / `line_code_text` / `header_source[field].text`), `before_recognized` (бывшее `before`), `line_id`→`line_ref` (для значения ещё `column`), для шапки поле — `header_field` (в `field` — `header`, как требует enum). `after` у ОСВ — `{period_year, period_quarter, osv_account_code}` | схема требует `field`/`after`/`source`/`rule` и допускает дополнительные свойства: `line_ref`, `column`, `header_field` — для разбора правки без join; `after` у `osv_period_account` обязателен, а «значение до» в `document` не хранится (`before_*` = null) | `header_field` и `line_ref`/`column` вне обязательных свойств схемы — потребитель не может на них полагаться как на контракт; у ОСВ нет «до» значения периода/счёта |
| 07.11.2026 | R-19 (I-2): Схема контракта НЕ менялась | все поля контракта заполняются; `raw_line_codes` необязателен и по-прежнему не пишется (R-5b, deferred) | — |
| 07.11.2026 | R-20 (I-2): Тест валидации: хелпер `api/tests/quality_payload_contract.py` (jsonschema Draft 2020-12, `$defs/intake-payload` и `$defs/outcome-payload`); хелперы чтения фактов в интеграционных тестах (`_outcome_fact`, `_intake_fact` в test_api_documents/test_api_files_delete/test_result_mapping) валидируют каждый прочитанный payload, test_auto_confirm — явно. Покрыты: приём ББ/ОФР и ОСВ, AUTO_CONFIRMED (форма и ОСВ), CONFIRMED с правками суммы/кода/шапки/ОСВ, DELETED(_AFTER_CONFIRM), SUPERSEDED(_AFTER_CONFIRM) | payload строится реальными сервисными функциями на PostgreSQL, не ручными литералами; `jsonschema` уже в `requirements-dev.txt` | валидация — в тестах, не в рантайме: выход payload за контракт в проде не отсекается |
| 07.11.2026 | R-21 (I-5): Правка отчётной даты шапки (`report_date`): сырой текст `header_source['report_date'].text` разбирается `parse_date` пакета `arm_statements` (после `fix_lookalikes`, `default_rules()`), даты сравниваются как даты (`attribute_header_date_fix`): равны — «наш разбор», различаются — «сервис», текст не разобрался — «наш разбор». Прочие поля шапки — прежнее сравнение подстрокой | ISO `2025-03-31` как подстрока `31.03.2025` не находился → почти любая правка даты шла в «сервис», вопреки анти-цели §2 («спорное — в наш разбор»); §14.2 и design §5 называют `parse_date` | если `parse_date` ошибётся в формате, дата различится — правка уйдёт в «сервис» (завышение доли ошибок сервиса); правка «Дата колонки» по-прежнему не учитывается (R-5a) |
| 06.11.2026 | R-4 (Task 7): атрибуция правок суммы/кода/шапки читает ЖИВЫЕ колонки (`is_modified`, `original_amount`, `source_text`, `line_code_modified`, `line_code_original_value`, `*_modified`/`*_original_value` шапки), а не JSON `quality_intake_fact.payload`, как буквально предлагал план; в `documents.py` docstring ссылается на «отчёт задачи 7» | `source_text` неизменяем (инвариант «первичка не мутируется»), поэтому живые колонки и снимок приёма равны по содержанию; типизированные колонки проще и надёжнее, чем разбор чужого JSON; функции `attribute_*` принимают строки и источник-агностичны | если понадобится сравнение по payload приёма — сменить источник в одном месте (`_write_confirm_outcome_fact`); docstring ссылается на файл отчёта, который удаляется вместе с SDD-workspace |
| 06.11.2026 | R-5a (Task 7): правка «Дата колонки» (§14.2) НЕ учитывается в счётчиках атрибуции | у `financial_statement_column` нет `*_modified`/`original_*`; правки дат колонок есть лишь в общем механизме аудита `services/statements.py` без чистого запроса «менялась ли дата колонки»; нужен новый столбец (схема) — вне объёма change | правки дат колонок в метриках не видны (занижены правки «наш разбор»/«сервис»); deferred, в Jira при закрытии (M-8) |
| 06.11.2026 | R-5b (Task 7): набор четырёхзначных кодов сырого ответа ИС (`raw_line_codes`, design §5) при приёме не извлекается и в снимок не кладётся; поле контракта необязательно и не пишется | задачи 2/5 извлечение не реализовали; адаптер шага 1 живёт в scan-gateway; извлечение вне объёма change | атрибуция добавленных строк PDF неполна; направление — см. R-6; deferred, в Jira (M-8) |
| 06.11.2026 | R-6 (Task 7, меняет направление R-5b): добавленная строка PDF при отсутствии набора кодов относится к «нашему разбору» (прямая атрибуция без вызова `attribute_added_row` с пустым набором) | пустой набор давал бы «сервис» всегда, вопреки уже принятому решению 27.10 («спорное — в наш разбор», строка про код добавленной строки) и анти-цели §2 | метрика сервиса чуть лучше реальной на добавленных строках (стоимость уже заложена в записи 27.10) |
| 06.11.2026 | R-7 (Task 7, сведён I-2): несоответствие формы `payload.fixes`/`values` контракту `quality-facts.schema.json` было отложено и вынесено в финальное ревью | ни одна задача плана не владела приведением payload к контракту, контракт не был вендорен, тестов валидации не было | закрыто в I-2: R-16…R-21 (вендоринг, приведение payload, тест валидации) |
| 07.11.2026 | R-22 (final review M-7): условие §14.4 «нарушенное соотношение не задето правками» НЕ проверяется: `checks_accepted = meta.checks_accepted_by is not None` (`documents._write_confirm_outcome_fact`), то есть любой акцепт нарушений при ручном подтверждении | на ячейку матрицы не влияет: при любой правке (`service`/`ours`) приоритет §6 п.2-3 выше «документа клиента», а без правок задетое правками соотношение невозможно; влияет только на поле `checks_accepted` в факте и на «документ клиента» у документа без правок — где условие выполняется тривиально. Точная проверка требует пересчёта соотношений до/после правок | если акцепт дан, а часть соотношений исправлена правками, поле `checks_accepted` у такого факта завышено; ячейка остаётся «сервис»/«наш разбор» |
| 07.11.2026 | R-23 (final review I-7): дельты OpenSpec — ADDED `quality-metrics`, `quality-metrics-ui`; MODIFIED `primary-files` (загрузка, удаление), `recognition-job-exchange` (inbox, таймаут), `documents-api` (подтверждение), `osv-result-mapping` (цикл маппинга, автоподтверждение), `authn-authz` (`GET /me`), `reference-data-seed` (seed, 16 → 17 прав) | `openspec validate --strict` требует полного текста изменяемого требования, поэтому MODIFIED копируют текущее требование из `openspec/specs/` с добавлением; авторизационная спека не перечисляет права, поэтому право отражено сценарием `/me` | при архивации дельты заменят требования целиком — правки спек основной ветки, сделанные параллельно, потеряются (проверить конфликт перед `openspec archive`) |