2026-11-07 02:46:07 +04:00

245 lines
49 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.24 (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/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.24), формат `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/` (сиблинг-репозиторий arm-ka-docs, работа по нему уже слита туда), а в `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` не пишется (нет в `audit_action`) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах |
| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY |