From 2344889418438a8036682a1b43a0994b37ebdcd8 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:49:12 +0300 Subject: [PATCH] document lyrics and analyze commands --- ARCHITECTURE.md | 48 +++++++++++++++++++++++++++++++++++++++++++++- ARCHITECTURE.ru.md | 47 ++++++++++++++++++++++++++++++++++++++++++++- README.md | 34 ++++++++++++++++++++++++++++++-- README.ru.md | 34 ++++++++++++++++++++++++++++++-- 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aeb6ed3..31b5c92 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,6 +21,10 @@ sort_ym/ genres.py — the real Yandex genre tree; classify_track — resolves track genre and artist genres into a single sub-genre (root_id); ROOT_BUCKET — sub-genre -> 11 top-level buckets language.py — hybrid language detection (lyrics API / genre hint / alphabet) + lyrics.py — text lyrics of RU-tracks: track_supplement().lyrics.full_lyrics -> + cache/lyrics_text.json (incrementally, atomic-write) + analyze.py — lyric analysis via local Ollama (/api/chat, think + JSON Schema) -> + cache/lyrics_analysis.json (written after each track) classify.py — (sub-genre | top-level bucket, language) -> playlist name; with_label — source label suffix for apply --source --label report.py — two-pass report row building, writes out/report.csv, summaries @@ -34,12 +38,15 @@ sort_ym/ tests/ test_genres.py — classify_track, dominant, bucket_for, genre tree test_language.py — language detection + test_lyrics.py — lyrics text download, cache structure, resumability + test_analyze.py — local Ollama integration, JSON Schema parsing, cache atomicity test_report.py — two-pass collapsing of small sub-genre groups test_fetch.py, test_apply.py — track/artist cache, apply idempotency test_digest.py — artist/album aggregation, long-tail collapsing, digest size bounds test_source.py — playlist URL parsing, matching client.tracks() response by id, handling private/inaccessible playlists - test_cli.py — --source/--label flag validation before any network access + test_cli.py, test_config.py — --source/--label flag validation before any network access; + config.toml reading including [ollama] block cache/ — API cache (in .gitignore) out/ — reports (in .gitignore) @@ -59,6 +66,9 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json apply --yes → users_playlists_create / users_playlists_insert_track digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network) → build_rows() → out/digest.md +lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json +analyze → cache/lyrics_text.json → Ollama /api/chat → cache/lyrics_analysis.json +digest → + cache/lyrics_analysis.json → out/digest.md (aggregate block) + out/lyrics_digest.md report/apply/digest --source → source.parse_playlist_url() → (user_id, kind) → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) @@ -107,6 +117,36 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, 3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, `unicodedata`). 4. Otherwise — `UNKNOWN` → a separate "Undetermined" playlist. +- **Lyric analysis (`lyrics.py`, `analyze.py`)**: only Russian-language tracks are analyzed. The + reason is signal validity, not cost — on a foreign-language track the meaning arrives through + the music and the vocal delivery, not through parsed words, so an LLM reading the literal text + would add a signal the listener never perceives. Text fetching and analysis are **two separate + commands** on purpose: the fetch is network-bound against the deprecated `track_supplement`, + the analysis is a multi-hour local GPU job, and a prompt or model change must be re-runnable + without touching Yandex again. `analyze` and `digest` construct no `Client` at all. + + The Ollama call (`/api/chat`) combines `think: true` with a full JSON Schema in `format`: + reasoning goes to a separate `message.thinking` field (never cached) while only the final + answer is grammar-constrained, so the model reasons freely and the result still parses. + `num_predict: -1` — a token cap would truncate mid-JSON and turn a slow answer into an invalid + one; the bound is the request timeout instead. `temperature: 0.65`, not 0: at 0 the narrative + fields flatten out. + + `themes` are free-form but pattern-constrained to English snake_case + (`^[a-z][a-z0-9_]{2,29}$`) — in the pilot, an unconstrained field drifted between Russian and + English inside a single run, making the values impossible to aggregate; the pattern makes + Cyrillic mechanically unrepresentable at decode time. They are English for the same reason + genre slugs and `ROOT_BUCKET` keys are: they are aggregation keys. `mood` is a closed enum that + deliberately excludes irony/sarcasm — that axis is `stance`, and mixing the two produced + compound values like `"aggressive/triumphant"`. + + Resumability: the cache is written atomically after **every single track** (not batched by 20 + as in `language.py`) — inference costs tens of seconds, so a crash must cost at most one track. + An entry is reused only when `(model, prompt_version, lyrics_hash)` all match, so a corrected + lyrics text or a bumped prompt invalidates exactly what it should. A timeout or a broken + response is stored as an `error` marker and the batch continues; error entries are never + considered fresh, so the next run retries precisely those tracks. + - **Track identifiers**: a track has a composite `track_id` (`"id:album_id"`, used as the cache key and to check against tracks already in a playlist) and separate `id`/`album_id` (needed as separate parameters for `track_supplement`, `tracks_lyrics`, and `users_playlists_insert_track`). @@ -178,6 +218,12 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, `report --extra`-only purpose as `added_at` above. - `cache/genre_catalog.json` — a flat snapshot of Yandex's genre tree (id -> title, root_id). - `cache/lyrics_lang.json` — lyrics language per track (id -> language code | null). +- `cache/lyrics_text.json` — lyrics text per track (numeric id -> text | null, RU tracks only). +- `cache/lyrics_analysis.json` — per-track analysis record: `{track_id: {model, prompt_version, + lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}`. +- `out/lyrics_digest.md` — full per-track reading grouped by primary mood. +- `config.toml` — includes `[ollama]` block with `host`, `model`, `prompt_version`, `timeout`, + `keep_alive`. - `out/report.csv` — a preview of the playlist breakdown before `apply` (including the `fine_bucket` column — the sub-genre before any collapsing into a top-level bucket). `report --order playlist` keeps source track order instead of sorting by target playlist; diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index f187076..69d910e 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -21,6 +21,10 @@ sort_ym/ genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит) + lyrics.py — тексты песен RU-треков: track_supplement().lyrics.full_lyrics -> + cache/lyrics_text.json (инкрементально, atomic-write) + analyze.py — разбор текстов локальной Ollama (/api/chat, think + JSON Schema) -> + cache/lyrics_analysis.json (запись после каждого трека) classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки источника для apply --source --label report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки @@ -34,13 +38,16 @@ sort_ym/ tests/ test_genres.py — classify_track, dominant, bucket_for, дерево жанров test_language.py — детекция языка + test_lyrics.py — загрузка текстов песен, структура кэша, резюмируемость + test_analyze.py — интеграция локальной Ollama, разбор JSON Schema, атомарность кэша test_report.py — двухпроходное схлопывание мелких под-жанровых групп test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста, границы размера дайджеста test_source.py — разбор ссылки на плейлист, сопоставление ответа client.tracks() по id, обработка приватных/недоступных плейлистов - test_cli.py — валидация флагов --source/--label до обращения к сети + test_cli.py, test_config.py — валидация флагов --source/--label до обращения к сети; + чтение config.toml включая блок [ollama] cache/ — кэш API (в .gitignore) out/ — отчёты (в .gitignore) @@ -60,6 +67,9 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json apply --yes → users_playlists_create / users_playlists_insert_track digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется) → build_rows() → out/digest.md +lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json +analyze → cache/lyrics_text.json → Ollama /api/chat → cache/lyrics_analysis.json +digest → + cache/lyrics_analysis.json → out/digest.md (агрегат) + out/lyrics_digest.md report/apply/digest --source → source.parse_playlist_url() → (user_id, kind) → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) @@ -108,6 +118,35 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, 3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, `unicodedata`). 4. Иначе — `UNKNOWN` → отдельный плейлист «Не определено». +- **Разбор текстов (`lyrics.py`, `analyze.py`)**: разбираются только русскоязычные треки. + Причина — валидность сигнала, не стоимость: на иноязычном треке смысл передаётся музыкой и + подачей вокала, а не разобранными словами, поэтому LLM, читающая буквальный текст, добавит + сигнал, который слушатель при прослушивании не считывает. Загрузка текста и анализ — это + **две отдельные команды** намеренно: загрузка привязана к сети и устаревшему `track_supplement`, + анализ — это многочасовая работа локального GPU, а смена prompt или модели должна быть + переиспользуема без нового обращения к Яндексу. `analyze` и `digest` вообще не создают `Client`. + + Вызов Ollama (`/api/chat`) комбинирует `think: true` с полной JSON Schema в `format`: + рассуждение идёт в отдельное поле `message.thinking` (никогда не кэшируется), а финальный ответ + ограничивается грамматикой, так что модель рассуждает свободно, а результат всё равно парсится. + `num_predict: -1` — ограничение по токенам обрежет JSON и превратит медленный ответ в невалидный; + ограничение вместо этого — таймаут запроса. `temperature: 0.65`, не 0: при 0 нарративные поля + становятся одноообразными. + + `themes` свободного формата, но ограничены паттерном англ. snake_case (`^[a-z][a-z0-9_]{2,29}$`) — + в пилоте неограниченное поле дрейфовало между русским и английским в одном прогоне, что сделало + значения невозможными для агрегации; паттерн делает кириллицу механически невозможной при разборе. + Они англ. по той же причине, что и слаги жанров, и `ROOT_BUCKET`: это ключи агрегации. `mood` — + закрытый enum, намеренно исключающий иронию/сарказм — это ось `stance`, и смешивание двух осей + дало составные значения типа `"aggressive/triumphant"`. + + Резюмируемость: кэш пишется атомарно после **каждого трека** (не батчами по 20, как в + `language.py`) — инференс занимает десятки секунд, так что крах должен стоить максимум один трек. + Запись переиспользуется, только если совпадают `(model, prompt_version, lyrics_hash)`, поэтому + исправленный текст или bumped prompt инвалидирует ровно то, что надо. Таймаут или повреждённый + ответ сохраняются как `error` marker, прогон продолжается; error-записи никогда не считаются + свежими, так что следующий прогон повторит точно эти треки. + - **Идентификаторы треков**: у трека есть composite `track_id` (`"id:album_id"`, используется как ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные `id`/`album_id` (нужны как раздельные параметры для `track_supplement`, `tracks_lyrics` и @@ -177,6 +216,12 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, и `added_at` выше, только для `report --extra`. - `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id). - `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null). +- `cache/lyrics_text.json` — текст песни по треку (numeric id -> текст | null, только RU-треки). +- `cache/lyrics_analysis.json` — запись анализа на трек: `{track_id: {model, prompt_version, + lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}`. +- `out/lyrics_digest.md` — полный построчный разбор, сгруппированный по основному настроению. +- `config.toml` — включает блок `[ollama]` с полями `host`, `model`, `prompt_version`, `timeout`, + `keep_alive`. - `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку `fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist` сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; отдельные флаги diff --git a/README.md b/README.md index 8fae56c..f95c7c4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ For development (tests): .venv\Scripts\python -m sort_ym apply --limit 20 --yes # dry run: create playlists and add the first 20 tracks .venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account) .venv\Scripts\python -m sort_ym digest # library summary (top artists, genres) to out/digest.md - to paste into an LLM chat; no network used +.venv\Scripts\python -m sort_ym lyrics # download the lyrics text of Russian-language tracks (cache/lyrics_text.json); does not change the account +.venv\Scripts\python -m sort_ym analyze # analyze those lyrics with a local Ollama model (cache/lyrics_analysis.json); no internet, runs for hours, resumable ``` On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added. @@ -80,11 +82,13 @@ To start over from scratch (re-fetch everything), delete the `cache/` folder: Remove-Item -Recurse -Force cache ``` -The cache consists of four files: +The cache consists of six files: - `cache/tracks.json` — liked tracks and their metadata (including artist ids); - `cache/artist_genres.json` — genre tags for each artist (an artist can have several); - `cache/genre_catalog.json` — a snapshot of Yandex's genre tree as of the last run; -- `cache/lyrics_lang.json` — detected lyrics language per track. +- `cache/lyrics_lang.json` — detected lyrics language per track; +- `cache/lyrics_text.json` — lyrics text of Russian-language tracks; +- `cache/lyrics_analysis.json` — per-track lyric analysis from the local model. Deleting `cache/` makes `fetch` and `report` re-fetch everything from scratch on the next run. @@ -159,6 +163,32 @@ Digest saved: out/digest.md Example `out/digest.md` (abridged): +## Lyric analysis (Russian tracks) + +`lyrics` downloads the lyrics text, `analyze` reads it with a local model and writes a per-track +profile: mood, themes, emotional arc, point of view, tone, plus a short description of what the +song is about and why it might resonate. + +``` +.venv\Scripts\python -m sort_ym lyrics # lyrics text of Russian-language tracks +.venv\Scripts\python -m sort_ym analyze # analysis via local Ollama, hours of local compute +.venv\Scripts\python -m sort_ym digest # out/lyrics_digest.md + an aggregate block in out/digest.md +``` + +Only Russian-language tracks are analyzed. On a foreign-language track the meaning is carried by +the music and the vocal delivery rather than by parsed words, so a literal reading of the text +would add a signal the listener never actually perceives. + +Requirements: Ollama running locally with the model named in `config.toml` under `[ollama]` +(`ollama list` to check). No internet is used — `analyze` never talks to Yandex. + +Expect roughly 40 seconds per track — about three hours for a ~266-track library. The run is +interruptible: results are written after every single track, so Ctrl+C costs at most one track, +and re-running continues where it stopped. Tracks that timed out are retried on the next run. +Raising `prompt_version` in `config.toml` invalidates the whole cache and re-analyzes everything. + +Example `out/digest.md` (abridged): + ``` ## Genres (top-level buckets) - Indie & Alternative — 680 (34%): INT 571 / RU 108 diff --git a/README.ru.md b/README.ru.md index 8ca916a..1a77ffb 100644 --- a/README.ru.md +++ b/README.ru.md @@ -42,6 +42,8 @@ python -m venv .venv .venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков .venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт) .venv\Scripts\python -m sort_ym digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется +.venv\Scripts\python -m sort_ym lyrics # загрузить тексты песен русскоязычных треков (cache/lyrics_text.json), аккаунт не меняется +.venv\Scripts\python -m sort_ym analyze # разобрать эти тексты локальной моделью Ollama (cache/lyrics_analysis.json), без интернета, идёт часами, продолжается после обрыва ``` При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки. @@ -80,11 +82,13 @@ python -m venv .venv Remove-Item -Recurse -Force cache ``` -Кэш состоит из четырёх файлов: +Кэш состоит из шести файлов: - `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов); - `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста); - `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска; -- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека. +- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека; +- `cache/lyrics_text.json` — тексты песен русскоязычных треков; +- `cache/lyrics_analysis.json` — разбор текста каждого трека локальной моделью. Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске. @@ -160,6 +164,32 @@ $ python -m sort_ym digest Пример `out/digest.md` (сокращённо): +## Разбор текстов (русскоязычные треки) + +`lyrics` загружает текст песни, `analyze` читает его локальной моделью и пишет профиль каждого +трека: настроение, темы, эмоциональную арку, ракурс, тон, плюс короткое описание того, о чём +песня и чем она может цеплять. + +``` +.venv\Scripts\python -m sort_ym lyrics # тексты песен русскоязычных треков +.venv\Scripts\python -m sort_ym analyze # разбор локальной Ollama, часы локальных вычислений +.venv\Scripts\python -m sort_ym digest # out/lyrics_digest.md + агрегат в out/digest.md +``` + +Разбираются только русскоязычные треки. На иноязычном треке смысл передаётся музыкой и подачей +вокала, а не разобранными словами, поэтому буквальное чтение текста добавило бы сигнал, которого +слушатель при прослушивании не считывает. + +Что нужно: запущенная локально Ollama с моделью из `config.toml`, блок `[ollama]` (проверить — +`ollama list`). Интернет не используется: `analyze` не обращается к Яндексу вообще. + +Скорость — примерно 40 секунд на трек, около трёх часов на ~266 треков. Прогон прерываемый: +результат пишется после каждого трека, Ctrl+C стоит максимум один трек, повторный запуск +продолжает с места обрыва. Треки, упавшие по таймауту, повторяются при следующем запуске. +Увеличение `prompt_version` в `config.toml` обесценивает весь кэш и запускает разбор заново. + +Пример `out/digest.md` (сокращённо): + ``` ## Жанры (крупные корзины) - Инди и альтернатива — 680 (34%): INT 571 / RU 108