Merge branch 'worktree-lyrics-analysis'

This commit is contained in:
Andrew Pimenov 2026-08-16 19:58:28 +03:00
commit 12145cc82a
15 changed files with 1405 additions and 8 deletions

View file

@ -21,6 +21,10 @@ sort_ym/
genres.py — the real Yandex genre tree; classify_track — resolves track genre and artist 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 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) 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 classify.py — (sub-genre | top-level bucket, language) -> playlist name; with_label — source
label suffix for apply --source --label label suffix for apply --source --label
report.py — two-pass report row building, writes out/report.csv, summaries report.py — two-pass report row building, writes out/report.csv, summaries
@ -34,12 +38,15 @@ sort_ym/
tests/ tests/
test_genres.py — classify_track, dominant, bucket_for, genre tree test_genres.py — classify_track, dominant, bucket_for, genre tree
test_language.py — language detection 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_report.py — two-pass collapsing of small sub-genre groups
test_fetch.py, test_apply.py — track/artist cache, apply idempotency test_fetch.py, test_apply.py — track/artist cache, apply idempotency
test_digest.py — artist/album aggregation, long-tail collapsing, digest size bounds 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 test_source.py — playlist URL parsing, matching client.tracks() response by id, handling
private/inaccessible playlists 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) cache/ — API cache (in .gitignore)
out/ — reports (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 apply --yes → users_playlists_create / users_playlists_insert_track
digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network) digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network)
→ build_rows() → out/digest.md → 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 <url> → source.parse_playlist_url() → (user_id, kind) report/apply/digest --source <url> → source.parse_playlist_url() → (user_id, kind)
→ client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch)
@ -107,6 +117,37 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, `unicodedata`). 3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, `unicodedata`).
4. Otherwise — `UNKNOWN` → a separate "Undetermined" playlist. 4. Otherwise — `UNKNOWN` → a separate "Undetermined" playlist.
- **Lyric analysis (`lyrics.py`, `analyze.py`)**: by default only Russian-language tracks are
analyzed — the prompt and the narrative fields (`summary`/`resonance`/`key_line`) are written
in Russian and were validated against a Russian-lyrics pilot run; quality on other languages is
unverified. `sort_ym lyrics --all-languages` lifts the filter and fetches/analyzes every track
with available lyrics regardless of detected language (`lyrics.ru_lyric_track_ids(...,
all_languages=True)`). 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 - **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 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`). separate parameters for `track_supplement`, `tracks_lyrics`, and `users_playlists_insert_track`).
@ -178,6 +219,12 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
`report --extra`-only purpose as `added_at` above. `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/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_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 - `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). `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; `report --order playlist` keeps source track order instead of sorting by target playlist;

View file

