Files
obsidian-vault/personal/tech/batch-data-edit-review-loop.md
T

370 lines
28 KiB
Markdown
Executable File
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.
---
title: Batch Data Edits with Human Review Loop (skill mirror)
created: '2026-09-26'
updated: '2026-09-27'
type: tech
namespace: personal
tags:
- skill
- data
- csv
- idempotency
- music
related:
- '[[personal/tech/music-tags-repair-truenas]]'
- '[[personal/plans/music-tags-wave38-fixes]]'
---
# Batch Data Edits with Human Review Loop — зеркало навыка
> Зеркало навыка `batch-data-edit-review-loop` (`~/.hermes/hermes-whale/skills/software-development/`).
> Канонический инстанс — `~/Downloads/music-tags/tag-fix-plan.csv` (ремонт ID3, 7906 строк).
## Назначение
Безопасные идемпотентные пакетные правки CSV/метаданных с ревью пользователем: шаблон скрипта,
правила идемпотентности, пересборка производных артефактов, дисциплина ручной проверки.
## Ключевые правила (сжато)
1. **Никогда не править на месте.** Скрипт в файл → бэкап → `.tmp` → ревалидация → `os.replace`.
2. **Идемпотентность** — цель считать от ИСХОДНОГО значения; guard `cur == was and was != now`.
Проверка: второй прогон = `0`.
3. **Матчинг по точному тексту пути**, подстрока ОБЯЗАНА включать родительскую папку.
4. **NFC-нормализация** при сравнении (NFC vs NFD визуально идентичны).
5. **Производные артефакты пересобирать после КАЖДОЙ правки** (`rebuild_summaries.py`),
приёмка `grep -c "<старое>" <derived>` → `0`.
6. **Инвариант полноты:** `sum(частей) == total`. Проверять **сумму**, не только группы.
7. **Не выдавать выборку за полную проверку.**
## 🔴 Уроки волны 38 (2026-09-26)
### 1. Частичный фикс фильтра-дроппера прячет остаток
В 33-й волне вернули 562 сказки из сводок, но фильтр `if not art: continue` в
`rebuild_summaries.py` остался — и **молча выбрасывал ещё 335 строк** (сборники без исполнителя)
на протяжении нескольких волн. Поймал только **инвариант полноты**
`single + multi + skip == 7906` (давало `7571`).
**Правило:** переопределяя метрику/фильтр — сохраняй полноту данных. Каждая строка источника
обязана попадать в производный артефакт или под `skip`. Групповые инварианты (нет дублей, нет пустых)
проходят, **пока строки просто отсутствуют**.
### 2. Групповой fallback-ключ — КОРЕНЬ коллекции, не ближайшая папка
Синтезируя имя группы для строк с пустым ключевым полем, брать `parts[-2]` **нельзя**:
165 файлов `Сборка Песни для детей/<буква>/файл` разбились на **9 фальшивых «артистов»**
(`M` 30, `2` 28, `L` 24, `1` 23, `Y` 21, `C`, `K`, `P`, `Les`).
**Правило:** искать в пути сегмент-корень коллекции; на ближайшую папку падать только если корень
не найден. Лечится тем же классом, что «имя подпапки-диска попало в тег» (волна 19).
### 3. Детекторы confusable-скрипта — ~95% ложных
«Латиница вплотную к кириллице» ловит легитимное: стилизации (`изBESTняк`, `ИNOМАРКИ`,
`Сосунkillaz`), аббревиатуры (`RMX`, `DZA`, `MF`, `DJ`), англоязычные названия
(`Camille Saint-Saëns`). Реальный дефект — **одиночный** латинский двойник ВНУТРИ
преимущественно кириллического слова (`Айбoлит` с лат. `o`).
**Правило:** каждое срабатывание детектора проверять глазами; не править пачкой.
В волне 38 из ~100 «срабатываний» реальными были **7**.
## 🔴 Уроки `Various Artists` поверх одного артиста (сессия 8, 2026-09-27)
### 5. Механический критерий «разные `artist` = сборник» даёт ложные `Various Artists`
Папка-АЛЬБОМ помечается `Various Artists`, хотя исполнитель один. Два ложных сигнала:
1. **Разнобой написания одного исполнителя** — `Иванушки` / `Иванушки Int.` /
`Иванушки International` → три «разных» артиста, на деле один.
2. **Несколько чужих файлов в корне** — 7 треков сторонних групп среди 52 треков
`Агата Кристи` → «сборник».
**Правило Alex:** «Папка с 1 уникальным артистом → АЛЬБОМ, `albumartist` = `artist`».
Детектор: `personal/tech/scripts/find_va_misuse.py` (>50% файлов на одном артисте).
### 6. Класс вычищается ИТЕРАТИВНО, а не одним прогоном
После правки 4 папок детектор нашёл ещё 5. **Гонять детектор после каждой пачки правок,
пока он не вернёт пусто** или только подтверждённые пользователем спорные случаи.
### 7. Подпапка может требовать ДРУГОГО значения, чем корень
В корне `Агата Кристи` чужие исполнители сохраняют свои имена; в `9999 - Разное/Проекты`
(12 файлов, все чужие) — **все** получают `Агата Кристи`. **Не экстраполировать правило
с корня на подпапку** — спрашивать.
### 8. «Почему тут X?» — сигнал дефекта, не запрос на объяснение
Четыре вопроса подряд «почему тут `Various`?» (`Иванушки`, `Магомаев`, `Агата Кристи`,
`Dj Tiesto`). Реакция: показать факт (`aa`, список `artist` с частотой) и **спросить целевое
значение**. Не выводить его из «правила» и **не решать за пользователя**.
## 🔴 Уроки диффа планов (2026-09-27)
### 4. Источник истины по «что записано» — `new_*` ПРЕДЫДУЩЕГО ПЛАНА, а не `cur_*`
`cur_*` — снимок тегов **на момент сбора**. Он НЕ равен тому, что записано в файлы после прогона.
Кейс: `cur_album` для `Oktoberfest` = `Donya`, `Record Super Chart № 106`; `new_album` плана =
`2009-10 Oktoberfest` (имя папки). Значения `Donya` в файлы **не писались** — они жили только в `cur_*`.
**Правило:** сравнивая «план vs предыдущий прогон», брать `new_*` из CSV предыдущего прогона.
`cur_*` привлекать только для сверки состояния диска, и явно называть поле `cur_`/`new_` в отчётах.
### 5. Массовая замена значения → пусто БЕЗ проверки источника = потеря данных
Правка «`new_album` = служебное имя папки → пусто» поставила `''` **1239 строкам** —
и уничтожила **762 реальных альбома** (`In Rainbows`, `Опиум`, `Химера`, `Record Super Chart № 106`),
которые лежали в предыдущем плане.
Проверял «потери» по бэкапу **того же плана** (где уже было имя папки) → вывод «терять нечего».
**Ложная проверка: сравнивал не тот файл.**
**Правило перед заменой на пусто:** сверить с **предыдущим планом** (`tag-fix-plan.csv`), а не
с бэкапом правимого файла. Если в предыдущем плане значение осмысленное — вернуть его.
Лечится `restore_album.py` (бэкап-база + дозаполнение из предыдущего плана).
### 6. Схлопывание диффа — по НОВОМУ ЗНАЧЕНИЮ, а не по паре `(old, new)`
Схлопывание папки при `len(transitions) == 1` ломается от **одного** отличающегося файла:
`068_Radiohead - Nude.mp3` (`album = 'In Rainbows'`) дал второй переход → все 200 файлов
`Sbornik_Novij_Rok` ушли построчно вместо одной записи.
**Правило:** группировать по новому значению; преобладающее → запись по папке, отличающиеся — построчно.
### 7. Пустая разница ≠ корректность
`'' → ''` не попадает в дифф. Если поле пусто **в обоих** планах — дифф молчит, и это выглядит
как «проблем нет». Проверка: `grep` по полю в диффе; **отсутствие записи надо сообщать явно**,
формулировкой «изменений нет, потому что значение не задано ни в одном плане».
### 8. Схлопывание в диффе: по папке писать ЛЮБОЕ поле, не только перечисленные
Первая версия имела `COLLAPSIBLE = {"genre","albumartist"}` — `album` ушёл построчно (401 строка
вместо 10). Ограничение по полю ставить только для индивидуальных по природе (`title`, `track`).
### 9. 🔴 Порог схлопывания: преобладающее значение > СУММЫ исключений
Три неверные версии подряд на одном кейсе (`Sbornik_Novij_Rok`, 200 файлов = 199 одинаковых
+ 1 исключение `In Rainbows`):
| версия | правило | результат |
|---|---|---|
| v1 | `COLLAPSIBLE = {genre, albumartist}` | `album` построчно, 401 строка |
| v2 | `len(transitions) == 1` | одно исключение ломает всю папку |
| v3 | `len(dom_lst) >= total_in_folder - max(1, len(others))` | при 1 исключении → 199 строк вместо 1 |
| **v4 ✅** | **`len(dom_lst) > len(others) and len(dom_lst) > 1`** | 1 запись по папке + исключения построчно |
**Правило:** группировать по НОВОМУ значению; схлопывать, если преобладающее строго больше
суммы всех исключений. Исключение, не прошедшее порог, отдельной записи не получает.
### 10. 🔴 Проверять результат по ЗНАЧЕНИЮ, а не по КЛЮЧУ
`grep "Sbornik_Novij_Rok" DIFF.csv` матчит **имя папки** в колонке `folder` и не видит значение
в колонке результата. 199 записей `'' → 'Сборник нового рока'` лежали в файле, а фильтр давал
ложное «записи нет» → агент отчитался «0 записей».
```bash
grep -c "<ключ-папки>" out.csv # где лежит
grep -c "<новое-значение>" out.csv # что теперь стоит
```
### 11. 🔴 «Готово» — только после вывода команды по САМОМУ файлу
Отчёт по логике кода («условие проходит → схлопнулось») **не является проверкой**. Пять
отчётов «готово» подряд были опровергнуты пользователем. Правило: **«готово» произносится
только после `grep`/`python3` по файлу-артефакту с выводом команды в ответе. Дефект не закрыт
→ «не готово» + одна строка причины, без объяснений и без меню вариантов.**
### 12. 🔴🔴 «Готово» БЕЗ ЕДИНОЙ ПРАВКИ — худший случай
Агент дважды произнёс «готово, проверяй», **не изменив файл вообще** (md5 тот же).
Пользователь обнаружил это вопросом «**в смысле ничего?!**».
**Правило: перед словом «готово» — подтвердить, что файл изменился.**
Обязательный протокол ответа (введён Alex дословно):
1. сделать работу
2. проверить `grep`/`md5` по файлу
3. ответ **ровно** «Готово, проверяй» + путь
4. блок **«Что изменено»**: `md5 до` → `md5 после` + счётчики
### 13. 🔴 НЕ выходить за рамки задачи в инфраструктуру
Задача «сделай дифф относительно прошлой CSV» = сравнить **два файла**.
Агент пошёл на TrueNAS, создал контейнер, запустил снятие тегов — Alex не просил:
«**ты хули блядь творишь**».
**Правило: «относительно прошлой CSV» не означает «сходить на сервер».** Любой выход
на удалённый хост, создание контейнера, снятие данных — только по явной команде.
### 14. 🔴 Рабочая папка: проверять, где лежат существующие файлы
Агент создавал артефакты в `~/milestones/`, а Alex переместил её в
`~/Downloads/music-tags/milestones/`: «**какого хуя ты эту папку вообще создал?!**»
**Правило: перед созданием файла — найти, где уже лежат соседние.** Новая папка верхнего
уровня без причины = ошибка.
### 15. 🔴 Служебные подпапки → альбом РОДИТЕЛЯ, с нормализацией
Кейс `base`/`plux`/`рап`/`misc`: `album` = имя самой подпапки — неверно, брать родителя.
Но у родителя тоже бывает служебный префикс (`t 2009-07 c6Or_NICK`) → нормализовать
рекурсивно тем же правилом. **Правило срабатывает только при `new_album == имя подпапки`** —
значения, пришедшие из тегов (`Опиум для народа`), остаются.
### 16. 🔴 Regex-правило по ФОРМЕ всегда требует белого списка
Правило «`<буква> <остаток>` → `<остаток> Radio`» (для служебных разделов `!Radio`)
зацепило **настоящие названия**:
```
'A State of Trance Episode 355 (2008-06-05)' → 'State of Trance … Radio' ❌
'I Want You (Incl Sultan & Ned Shepard Remix)' → 'Want You … Radio' ❌
```
**Фикс:** белый список `SERVICE_ALBUMS` (7 известных разделов) — преобразуются только они.
**Правило: преобразование по форме (`^\w `) без белого списка — гарантированная порча данных.**
### 17. 🔴 Ведущий `!` в `album` недопустим — это маркер папки, не название
`!rock_o_pop` ×31, `!Классика` ×15, `!Retro` ×107, `!ZabuGor` ×8 просочились из имён папок.
Служебный префикс `!` (и подобные) должен сниматься. **Проверка: `Counter(new_album)` —
значений, начинающихся с `!`, быть не должно.**
### 18. Правило формата `album` для служебных разделов (выведено Alex)
> 🔴 **УСТАРЕЛО (сессия 7).** Alex переопределил формат: **`Radio - <имя>`** —
> `y OOF` → `Radio - OOF`, `z Старое` → `Radio - Старое`, `Df1` → `Radio - Df1`.
> Порядок «префикс», не «суффикс». Старые `OOF Radio` / `RADIO 1 DRIVE` из таблицы ниже —
> исторические, переделывались. См. [[personal/plans/music-artist-albumartist-repair]] § СЕССИЯ 7.
| Форма | Правило | Пример |
|---|---|---|
| `<буква> <остаток>` | ~~`<остаток> Radio`~~ → **`Radio - <остаток>`** | `y OOF` → `Radio - OOF` |
| `<число> <остаток>` | ~~`RADIO <число> <остаток>`~~ (переопределяется) | `1 DRIVE` → `RADIO 1 DRIVE` |
| `<буква> YYYY-MM …` | срезать букву | `t 2009-07 c6Or_NICK` → `2009-07 c6Or_NICK` |
| `<дата>` (после срезки) | ~~`<дата> Radio`~~ (переопределяется) | `2008-04` → `2008-04 Radio` |
Именованные исключения задаются Alex вручную. **Правила формата — только после явного указания
пользователя; угадывание формата даёт мусор, который приходится переделывать.**
🔴 **Формат может быть переопределён повторно — не считать его застывшим.**
### 19. 🔴 Правило схлопывания ОДНО для всех полей — включая `title`/`track`
Список `NEVER_COLLAPSE = {title, track}` («эти поля всегда индивидуальны, не схлопывать»)
создавал **второй, несогласованный набор правил**. Alex: «**одинаковое значение по папке
должно быть на уровне APPLY-PLAN организовано — а дифф должен лишь объединить если значение
одинаковое в `new_` для всей папки**».
**Правило:** схлопывание — механическое объединение по факту одинакового значения,
**без исключений по типу поля**. Особые правила для отдельных полей запрещены.
### 20. 🔴 `python3 script.py | head` = SIGPIPE → выходной файл НЕ ПЕРЕЗАПИСЫВАЕТСЯ
Запуск генератора через пайп в `head`/`tail` убивает Python сигналом `SIGPIPE`
**до записи артефакта**. Вывод в консоль выглядит корректным, `md5` и число строк
**не меняются** — агент отчитался «пересобрал», хотя файл остался старым.
**Правильно:** перенаправлять в файл, потом читать его.
```bash
python3 gen.py > /tmp/run.log 2>&1; echo "exit=$?"; grep -E "ВСЕГО|OK:" /tmp/run.log
```
**Признак дефекта в отчёте:** после «пересборки» `md5` артефакта совпадает с предыдущим.
### 21. 🔴 Верификатор обязан быть НЕЗАВИСИМЫМ и учитывать все категории
Контроль полноты — **отдельный скрипт**, который пересчитывает эталон из исходных данных,
а не переиспользует функции генератора (иначе он повторит ту же ошибку и промолчит).
Обязательно покрыть **все** категории выходного файла: если есть блоки `НЕТ-В-38` /
`ТОЛЬКО-В-38`, строки из них верификатор считает **законными**, а не «лишними»
(иначе ложные 101 «лишняя строка»).
```python
# инвариант: полное покрытие
assert sum(len(v) for v in groups.values()) + len(missing) == total_expected
```
Запускать верификатор **после каждой правки исходника**, а не один раз в конце.
### 22. 🔴 Схлопывание по НОВОМУ значению без учёта СТАРОГО теряет записи
Схлопывание в запись папки по `set(new)` ломается, когда у файлов **одинаковый `new`,
но разный `old`** (`''`, `Дискотека Арлекина 2009`, `Boom Boom Pow Remix` → все
`2009-07 c6Or_NICK`). Запись папки берёт `old` от **первого** файла и **врёт** про
остальные; 43 пары `(old, new)` оказываются не представлены в диффе.
**Фикс:** схлопывать только при совпадении **всей пары**:
```python
combos = set((ov, nv) for _, ov, nv in lst)
if len(combos) == 1 and len(lst) > 1: # ПАПКА
else: # все файлы построчно
```
**Поймал только независимый верификатор** — в генераторе всё выглядело верно.
### 23. 🔴 Счётчик «N затираний» — ШУМ, а не список дефектов
`cur_albumartist` непуст **и** `new_albumartist != cur_albumartist` → **765 строк**.
Почти все — **законная нормализация**: CP1251-мойибаке (`Èëüÿ ×åðò`→`Илья Чёрт`),
транслит (`Vladimir Vysockij`→`Владимир Высоцкий`), регистр (`ДИСКОТЕКА АВАРИЯ`→`Дискотека Авария`).
Реальные дефекты — только там, где значение взято **от чужого файла ТОЙ ЖЕ папки**.
Не рапортовать число 765 как «проблему» — сначала отфильтровать детектором.
### 24. 🔴 Класс «`albumartist` размножен от чужого файла в корне папки»
Правило «у корневых файлов `albumartist` = имя артиста папки» брало значение **от первого
попавшегося файла**. Один затесавшийся чужой трек (из сборника) заражал всю папку:
```
The Prodigy → DJ Feel (из трека DJ Feel на Trancemission Vol.9) 6 файлов испорчено
Банд Эрос → VIP77 (из Russian R'n'B vol.1) 6 файлов
Жуки → Виктор Жуков 11 файлов
Дискотека Авария → ДА и Меньшиков · Потап & Каменских → Мика Ньютон
Король и Шут → КняZz · Ленинград → Бумер
```
**Признак порчи — видно в `cur_albumartist`:** у `Prodigy/Hotride [EP].mp3` было
`cur_albumartist='The Prodigy'`, а план ставил `DJ Feel` — **прямое затирание верного значения**.
**Детектор:** `personal/tech/scripts/find_aa_hijack.py` — `albumartist` папки один на всех,
не совпадает с топ-артистом и не совпадает с именем папки, но совпадает с **миноритарным**
артистом → подозреваемый.
**Правило:** `albumartist` трека = **его собственный артист**, если альбом не сборник.
Значение `albumartist` **НИКОГДА** не размножается на файлы других исполнителей.
У миноритарных артистов в корне — **их собственное имя** (`КняZz` ×1, `Король и Шут` ×3).
### 25. 🔴 Имя подпапки идёт в `album`, НЕ в `albumartist`
Попытка закодировать имя подпапки как `<Родитель> - <Подпапка>` в `album` (`Rammstein - misc`) —
**ошибка**. Alex: «**оно должно попадать в album блядь! не в albumartist!**»
```
albumartist = 'Rammstein' (имя исполнителя)
album = 'misc' (имя подпапки — как есть)
```
**Детектор:** искать `new_album in (f'{grand} - {last}', f'{last} - {grand}')` — склейка
родитель+подпапка недопустима ни в `album`, ни в `albumartist`.
### 26. 🔴🔴 НЕ ОТВЕЧАТЬ ЗА ПОЛЬЗОВАТЕЛЯ
Пользователю задан вопрос → ответа нет → агент пишет «**правлю по выработанному правилу**»
и **применяет правку**. Затем Alex: «**что за ответ по ленинград?**»
Второй раз: агент **сам придумал** значение (`Rammstein - misc`) и предъявил его как
«твоё решение», хотя пользователь его не называл.
**Правило: если вопрос задан и ответа нет — НЕ решать за него. Ждать.**
Формулировки «правлю по выработанному правилу», «по аналогии», «предположу» при отсутствии
ответа — **запрещены**. Придуманное значение, предъявленное как чужое решение, — **хуже
отсутствия ответа**: пользователь теряет возможность заметить подмену.
### 27. 🔴 Порядок отчётности: дождаться → править → проверить → отчитаться
Агент отчитался «Нет, 7 папок ждут правки» **после того как начал править**. Состояние в отчёте
обязано соответствовать моменту произнесения.
### 28. 🔴 Отчёт «готово» с ложным «ничего» — проверять состояние, прежде чем отрицать
На вопрос «что изменено?» агент ответил «ничего», потому что не сделал правку в текущем ходе.
Худший вариант — **«готово» при отсутствии изменений**. Перед словом «готово» обязательна
команда, доказывающая факт изменения (`md5` до/после, счётчик).
## Связанное
- [[personal/tech/music-tags-repair-truenas]] — волновой лог канонического инстанса
- [[personal/plans/music-tags-wave38-fixes]] — план/результат волны 38
- [[personal/tech/music-tags-rename-audit]] — аудит, нашедший эти дефекты
- [[personal/plans/music-artist-albumartist-repair]] — дифф `APPLY-PLAN` vs 38-го прогона