@ -21,6 +21,10 @@ sort_ym/
genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров
артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин
language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит) 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 — суффикс метки classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки
источника для apply --source --label источника для apply --source --label
report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки
@ -34,13 +38,16 @@ sort_ym/
tests/ tests/
test_genres.py — classify_track, dominant, bucket_for, дерево жанров test_genres.py — classify_track, dominant, bucket_for, дерево жанров
test_language.py — детекция языка test_language.py — детекция языка
test_lyrics.py — загрузка текстов песен, структура кэша, резюмируемость
test_analyze.py — интеграция локальной Ollama, разбор JSON Schema, атомарность кэша
test_report.py — двухпроходное схлопывание мелких под-жанровых групп test_report.py — двухпроходное схлопывание мелких под-жанровых групп
test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения
test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста, test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста,
границы размера дайджеста границы размера дайджеста
test_source.py — разбор ссылки на плейлист, сопоставление ответа client.tracks() по id, 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) cache/ — кэш API (в .gitignore)
out/ — отчёты (в .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 apply --yes → users_playlists_create / users_playlists_insert_track
digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется) digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется)
→ build_rows() → out/digest.md → 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 <url> → source.parse_playlist_url() → (user_id, kind) report/apply/digest --source <url> → source.parse_playlist_url() → (user_id, kind)
→ client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch)
@ -108,6 +118,36 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, `unicodedata`). 3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, `unicodedata`).
4. Иначе — `UNKNOWN` → отдельный плейлист «Не определено». 4. Иначе — `UNKNOWN` → отдельный плейлист «Не определено».
- **Разбор текстов (`lyrics.py`, `analyze.py`)**: по умолчанию разбираются только русскоязычные
треки — промпт и нарративные поля (`summary`/`resonance`/`key_line`) написаны на русском и
проверены на пилотном прогоне по русской лирике; качество на других языках не проверялось.
`sort_ym lyrics --all-languages` снимает фильтр и загружает/разбирает все треки с доступным
текстом независимо от определённого языка (`lyrics.ru_lyric_track_ids(...,
all_languages=True)`). Загрузка текста и анализ — это **две отдельные команды** намеренно: загрузка привязана к сети и устаревшему `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"`, используется как - **Идентификаторы треков**: у трека есть composite `track_id` (`"id:album_id"`, используется как
ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные `id`/`album_id` ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные `id`/`album_id`
(нужны как раздельные параметры для `track_supplement`, `tracks_lyrics` и (нужны как раздельные параметры для `track_supplement`, `tracks_lyrics` и
@ -177,6 +217,12 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
и `added_at` выше, только для `report --extra`. и `added_at` выше, только для `report --extra`.
- `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id). - `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id).
- `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null). - `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` (включая колонку - `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку
`fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist` `fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist`
сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; отдельные флаги сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; отдельные флаги

View file

@ -46,6 +46,8 @@ All commands below use the Windows `.venv\Scripts\...` path — on Linux/macOS s
.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 --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 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 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. On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added.
@ -85,11 +87,13 @@ Remove-Item -Recurse -Force cache # Windows (PowerShell)
rm -rf cache # Linux/macOS rm -rf cache # Linux/macOS
``` ```
The cache consists of four files: The cache consists of six files:
- `cache/tracks.json` — liked tracks and their metadata (including artist ids); - `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/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/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. Deleting `cache/` makes `fetch` and `report` re-fetch everything from scratch on the next run.
@ -166,6 +170,33 @@ Digest saved: out/digest.md
Example `out/digest.md` (abridged): 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
```
By default only Russian-language tracks are analyzed — the prompt and the narrative fields
(`summary`/`resonance`/`key_line`) are written in Russian and were validated on a Russian-lyrics
pilot run; quality on other languages is unverified. Pass `--all-languages` to `lyrics` to fetch
and analyze every track with available lyrics regardless of detected language.
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) ## Genres (top-level buckets)
- Indie & Alternative — 680 (34%): INT 571 / RU 108 - Indie & Alternative — 680 (34%): INT 571 / RU 108

View file

@ -46,6 +46,8 @@ python -m venv .venv
.venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков .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 apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт)
.venv\Scripts\python -m sort_ym digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется .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` не дублирует уже добавленные треки. При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки.
@ -85,11 +87,13 @@ Remove-Item -Recurse -Force cache # Windows (PowerShell)
rm -rf cache # Linux/macOS rm -rf cache # Linux/macOS
``` ```
Кэш состоит из четырёх файлов: Кэш состоит из шести файлов:
- `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов); - `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов);
- `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста); - `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста);
- `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска; - `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска;
- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека. - `cache/lyrics_lang.json` — определённые языки текстов для каждого трека;
- `cache/lyrics_text.json` — тексты песен русскоязычных треков;
- `cache/lyrics_analysis.json` — разбор текста каждого трека локальной моделью.
Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске. Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске.
@ -167,6 +171,33 @@ $ python -m sort_ym digest
Пример `out/digest.md` (сокращённо): Пример `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
```
По умолчанию разбираются только русскоязычные треки — промпт и нарративные поля
(`summary`/`resonance`/`key_line`) написаны на русском и проверены на пилотном прогоне по
русской лирике; качество на других языках не проверялось. Флаг `--all-languages` у `lyrics`
снимает фильтр и загружает/разбирает все треки с доступным текстом независимо от языка.
Что нужно: запущенная локально Ollama с моделью из `config.toml`, блок `[ollama]` (проверить —
`ollama list`). Интернет не используется: `analyze` не обращается к Яндексу вообще.
Скорость — примерно 40 секунд на трек, около трёх часов на ~266 треков. Прогон прерываемый:
результат пишется после каждого трека, Ctrl+C стоит максимум один трек, повторный запуск
продолжает с места обрыва. Треки, упавшие по таймауту, повторяются при следующем запуске.
Увеличение `prompt_version` в `config.toml` обесценивает весь кэш и запускает разбор заново.
Пример `out/digest.md` (сокращённо):
``` ```
## Жанры (крупные корзины) ## Жанры (крупные корзины)
- Инди и альтернатива — 680 (34%): INT 571 / RU 108 - Инди и альтернатива — 680 (34%): INT 571 / RU 108

View file

@ -22,6 +22,18 @@ top_artists = 40
# Сколько альбомов показывать поимённо; остальные - в одну строку с метрикой "альбомы vs синглы". # Сколько альбомов показывать поимённо; остальные - в одну строку с метрикой "альбомы vs синглы".
top_albums = 15 top_albums = 15
[ollama]
# Локальная Ollama для разбора текстов песен (sort_ym analyze).
host = "http://localhost:11434"
model = "qwen3.6-35b-a3b:latest"
# Любое изменение промпта или схемы ответа => +1. Старые записи кэша станут невалидными
# и будут переанализированы, а это ~3 часа на полную библиотеку - не менять просто так.
prompt_version = 1
# Таймаут одного запроса, секунды. В пилоте худший трек занял 84с, 600 - запас ~7x.
timeout = 600
# Сколько Ollama держит модель загруженной между треками (иначе перезагрузка на каждый трек).
keep_alive = "30m"
[paths] [paths]
token_file = ".token" token_file = ".token"
cache_dir = "cache" cache_dir = "cache"

269
sort_ym/analyze.py Normal file
View file

@ -0,0 +1,269 @@
from __future__ import annotations
import hashlib
import json
import urllib.request
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Callable
ANALYSIS_CACHE_FILE = "lyrics_analysis.json"
# Настроение - закрытый список: структурная часть схемы существует ради будущей
# фильтрации/агрегации ("плейлист по настроению"), а свободная строка в пилоте схлопывалась
# в составные значения вроде "aggressive/triumphant". Ирония/сарказм сюда намеренно не входят -
# это ось stance, смешение осей и порождало составные значения.
MOODS = [
"melancholy",
"longing",
"nostalgia",
"tenderness",
"sensuality",
"euphoria",
"serenity",
"playfulness",
"defiance",
"rage",
"anxiety",
"despair",
"grief",
"resolve",
]
EMOTIONAL_ARCS = ["static", "descent", "uplift", "turn"]
POVS = ["confessional", "narrative", "abstract", "dialogue"]
REGISTERS = ["poetic", "conversational", "slang", "archaic"]
CONCRETENESS = ["concrete", "mixed", "abstract"]
STANCES = ["sincere", "ironic", "bitter"]
# Темы не закрытый enum (специфичность вроде digital_aging ценна), но язык и формат
# фиксируются схемой: в пилоте без паттерна язык плавал между английским и русским внутри
# одного прогона. Паттерн на уровне JSON Schema делает кириллицу и составные значения
# механически невозможными на этапе генерации, а не отлавливаемыми постфактум.
THEME_PATTERN = "^[a-z][a-z0-9_]{2,29}$"
def _enum(values: list[str]) -> dict:
return {"type": "string", "enum": values}
RESPONSE_SCHEMA: dict = {
"type": "object",
"properties": {
"mood": {
"type": "array",
"items": _enum(MOODS),
"minItems": 1,
"maxItems": 3,
"uniqueItems": True,
},
"themes": {
"type": "array",
"items": {"type": "string", "pattern": THEME_PATTERN},
"minItems": 2,
"maxItems": 5,
},
"emotional_arc": _enum(EMOTIONAL_ARCS),
"pov": _enum(POVS),
"register": _enum(REGISTERS),
"concreteness": _enum(CONCRETENESS),
"stance": _enum(STANCES),
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
"summary": {"type": "string"},
"resonance": {"type": "string"},
"key_line": {"type": "string"},
},
"required": [
"mood",
"themes",
"emotional_arc",
"pov",
"register",
"concreteness",
"stance",
"confidence",
"summary",
"resonance",
"key_line",
],
"additionalProperties": False,
}
def lyrics_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _normalized(text: str) -> str:
return " ".join(text.split()).casefold()
def verify_key_line(key_line: str, lyrics: str) -> bool:
"""Цитата ли key_line на самом деле - модель иногда пересказывает вместо цитирования."""
if not key_line.strip():
return False
return _normalized(key_line) in _normalized(lyrics)
def is_fresh(entry: dict | None, model: str, prompt_version: int, text_hash: str) -> bool:
if not entry or "error" in entry:
return False
return (
entry.get("model") == model
and entry.get("prompt_version") == prompt_version
and entry.get("lyrics_hash") == text_hash
)
def top_themes(cache: dict[str, dict], limit: int = 60) -> list[str]:
"""Самые частые уже присвоенные темы - подмешиваются в промпт, чтобы словарь тем
сходился по всей библиотеке, а не расползался в синонимы."""
counter: Counter[str] = Counter()
for entry in cache.values():
counter.update(entry.get("themes", []))
return [theme for theme, _ in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]]
def load_analysis(cache_dir: Path) -> dict[str, dict]:
cache_file = cache_dir / ANALYSIS_CACHE_FILE
if cache_file.exists():
return json.loads(cache_file.read_text(encoding="utf-8"))
return {}
@dataclass(frozen=True)
class OllamaSettings:
host: str
model: str
prompt_version: int
timeout: float
keep_alive: str
PROMPT_TEMPLATE = """Ты разбираешь текст песни для профиля музыкального вкуса одного слушателя.
Трек: «{title}» — {artists}
Текст:
\"\"\"
{lyrics}
\"\"\"
{themes_hint}
Заполни поля схемы. Требования:
- summary: 2-4 предложения по-русски о том, что в песне происходит и как это подано.
- resonance: 1-2 предложения по-русски о том, чем этот текст может цеплять слушателя.
- key_line: одна строка ДОСЛОВНО из текста выше, без изменений и без пересказа.
- themes: 2-5 тем на английском в snake_case (1-3 слова, единственное число).
- Остальные поля выбирай строго из допустимых значений схемы.
"""
THEMES_HINT_TEMPLATE = """
Уже использованные темы в этой библиотеке (переиспользуй подходящую, иначе придумай новую):
{themes}
"""
def build_prompt(title: str, artists: list[str], lyrics: str, known_themes: list[str]) -> str:
themes_hint = THEMES_HINT_TEMPLATE.format(themes=", ".join(known_themes)) if known_themes else ""
return PROMPT_TEMPLATE.format(
title=title,
artists=", ".join(artists),
lyrics=lyrics,
themes_hint=themes_hint,
)
def call_ollama(settings: OllamaSettings, prompt: str) -> dict:
"""think=True уводит рассуждение в отдельное поле message.thinking, а format (полная
JSON Schema, не строка "json") констрейнит только финальный ответ - модель думает
свободно, результат при этом гарантированно парсится. num_predict=-1: капать вывод
нельзя, обрыв на середине даст невалидный JSON вместо просто короткого ответа."""
payload = {
"model": settings.model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"think": True,
"format": RESPONSE_SCHEMA,
"keep_alive": settings.keep_alive,
"options": {"num_predict": -1, "temperature": 0.65},
}
request = urllib.request.Request(
f"{settings.host.rstrip('/')}/api/chat",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=settings.timeout) as response:
body = json.loads(response.read().decode("utf-8"))
return json.loads(body["message"]["content"])
def _atomic_write_json(path: Path, data: dict) -> None:
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def _track_meta(tracks_cache: dict[str, dict]) -> dict[str, dict]:
"""Числовой id -> название и исполнители. Ключ tracks_cache составной ("id:album_id"),
а кэши текста и разбора живут на числовом id, как и cache/lyrics_lang.json."""
return {str(t["id"]): {"title": t["title"], "artists": t["artists"]} for t in tracks_cache.values()}
def analyze_tracks(
tracks_cache: dict[str, dict],
lyrics_cache: dict[str, str | None],
cache_dir: Path,
settings: OllamaSettings,
limit: int | None = None,
call: Callable[[OllamaSettings, str], dict] = call_ollama,
) -> dict[str, dict]:
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / ANALYSIS_CACHE_FILE
cache = load_analysis(cache_dir)
meta = _track_meta(tracks_cache)
pending = []
for tid, text in lyrics_cache.items():
if not text:
continue
text_hash = lyrics_hash(text)
if is_fresh(cache.get(tid), settings.model, settings.prompt_version, text_hash):
continue
pending.append((tid, text, text_hash))
if limit is not None:
pending = pending[:limit]
if not pending:
print("Все тексты уже разобраны.")
return cache
print(f"Разбор текстов: {len(pending)} треков, модель {settings.model}")
failed = 0
for i, (tid, text, text_hash) in enumerate(pending, 1):
info = meta.get(tid, {"title": tid, "artists": []})
prompt = build_prompt(info["title"], info["artists"], text, top_themes(cache))
stamp = {
"model": settings.model,
"prompt_version": settings.prompt_version,
"lyrics_hash": text_hash,
"analyzed_at": datetime.now().isoformat(timespec="seconds"),
}
try:
data = call(settings, prompt)
# Широкий except намеренно: трёхчасовой прогон не должен падать целиком из-за
# одного зависшего/битого ответа локальной модели. KeyboardInterrupt и SystemExit
# наследуются от BaseException и сюда не попадают - Ctrl+C прерывает прогон.
except Exception as e:
cache[tid] = {**stamp, "error": f"{type(e).__name__}: {e}"}
failed += 1
print(f" [{i}/{len(pending)}] {info['title']} - ошибка: {type(e).__name__}")
else:
cache[tid] = {**data, **stamp, "key_line_verified": verify_key_line(data["key_line"], text)}
print(f" [{i}/{len(pending)}] {info['title']} - {', '.join(data['mood'])}")
_atomic_write_json(cache_file, cache)
if failed:
print(f" не разобрано: {failed} - повторный запуск analyze попробует их снова")
return cache

View file

@ -7,7 +7,7 @@ from yandex_music import Client
from yandex_music.exceptions import UnauthorizedError from yandex_music.exceptions import UnauthorizedError
from . import apply as apply_mod from . import apply as apply_mod
from . import auth, classify, digest, fetch, genres, language, report, source from . import analyze, auth, classify, digest, fetch, genres, language, lyrics, report, source
from .config import Config, load_config from .config import Config, load_config
from .ymclient import make_client from .ymclient import make_client
@ -63,6 +63,54 @@ def cmd_fetch(args: argparse.Namespace) -> None:
fetch.fetch_artist_genres(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay) fetch.fetch_artist_genres(client, cfg.cache_dir, cfg.batch_size, cfg.fetch_batch_delay)
def cmd_lyrics(args: argparse.Namespace) -> None:
cfg = load_config()
client = make_client(auth.get_token(cfg.token_file), cfg.request_timeout)
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
if not tracks_cache:
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
lang_cache: dict[str, str | None] = {}
if not args.all_languages:
# Тот же вызов, что в cmd_report: инкрементальный, на заполненном кэше почти no-op,
# но без него RU-гейт опирался бы только на алфавитную эвристику.
ids_needing_lang = [str(t["id"]) for t in tracks_cache.values() if t["lyrics_available"]]
lang_cache = language.fetch_api_languages(client, ids_needing_lang, cfg.cache_dir, cfg.lyrics_request_delay)
ids = lyrics.ru_lyric_track_ids(tracks_cache, lang_cache, all_languages=args.all_languages)
if args.limit is not None:
ids = ids[: args.limit]
label = "треков" if args.all_languages else "RU-треков"
print(f"{label} с текстом: {len(ids)}")
lyrics.fetch_lyrics_text(client, ids, cfg.cache_dir, cfg.lyrics_request_delay)
def cmd_analyze(args: argparse.Namespace) -> None:
# Ни одной сетевой ручки Яндекса: analyze работает только по кэшам плюс локальная Ollama.
cfg = load_config()
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
if not tracks_cache:
raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch")
lyrics_cache = lyrics.load_lyrics_cache(cfg.cache_dir)
if not lyrics_cache:
raise SystemExit("Нет текстов песен. Сначала запустите: python -m sort_ym lyrics")
settings = analyze.OllamaSettings(
host=cfg.ollama_host,
model=args.model or cfg.ollama_model,
prompt_version=cfg.ollama_prompt_version,
timeout=cfg.ollama_timeout,
keep_alive=cfg.ollama_keep_alive,
)
result = analyze.analyze_tracks(tracks_cache, lyrics_cache, cfg.cache_dir, settings, limit=args.limit)
print(f"\nРазобрано треков в кэше: {len(digest.analyzed_entries(result))}")
print("Дайджест разбора соберётся при следующем: python -m sort_ym digest")
def _print_report_summary(rows: list[dict], out_file) -> None: def _print_report_summary(rows: list[dict], out_file) -> None:
print("\nРаспределение по плейлистам:") print("\nРаспределение по плейлистам:")
for name, n in sorted(report.summarize(rows).items()): for name, n in sorted(report.summarize(rows).items()):
@ -169,6 +217,7 @@ def cmd_digest(args: argparse.Namespace) -> None:
tracks_cache, artist_genres, catalog, lang_cache = ctx.tracks, ctx.artist_genres, ctx.catalog, ctx.lang_cache tracks_cache, artist_genres, catalog, lang_cache = ctx.tracks, ctx.artist_genres, ctx.catalog, ctx.lang_cache
wording = digest.playlist_wording(ctx.info.title, ctx.info.owner, ctx.info.url) wording = digest.playlist_wording(ctx.info.title, ctx.info.owner, ctx.info.url)
out_name = digest.DIGEST_SOURCE_FILE out_name = digest.DIGEST_SOURCE_FILE
analysis = {}
else: else:
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir) tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
if not tracks_cache: if not tracks_cache:
@ -181,14 +230,20 @@ def cmd_digest(args: argparse.Namespace) -> None:
artist_genres = fetch.load_artist_genres(cfg.cache_dir) artist_genres = fetch.load_artist_genres(cfg.cache_dir)
lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети
wording, out_name = digest.DEFAULT_WORDING, digest.DIGEST_FILE wording, out_name = digest.DEFAULT_WORDING, digest.DIGEST_FILE
analysis = analyze.load_analysis(cfg.cache_dir) # только кэш, без сети и без Ollama
rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min) rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min)
top = args.top if args.top is not None else cfg.digest_top_artists top = args.top if args.top is not None else cfg.digest_top_artists
top_albums = args.top_albums if args.top_albums is not None else cfg.digest_top_albums top_albums = args.top_albums if args.top_albums is not None else cfg.digest_top_albums
text = digest.render_digest(rows, tracks_cache, artist_genres, top, top_albums, wording=wording) text = digest.render_digest(rows, tracks_cache, artist_genres, top, top_albums, wording=wording, analysis=analysis)
out_file = digest.write_digest(text, cfg.out_dir, out_name) out_file = digest.write_digest(text, cfg.out_dir, out_name)
if analysis:
lyrics_text = digest.render_lyrics_digest(tracks_cache, analysis)
lyrics_file = digest.write_digest(lyrics_text, cfg.out_dir, digest.LYRICS_DIGEST_FILE)
print(f"Разбор текстов сохранён: {lyrics_file}")
print("\nЖанры:") print("\nЖанры:")
for g in digest.genre_stats(rows): for g in digest.genre_stats(rows):
print(f" {classify.BUCKET_LABELS.get(g['bucket'], g['bucket'])}: {g['tracks']}") print(f" {classify.BUCKET_LABELS.get(g['bucket'], g['bucket'])}: {g['tracks']}")
@ -209,6 +264,22 @@ def main(argv: list[str] | None = None) -> None:
sub.add_parser("auth", help="войти через device-flow и сохранить токен") sub.add_parser("auth", help="войти через device-flow и сохранить токен")
sub.add_parser("fetch", help="загрузить лайкнутые треки в локальный кэш") sub.add_parser("fetch", help="загрузить лайкнутые треки в локальный кэш")
p_lyrics = sub.add_parser("lyrics", help="загрузить тексты песен RU-треков в cache/lyrics_text.json")
p_lyrics.add_argument("--limit", type=int, default=None, help="ограничить количество треков (для пробного запуска)")
p_lyrics.add_argument(
"--all-languages",
action="store_true",
help="не фильтровать по языку - загрузить тексты всех треков с лирикой, а не только RU",
)
p_analyze = sub.add_parser(
"analyze",
help="разобрать тексты песен локальной моделью через Ollama (cache/lyrics_analysis.json); "
"долгий локальный прогон, прерывается и продолжается без потери прогресса",
)
p_analyze.add_argument("--limit", type=int, default=None, help="ограничить количество треков (для пробного запуска)")
p_analyze.add_argument("--model", default=None, help="переопределить модель Ollama из config.toml")
p_report = sub.add_parser("report", help="посчитать распределение по плейлистам и сохранить out/report.csv (ничего не меняет в аккаунте)") p_report = sub.add_parser("report", help="посчитать распределение по плейлистам и сохранить out/report.csv (ничего не меняет в аккаунте)")
p_report.add_argument("--source", metavar="URL", default=None, help=SOURCE_HELP) p_report.add_argument("--source", metavar="URL", default=None, help=SOURCE_HELP)
p_report.add_argument( p_report.add_argument(
@ -248,6 +319,8 @@ def main(argv: list[str] | None = None) -> None:
commands = { commands = {
"auth": cmd_auth, "auth": cmd_auth,
"fetch": cmd_fetch, "fetch": cmd_fetch,
"lyrics": cmd_lyrics,
"analyze": cmd_analyze,
"report": cmd_report, "report": cmd_report,
"apply": cmd_apply, "apply": cmd_apply,
"dedupe": cmd_dedupe, "dedupe": cmd_dedupe,

View file

@ -17,6 +17,11 @@ class Config:
small_group_min: int small_group_min: int
digest_top_artists: int digest_top_artists: int
digest_top_albums: int digest_top_albums: int
ollama_host: str
ollama_model: str
ollama_prompt_version: int
ollama_timeout: float
ollama_keep_alive: str
token_file: Path token_file: Path
cache_dir: Path cache_dir: Path
out_dir: Path out_dir: Path
@ -36,6 +41,11 @@ def load_config(path: Path | None = None) -> Config:
small_group_min=data["classify"]["small_group_min"], small_group_min=data["classify"]["small_group_min"],
digest_top_artists=data["digest"]["top_artists"], digest_top_artists=data["digest"]["top_artists"],
digest_top_albums=data["digest"]["top_albums"], digest_top_albums=data["digest"]["top_albums"],
ollama_host=data["ollama"]["host"],
ollama_model=data["ollama"]["model"],
ollama_prompt_version=data["ollama"]["prompt_version"],
ollama_timeout=data["ollama"]["timeout"],
ollama_keep_alive=data["ollama"]["keep_alive"],
token_file=ROOT / data["paths"]["token_file"], token_file=ROOT / data["paths"]["token_file"],
cache_dir=ROOT / data["paths"]["cache_dir"], cache_dir=ROOT / data["paths"]["cache_dir"],
out_dir=ROOT / data["paths"]["out_dir"], out_dir=ROOT / data["paths"]["out_dir"],

View file

@ -9,11 +9,14 @@ from . import classify, genres, report
DIGEST_FILE = "digest.md" DIGEST_FILE = "digest.md"
DIGEST_SOURCE_FILE = "digest_source.md" DIGEST_SOURCE_FILE = "digest_source.md"
LYRICS_DIGEST_FILE = "lyrics_digest.md"
DECADE_UNKNOWN = "unknown" DECADE_UNKNOWN = "unknown"
DECADE_MIN_YEAR = 1900 DECADE_MIN_YEAR = 1900
DECADE_MAX_YEAR = 2100 DECADE_MAX_YEAR = 2100
DECADE_TOP_GENRES = 3 # константа вёрстки (сколько жанров показывать на десятилетие), не config-knob DECADE_TOP_GENRES = 3 # константа вёрстки (сколько жанров показывать на десятилетие), не config-knob
LYRICS_TOP_MOODS = 6
LYRICS_TOP_THEMES = 8
@dataclass(frozen=True) @dataclass(frozen=True)
@ -257,6 +260,60 @@ def _pct(n: int, total: int) -> int:
return round(100 * n / total) if total else 0 return round(100 * n / total) if total else 0
def analyzed_entries(analysis: dict[str, dict]) -> list[dict]:
"""Только успешные записи - таймауты и битые ответы хранятся в том же кэше с полем error."""
return [e for e in analysis.values() if "error" not in e]
def _counted(counter: Counter, key: str, limit: int | None = None) -> list[dict]:
items = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
if limit is not None:
items = items[:limit]
return [{key: name, "tracks": n} for name, n in items]
def mood_stats(analysis: dict[str, dict]) -> list[dict]:
counter: Counter[str] = Counter()
for entry in analyzed_entries(analysis):
counter.update(entry.get("mood", []))
return _counted(counter, "mood")
def theme_stats(analysis: dict[str, dict], limit: int) -> list[dict]:
counter: Counter[str] = Counter()
for entry in analyzed_entries(analysis):
counter.update(entry.get("themes", []))
return _counted(counter, "theme", limit)
def lyrics_block(analysis: dict[str, dict]) -> list[str]:
"""Компактный агрегат разбора лирики для digest.md.
Сам разбор (266 нарративных блоков) живёт в out/lyrics_digest.md - digest.md существует
ради фиксированного потолка ~150 строк, и класть их сюда нельзя.
"""
entries = analyzed_entries(analysis)
if not entries:
return []
moods = ", ".join(f"{m['mood']} {m['tracks']}" for m in mood_stats(analysis)[:LYRICS_TOP_MOODS])
themes = ", ".join(f"{t['theme']} {t['tracks']}" for t in theme_stats(analysis, LYRICS_TOP_THEMES))
stances = Counter(e["stance"] for e in entries if "stance" in e)
povs = Counter(e["pov"] for e in entries if "pov" in e)
stance_part = " / ".join(f"{name} {n}" for name, n in sorted(stances.items(), key=lambda kv: (-kv[1], kv[0])))
pov_part = " / ".join(f"{name} {n}" for name, n in sorted(povs.items(), key=lambda kv: (-kv[1], kv[0])))
return [
"## Лирика (RU-подмножество)",
f"Разбор текстов {len(entries)} RU-треков локальной моделью; подробнее — out/{LYRICS_DIGEST_FILE}.",
f"- Настроения: {moods}",
f"- Темы: {themes}",
f"- Тон: {stance_part}",
f"- Ракурс: {pov_part}",
"",
]
def render_digest( def render_digest(
rows: list[dict], rows: list[dict],
tracks_cache: dict[str, dict], tracks_cache: dict[str, dict],
@ -264,6 +321,7 @@ def render_digest(
top_artists: int, top_artists: int,
top_albums: int, top_albums: int,
wording: Wording = DEFAULT_WORDING, wording: Wording = DEFAULT_WORDING,
analysis: dict[str, dict] | None = None,
) -> str: ) -> str:
total = len(rows) total = len(rows)
unique_artists = {name for t in tracks_cache.values() for name in t["artists"]} unique_artists = {name for t in tracks_cache.values() for name in t["artists"]}
@ -320,6 +378,8 @@ def render_digest(
lines.append(f"- {label}: {n} ({_pct(n, total)}%)") lines.append(f"- {label}: {n} ({_pct(n, total)}%)")
lines.append("") lines.append("")
lines.extend(lyrics_block(analysis or {}))
lines.append("## Десятилетия") lines.append("## Десятилетия")
lines.append("В скобках — доля от библиотеки; далее — три самые частые крупные корзины десятилетия.") lines.append("В скобках — доля от библиотеки; далее — три самые частые крупные корзины десятилетия.")
for d in decade_rows: for d in decade_rows:
@ -379,6 +439,56 @@ def render_digest(
return "\n".join(lines).rstrip() + "\n" return "\n".join(lines).rstrip() + "\n"
def _lyrics_track_names(tracks_cache: dict[str, dict]) -> dict[str, str]:
names: dict[str, str] = {}
for t in tracks_cache.values():
names[str(t["id"])] = f"{', '.join(t['artists'])} — {t['title']}"
return names
def render_lyrics_digest(tracks_cache: dict[str, dict], analysis: dict[str, dict]) -> str:
names = _lyrics_track_names(tracks_cache)
entries = [(tid, e) for tid, e in analysis.items() if "error" not in e]
failed = len(analysis) - len(analyzed_entries(analysis))
lines = [
"# Разбор текстов (RU-треки)",
"",
f"Разобрано треков: {len(entries)}. Группировка по основному настроению; "
"цитата приводится только если сверена с текстом дословно.",
"",
]
by_mood: dict[str, list[tuple[str, dict]]] = {}
for tid, entry in entries:
by_mood.setdefault(entry["mood"][0], []).append((tid, entry))
for mood, items in sorted(by_mood.items(), key=lambda kv: (-len(kv[1]), kv[0])):
lines.append(f"## {mood} ({len(items)})")
lines.append("")
for tid, entry in sorted(items, key=lambda kv: names.get(kv[0], kv[0])):
lines.append(f"### {names.get(tid, f'трек {tid}')}")
lines.append(
f"- Настроение: {', '.join(entry['mood'])}; арка: {entry['emotional_arc']}; "
f"ракурс: {entry['pov']}; тон: {entry['stance']}; речь: {entry['register']}; "
f"конкретность: {entry['concreteness']}"
)
lines.append(f"- Темы: {', '.join(entry['themes'])}")
if entry.get("key_line_verified"):
lines.append(f"- Цитата: «{entry['key_line']}»")
lines.append("")
lines.append(entry["summary"])
lines.append("")
lines.append(f"Чем может цеплять: {entry['resonance']}")
lines.append("")
if failed:
lines.append(f"Не разобрано: {failed} (таймаут или сбой модели, повторный `analyze` попробует снова).")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path: def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path:
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / filename out_file = out_dir / filename

95
sort_ym/lyrics.py Normal file
View file

@ -0,0 +1,95 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from yandex_music import Client
from yandex_music.exceptions import NotFoundError
from . import language
from .ymclient import with_retries
LYRICS_TEXT_CACHE_FILE = "lyrics_text.json"
def ru_lyric_track_ids(
tracks_cache: dict[str, dict],
lang_cache: dict[str, str | None],
all_languages: bool = False,
) -> list[str]:
"""Числовые id треков, у которых вообще есть текст.
По умолчанию - только RU: на незнакомом языке текст песни несёт для языковой модели меньше
сигнала, чем разбор трека, который слушатель понимает дословно. all_languages=True снимает
языковой фильтр, оставляя только lyrics_available (иначе track_supplement заведомо вернёт
запись без lyrics). Дедупликация нужна потому, что один и тот же трек может быть лайкнут с
разных альбомов: ключи tracks_cache при этом разные, а числовой id (и, соответственно, ключ
кэша текстов) - один.
"""
ids: list[str] = []
seen: set[str] = set()
for t in tracks_cache.values():
if not t.get("lyrics_available"):
continue
tid = str(t["id"])
if tid in seen:
continue
if not all_languages:
lang = language.detect_language(t["title"], t["artists"], t["genre_raw"], lang_cache.get(tid))
if lang != "RU":
continue
seen.add(tid)
ids.append(tid)
return ids
def _atomic_write_json(path: Path, data: dict) -> None:
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def load_lyrics_cache(cache_dir: Path) -> dict[str, str | None]:
cache_file = cache_dir / LYRICS_TEXT_CACHE_FILE
if cache_file.exists():
return json.loads(cache_file.read_text(encoding="utf-8"))
return {}
def _fetch_one_lyrics(client: Client, track_id: str) -> str | None:
try:
supplement = with_retries(lambda: client.track_supplement(track_id))
except NotFoundError:
return None
if supplement is None or supplement.lyrics is None:
return None
return supplement.lyrics.full_lyrics
def fetch_lyrics_text(
client: Client,
track_ids: list[str],
cache_dir: Path,
delay: float,
) -> dict[str, str | None]:
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / LYRICS_TEXT_CACHE_FILE
cache = load_lyrics_cache(cache_dir)
missing = [tid for tid in track_ids if tid not in cache]
if not missing:
return cache
print(f"Запрос текста песни для {len(missing)} треков...")
for i, tid in enumerate(missing, 1):
cache[tid] = _fetch_one_lyrics(client, tid)
if i % 20 == 0:
_atomic_write_json(cache_file, cache)
print(f" загружено {i}/{len(missing)}")
time.sleep(delay)
_atomic_write_json(cache_file, cache)
without_text = sum(1 for tid in track_ids if cache.get(tid) is None)
print(f" готово: {len(track_ids)} треков, без текста {without_text}")
return cache

325
tests/test_analyze.py Normal file
View file

@ -0,0 +1,325 @@
import json
import re
from pathlib import Path
from sort_ym import analyze
def test_theme_pattern_accepts_english_snake_case():
assert re.match(analyze.THEME_PATTERN, "digital_aging")
assert re.match(analyze.THEME_PATTERN, "modern_loneliness")
def test_theme_pattern_rejects_pilot_failure_modes():
# Пилот без паттерна давал кириллицу и составные значения через слэш вперемешку
# с английским в одном прогоне - агрегировать такое невозможно.
assert not re.match(analyze.THEME_PATTERN, "одиночество")
assert not re.match(analyze.THEME_PATTERN, "эмоциональное выгорание")
assert not re.match(analyze.THEME_PATTERN, "Digital_Aging")
assert not re.match(analyze.THEME_PATTERN, "ethereal/hopeful")
assert not re.match(analyze.THEME_PATTERN, "ab")
def test_mood_enum_excludes_stance_axis():
# Смешение осей в пилоте давало "aggressive/triumphant"; ирония/сарказм живут в stance.
assert "ironic" not in analyze.MOODS
assert "sarcastic" not in analyze.MOODS
assert "sincere" not in analyze.MOODS
assert len(analyze.MOODS) == len(set(analyze.MOODS))
def test_response_schema_constrains_mood_and_themes():
props = analyze.RESPONSE_SCHEMA["properties"]
assert props["mood"]["items"]["enum"] == analyze.MOODS
assert props["mood"]["maxItems"] == 3
assert props["themes"]["items"]["pattern"] == analyze.THEME_PATTERN
assert set(analyze.RESPONSE_SCHEMA["required"]) == set(props)
def test_lyrics_hash_changes_with_text():
assert analyze.lyrics_hash("текст") == analyze.lyrics_hash("текст")
assert analyze.lyrics_hash("текст") != analyze.lyrics_hash("другой текст")
def test_verify_key_line_accepts_verbatim_quote_ignoring_whitespace_and_case():
text = "Первая строка\nА я иду, шагаю по Москве\nТретья строка"
assert analyze.verify_key_line("А я иду, шагаю по Москве", text)
assert analyze.verify_key_line("а я иду, шагаю по москве", text)
def test_verify_key_line_rejects_paraphrase_and_empty():
text = "А я иду, шагаю по Москве"
assert not analyze.verify_key_line("Герой идёт по городу", text)
assert not analyze.verify_key_line("", text)
def test_is_fresh_requires_model_prompt_version_and_hash_match():
entry = {"model": "m1", "prompt_version": 1, "lyrics_hash": "h1"}
assert analyze.is_fresh(entry, "m1", 1, "h1")
assert not analyze.is_fresh(entry, "m2", 1, "h1")
assert not analyze.is_fresh(entry, "m1", 2, "h1")
assert not analyze.is_fresh(entry, "m1", 1, "h2")
assert not analyze.is_fresh(None, "m1", 1, "h1")
def test_is_fresh_false_for_error_marker():
# Ошибочные записи перезапрашиваются при следующем прогоне - это и есть механизм
# "повторить упавшие треки" вместо отдельного флага.
entry = {"model": "m1", "prompt_version": 1, "lyrics_hash": "h1", "error": "TimeoutError: ..."}
assert not analyze.is_fresh(entry, "m1", 1, "h1")
def test_top_themes_orders_by_frequency_and_limits():
cache = {
"1": {"themes": ["loneliness", "city"]},
"2": {"themes": ["loneliness", "memory"]},
"3": {"themes": ["loneliness", "city"]},
"4": {"error": "TimeoutError: ..."},
}
assert analyze.top_themes(cache, limit=2) == ["loneliness", "city"]
def test_load_analysis_returns_empty_dict_when_missing(tmp_path: Path):
assert analyze.load_analysis(tmp_path) == {}
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def read(self):
return json.dumps(self._payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _settings(**overrides):
base = dict(
host="http://localhost:11434",
model="qwen3.6-35b-a3b:latest",
prompt_version=1,
timeout=600,
keep_alive="30m",
)
base.update(overrides)
return analyze.OllamaSettings(**base)
def test_call_ollama_sends_schema_constrained_thinking_request(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
captured["timeout"] = timeout
captured["body"] = json.loads(req.data.decode("utf-8"))
return FakeResponse(
{"message": {"thinking": "рассуждение", "content": json.dumps({"mood": ["grief"]})}}
)
monkeypatch.setattr(analyze.urllib.request, "urlopen", fake_urlopen)
result = analyze.call_ollama(_settings(), "промпт")
assert captured["url"] == "http://localhost:11434/api/chat"
assert captured["timeout"] == 600
body = captured["body"]
assert body["model"] == "qwen3.6-35b-a3b:latest"
assert body["stream"] is False
assert body["think"] is True
assert body["format"] == analyze.RESPONSE_SCHEMA
assert body["keep_alive"] == "30m"
assert body["options"] == {"num_predict": -1, "temperature": 0.65}
assert body["messages"] == [{"role": "user", "content": "промпт"}]
assert result == {"mood": ["grief"]}, "в кэш идёт только content, не thinking"
def test_call_ollama_strips_trailing_slash_from_host(monkeypatch):
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
return FakeResponse({"message": {"content": "{}"}})
monkeypatch.setattr(analyze.urllib.request, "urlopen", fake_urlopen)
analyze.call_ollama(_settings(host="http://localhost:11434/"), "промпт")
assert captured["url"] == "http://localhost:11434/api/chat"
def test_build_prompt_includes_lyrics_title_and_known_themes():
prompt = analyze.build_prompt("Тоска", ["Артист"], "Текст песни", ["loneliness", "city"])
assert "Тоска" in prompt
assert "Артист" in prompt
assert "Текст песни" in prompt
assert "loneliness, city" in prompt
def test_build_prompt_omits_known_themes_section_when_empty():
prompt = analyze.build_prompt("Тоска", ["Артист"], "Текст песни", [])
assert "Уже использованные темы" not in prompt
GOOD_RESPONSE = {
"mood": ["melancholy", "longing"],
"themes": ["modern_loneliness", "city"],
"emotional_arc": "descent",
"pov": "confessional",
"register": "conversational",
"concreteness": "abstract",
"stance": "sincere",
"confidence": 0.9,
"summary": "Герой бродит по городу.",
"resonance": "Узнаваемое чувство одиночества.",
"key_line": "А я иду, шагаю по Москве",
}
TRACKS = {
"100:10": {"id": 100, "album_id": 10, "title": "Тоска", "artists": ["Артист"]},
"200:20": {"id": 200, "album_id": 20, "title": "Вторая", "artists": ["Другой"]},
}
LYRICS = {"100": "Первая строка\nА я иду, шагаю по Москве", "200": "Совсем другой текст"}
def test_analyze_stores_metadata_and_verifies_key_line(tmp_path: Path):
settings = _settings()
result = analyze.analyze_tracks(
TRACKS, {"100": LYRICS["100"]}, tmp_path, settings, call=lambda s, p: dict(GOOD_RESPONSE)
)
entry = result["100"]
assert entry["model"] == settings.model
assert entry["prompt_version"] == 1
assert entry["lyrics_hash"] == analyze.lyrics_hash(LYRICS["100"])
assert entry["key_line_verified"] is True
assert entry["mood"] == ["melancholy", "longing"]
assert "analyzed_at" in entry
assert analyze.load_analysis(tmp_path) == result
def test_analyze_marks_paraphrased_key_line_as_unverified(tmp_path: Path):
response = dict(GOOD_RESPONSE, key_line="Герой идёт по городу")
result = analyze.analyze_tracks(TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(), call=lambda s, p: response)
assert result["100"]["key_line_verified"] is False
def test_analyze_writes_after_every_track(tmp_path: Path):
# Прогон на 266 треках идёт ~3 часа: падение на середине не должно стоить больше
# одного трека, поэтому запись после каждого, а не пачками.
def call(settings, prompt):
if "Вторая" in prompt:
raise KeyboardInterrupt("пользователь прервал прогон")
return dict(GOOD_RESPONSE)
try:
analyze.analyze_tracks(TRACKS, LYRICS, tmp_path, _settings(), call=call)
except KeyboardInterrupt:
pass
persisted = analyze.load_analysis(tmp_path)
assert "100" in persisted, "первый трек должен быть на диске несмотря на обрыв на втором"
def test_analyze_records_error_marker_and_continues(tmp_path: Path):
def call(settings, prompt):
if "Тоска" in prompt:
raise TimeoutError("модель зависла")
return dict(GOOD_RESPONSE)
result = analyze.analyze_tracks(TRACKS, LYRICS, tmp_path, _settings(), call=call)
assert "TimeoutError" in result["100"]["error"]
assert result["200"]["summary"] == "Герой бродит по городу.", "таймаут одного трека не роняет батч"
def test_analyze_retries_error_entries_on_next_run(tmp_path: Path):
def failing(settings, prompt):
raise TimeoutError("модель зависла")
analyze.analyze_tracks(TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(), call=failing)
result = analyze.analyze_tracks(
TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(), call=lambda s, p: dict(GOOD_RESPONSE)
)
assert "error" not in result["100"]
assert result["100"]["summary"] == "Герой бродит по городу."
def test_analyze_skips_fresh_entries(tmp_path: Path):
def boom(settings, prompt):
raise AssertionError("не должен переанализировать свежую запись")
analyze.analyze_tracks(TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(), call=lambda s, p: dict(GOOD_RESPONSE))
result = analyze.analyze_tracks(TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(), call=boom)
assert result["100"]["summary"] == "Герой бродит по городу."
def test_analyze_reanalyzes_when_lyrics_text_changed(tmp_path: Path):
analyze.analyze_tracks(TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(), call=lambda s, p: dict(GOOD_RESPONSE))
updated = dict(GOOD_RESPONSE, summary="Исправленный текст, другой разбор.")
result = analyze.analyze_tracks(TRACKS, {"100": "Исправленный текст песни"}, tmp_path, _settings(), call=lambda s, p: updated)
assert result["100"]["summary"] == "Исправленный текст, другой разбор."
assert result["100"]["lyrics_hash"] == analyze.lyrics_hash("Исправленный текст песни")
def test_analyze_reanalyzes_when_prompt_version_bumped(tmp_path: Path):
analyze.analyze_tracks(TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(), call=lambda s, p: dict(GOOD_RESPONSE))
updated = dict(GOOD_RESPONSE, summary="Разбор по новому промпту.")
result = analyze.analyze_tracks(
TRACKS, {"100": LYRICS["100"]}, tmp_path, _settings(prompt_version=2), call=lambda s, p: updated
)
assert result["100"]["summary"] == "Разбор по новому промпту."
assert result["100"]["prompt_version"] == 2
def test_analyze_skips_tracks_without_text(tmp_path: Path):
def boom(settings, prompt):
raise AssertionError("нечего анализировать у трека без текста")
result = analyze.analyze_tracks(TRACKS, {"100": None, "200": ""}, tmp_path, _settings(), call=boom)
assert result == {}
def test_analyze_limit_processes_only_first_n(tmp_path: Path):
calls = []
def call(settings, prompt):
calls.append(prompt)
return dict(GOOD_RESPONSE)
analyze.analyze_tracks(TRACKS, LYRICS, tmp_path, _settings(), limit=1, call=call)
assert len(calls) == 1
def test_analyze_feeds_known_themes_into_later_prompts(tmp_path: Path):
prompts = []
def call(settings, prompt):
prompts.append(prompt)
return dict(GOOD_RESPONSE)
analyze.analyze_tracks(TRACKS, LYRICS, tmp_path, _settings(), call=call)
assert "modern_loneliness" not in prompts[0], "первому треку подмешивать нечего"
assert "modern_loneliness" in prompts[1], "темы первого трека должны попасть в промпт второго"

View file

@ -16,3 +16,41 @@ def test_apply_empty_label_fails_before_any_network_access():
def test_apply_without_yes_fails_regardless_of_source(): def test_apply_without_yes_fails_regardless_of_source():
with pytest.raises(SystemExit, match="--yes"): with pytest.raises(SystemExit, match="--yes"):
cli.main(["apply"]) cli.main(["apply"])
def test_lyrics_command_is_wired(monkeypatch):
called = {}
monkeypatch.setattr(
cli, "cmd_lyrics", lambda args: called.update(limit=args.limit, all_languages=args.all_languages)
)
cli.main(["lyrics", "--limit", "5"])
assert called == {"limit": 5, "all_languages": False}
def test_lyrics_command_all_languages_flag(monkeypatch):
called = {}
monkeypatch.setattr(cli, "cmd_lyrics", lambda args: called.update(all_languages=args.all_languages))
cli.main(["lyrics", "--all-languages"])
assert called == {"all_languages": True}
def test_analyze_command_is_wired(monkeypatch):
called = {}
monkeypatch.setattr(cli, "cmd_analyze", lambda args: called.update(limit=args.limit, model=args.model))
cli.main(["analyze", "--limit", "3", "--model", "other:latest"])
assert called == {"limit": 3, "model": "other:latest"}
def test_analyze_defaults_have_no_limit_and_no_model_override(monkeypatch):
called = {}
monkeypatch.setattr(cli, "cmd_analyze", lambda args: called.update(limit=args.limit, model=args.model))
cli.main(["analyze"])
assert called == {"limit": None, "model": None}

46
tests/test_config.py Normal file
View file

@ -0,0 +1,46 @@
from pathlib import Path
from sort_ym.config import load_config
MINIMAL_TOML = """
[throttle]
fetch_batch_delay = 0.4
lyrics_request_delay = 0.25
apply_request_delay = 0.3
request_timeout = 20
[fetch]
batch_size = 100
[classify]
small_group_min = 12
[digest]
top_artists = 40
top_albums = 15
[ollama]
host = "http://localhost:11434"
model = "qwen3.6-35b-a3b:latest"
prompt_version = 1
timeout = 600
keep_alive = "30m"
[paths]
token_file = ".token"
cache_dir = "cache"
out_dir = "out"
"""
def test_load_config_reads_ollama_block(tmp_path: Path):
path = tmp_path / "config.toml"
path.write_text(MINIMAL_TOML, encoding="utf-8")
cfg = load_config(path)
assert cfg.ollama_host == "http://localhost:11434"
assert cfg.ollama_model == "qwen3.6-35b-a3b:latest"
assert cfg.ollama_prompt_version == 1
assert cfg.ollama_timeout == 600
assert cfg.ollama_keep_alive == "30m"

View file

@ -275,3 +275,131 @@ def test_digest_line_count_stays_bounded_on_large_library():
text = digest.render_digest(rows, tracks_cache, {}, top_artists=40, top_albums=15) text = digest.render_digest(rows, tracks_cache, {}, top_artists=40, top_albums=15)
assert len(text.splitlines()) < 250 assert len(text.splitlines()) < 250
ANALYSIS = {
"100": {"mood": ["melancholy", "longing"], "themes": ["city", "memory"], "stance": "sincere", "pov": "confessional"},
"200": {"mood": ["melancholy"], "themes": ["city"], "stance": "ironic", "pov": "narrative"},
"300": {"error": "TimeoutError: модель зависла"},
}
def test_mood_stats_counts_every_mood_of_a_track():
assert digest.mood_stats(ANALYSIS) == [
{"mood": "melancholy", "tracks": 2},
{"mood": "longing", "tracks": 1},
]
def test_theme_stats_limits_and_orders():
assert digest.theme_stats(ANALYSIS, limit=1) == [{"theme": "city", "tracks": 2}]
def test_analyzed_entries_excludes_error_markers():
assert len(digest.analyzed_entries(ANALYSIS)) == 2
def test_lyrics_block_is_compact():
lines = digest.lyrics_block(ANALYSIS)
assert lines[0] == "## Лирика (RU-подмножество)"
assert len(lines) <= 7, "агрегат в digest.md должен оставаться компактным"
assert any("melancholy 2" in line for line in lines)
assert any("city 2" in line for line in lines)
def test_lyrics_block_empty_without_analysis():
assert digest.lyrics_block({}) == []
assert digest.lyrics_block({"300": {"error": "TimeoutError: ..."}}) == []
def test_render_digest_without_analysis_has_no_lyrics_section():
# Обратная совместимость: без analysis дайджест прежний, как в test_default_wording_matches_likes_output.
tracks_cache = {"100:1000": _track(100, "Тоска", ["Артист"], "rock")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10)
assert "## Лирика" not in text
def test_render_digest_with_analysis_includes_lyrics_section():
tracks_cache = {"100:1000": _track(100, "Тоска", ["Артист"], "rock")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10, analysis=ANALYSIS)
assert "## Лирика (RU-подмножество)" in text
LYRICS_TRACKS = {
"100:10": {"id": 100, "album_id": 10, "title": "Тоска", "artists": ["Артист"]},
"200:20": {"id": 200, "album_id": 20, "title": "Город", "artists": ["Другой"]},
}
FULL_ANALYSIS = {
"100": {
"mood": ["melancholy", "longing"],
"themes": ["city", "memory"],
"emotional_arc": "descent",
"pov": "confessional",
"register": "conversational",
"concreteness": "abstract",
"stance": "sincere",
"confidence": 0.9,
"summary": "Герой бродит по городу.",
"resonance": "Узнаваемое чувство одиночества.",
"key_line": "А я иду, шагаю по Москве",
"key_line_verified": True,
},
"200": {
"mood": ["melancholy"],
"themes": ["city"],
"emotional_arc": "static",
"pov": "narrative",
"register": "poetic",
"concreteness": "concrete",
"stance": "ironic",
"confidence": 0.8,
"summary": "Наблюдение за городом.",
"resonance": "Городская меланхолия.",
"key_line": "Пересказ вместо цитаты",
"key_line_verified": False,
},
"300": {"error": "TimeoutError: модель зависла"},
}
def test_render_lyrics_digest_lists_tracks_with_narrative():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "Артист — Тоска" in text
assert "Герой бродит по городу." in text
assert "Узнаваемое чувство одиночества." in text
assert "city, memory" in text
def test_render_lyrics_digest_omits_unverified_key_line():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "А я иду, шагаю по Москве" in text
assert "Пересказ вместо цитаты" not in text, "непроверенная цитата - вероятный пересказ модели"
def test_render_lyrics_digest_reports_failed_tracks_without_rendering_them():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "TimeoutError" not in text
assert "Не разобрано: 1" in text
def test_render_lyrics_digest_groups_by_primary_mood():
text = digest.render_lyrics_digest(LYRICS_TRACKS, FULL_ANALYSIS)
assert "## melancholy (2)" in text
def test_render_lyrics_digest_handles_track_missing_from_cache():
text = digest.render_lyrics_digest({}, {"100": FULL_ANALYSIS["100"]})
assert "трек 100" in text

136
tests/test_lyrics.py Normal file
View file

@ -0,0 +1,136 @@
import json
from pathlib import Path
import pytest
from yandex_music.exceptions import NotFoundError
from sort_ym import lyrics
def track(id_, title="Песня", artists=("Артист",), genre_raw="rock", lyrics_available=True):
return {
"id": id_,
"album_id": 10,
"title": title,
"artists": list(artists),
"artist_ids": [7],
"genre_raw": genre_raw,
"lyrics_available": lyrics_available,
}
def test_ru_lyric_track_ids_keeps_ru_and_drops_int():
tracks_cache = {
"100:10": track(100, title="Тоска", artists=["Гражданская Оборона"]),
"200:10": track(200, title="Come as You Are", artists=["Nirvana"]),
}
ids = lyrics.ru_lyric_track_ids(tracks_cache, lang_cache={})
assert ids == ["100"]
def test_ru_lyric_track_ids_trusts_api_language_over_alphabet():
# Кириллическое название, но API говорит "en" - латиницы в тексте нет, а язык известен точно.
tracks_cache = {"100:10": track(100, title="Тоска", artists=["Артист"])}
ids = lyrics.ru_lyric_track_ids(tracks_cache, lang_cache={"100": "en"})
assert ids == []
def test_ru_lyric_track_ids_skips_tracks_without_lyrics():
tracks_cache = {"100:10": track(100, title="Тоска", lyrics_available=False)}
assert lyrics.ru_lyric_track_ids(tracks_cache, lang_cache={}) == []
def test_ru_lyric_track_ids_deduplicates_same_numeric_id():
# Один и тот же трек лайкнут с двух альбомов - числовой id общий, ключи кэша разные.
tracks_cache = {
"100:10": track(100, title="Тоска"),
"100:20": track(100, title="Тоска"),
}
assert lyrics.ru_lyric_track_ids(tracks_cache, lang_cache={}) == ["100"]
def test_ru_lyric_track_ids_all_languages_includes_int():
tracks_cache = {
"100:10": track(100, title="Тоска", artists=["Гражданская Оборона"]),
"200:10": track(200, title="Come as You Are", artists=["Nirvana"]),
}
ids = lyrics.ru_lyric_track_ids(tracks_cache, lang_cache={}, all_languages=True)
assert ids == ["100", "200"]
def test_ru_lyric_track_ids_all_languages_still_gates_on_lyrics_available():
tracks_cache = {"100:10": track(100, lyrics_available=False)}
assert lyrics.ru_lyric_track_ids(tracks_cache, lang_cache={}, all_languages=True) == []
class FakeLyrics:
def __init__(self, full_lyrics):
self.full_lyrics = full_lyrics
class FakeSupplement:
def __init__(self, full_lyrics):
self.lyrics = FakeLyrics(full_lyrics) if full_lyrics is not None else None
class FakeClient:
def __init__(self, texts):
self._texts = texts
self.requested: list[str] = []
def track_supplement(self, track_id):
self.requested.append(track_id)
if track_id not in self._texts:
raise NotFoundError("no such track")
return FakeSupplement(self._texts[track_id])
def test_fetch_lyrics_text_caches_and_persists(tmp_path: Path):
client = FakeClient({"100": "Первая строка\nВторая строка"})
cache = lyrics.fetch_lyrics_text(client, ["100"], tmp_path, delay=0)
assert cache["100"] == "Первая строка\nВторая строка"
assert lyrics.load_lyrics_cache(tmp_path) == cache
def test_fetch_lyrics_text_skips_already_cached(tmp_path: Path):
(tmp_path / lyrics.LYRICS_TEXT_CACHE_FILE).write_text(
json.dumps({"100": "уже загружено"}, ensure_ascii=False), encoding="utf-8"
)
client = FakeClient({"100": "новый текст"})
cache = lyrics.fetch_lyrics_text(client, ["100"], tmp_path, delay=0)
assert client.requested == [], "уже закэшированный трек не должен запрашиваться снова"
assert cache["100"] == "уже загружено"
def test_fetch_lyrics_text_stores_none_for_missing_track_and_does_not_retry(tmp_path: Path):
# NotFoundError - окончательный ответ, а не сбой; None пишется в кэш как "текста нет",
# иначе такой трек перезапрашивался бы при каждом запуске.
client = FakeClient({})
lyrics.fetch_lyrics_text(client, ["100"], tmp_path, delay=0)
second_client = FakeClient({})
cache = lyrics.fetch_lyrics_text(second_client, ["100"], tmp_path, delay=0)
assert cache["100"] is None
assert second_client.requested == []
def test_fetch_lyrics_text_stores_none_when_supplement_has_no_lyrics(tmp_path: Path):
client = FakeClient({"100": None})
cache = lyrics.fetch_lyrics_text(client, ["100"], tmp_path, delay=0)
assert cache["100"] is None