mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-11 02:12:00 +03:00
Merge branch 'worktree-lyrics-analysis'
This commit is contained in:
commit
12145cc82a
15 changed files with 1405 additions and 8 deletions
|
|
@ -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 <url> → source.parse_playlist_url() → (user_id, kind)
|
||||
→ 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`).
|
||||
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
|
||||
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 +219,12 @@ report/apply/digest --source <url> → 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;
|
||||
|
|
|
|||
|
|
@ -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 <url> → source.parse_playlist_url() → (user_id, kind)
|
||||
→ 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`).
|
||||
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"`, используется как
|
||||
ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные `id`/`album_id`
|
||||
(нужны как раздельные параметры для `track_supplement`, `tracks_lyrics` и
|
||||
|
|
@ -177,6 +217,12 @@ report/apply/digest --source <url> → 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`
|
||||
сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; отдельные флаги
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -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 --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.
|
||||
|
|
@ -85,11 +87,13 @@ Remove-Item -Recurse -Force cache # Windows (PowerShell)
|
|||
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/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.
|
||||
|
||||
|
|
@ -166,6 +170,33 @@ 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
|
||||
```
|
||||
|
||||
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)
|
||||
- Indie & Alternative — 680 (34%): INT 571 / RU 108
|
||||
|
|
|
|||
35
README.ru.md
35
README.ru.md
|
|
@ -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 --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` не дублирует уже добавленные треки.
|
||||
|
|
@ -85,11 +87,13 @@ Remove-Item -Recurse -Force cache # Windows (PowerShell)
|
|||
rm -rf cache # Linux/macOS
|
||||
```
|
||||
|
||||
Кэш состоит из четырёх файлов:
|
||||
Кэш состоит из шести файлов:
|
||||
- `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` переполучить всё с нуля при следующем запуске.
|
||||
|
||||
|
|
@ -167,6 +171,33 @@ $ 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
|
||||
```
|
||||
|
||||
По умолчанию разбираются только русскоязычные треки — промпт и нарративные поля
|
||||
(`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
|
||||
|
|
|
|||
12
config.toml
12
config.toml
|
|
@ -22,6 +22,18 @@ top_artists = 40
|
|||
# Сколько альбомов показывать поимённо; остальные - в одну строку с метрикой "альбомы vs синглы".
|
||||
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]
|
||||
token_file = ".token"
|
||||
cache_dir = "cache"
|
||||
|
|
|
|||
269
sort_ym/analyze.py
Normal file
269
sort_ym/analyze.py
Normal 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
|
||||
|
|
@ -7,7 +7,7 @@ from yandex_music import Client
|
|||
from yandex_music.exceptions import UnauthorizedError
|
||||
|
||||
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 .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)
|
||||
|
||||
|
||||
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:
|
||||
print("\nРаспределение по плейлистам:")
|
||||
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
|
||||
wording = digest.playlist_wording(ctx.info.title, ctx.info.owner, ctx.info.url)
|
||||
out_name = digest.DIGEST_SOURCE_FILE
|
||||
analysis = {}
|
||||
else:
|
||||
tracks_cache = fetch.load_tracks_cache(cfg.cache_dir)
|
||||
if not tracks_cache:
|
||||
|
|
@ -181,14 +230,20 @@ def cmd_digest(args: argparse.Namespace) -> None:
|
|||
artist_genres = fetch.load_artist_genres(cfg.cache_dir)
|
||||
lang_cache = language.load_lang_cache(cfg.cache_dir) # только кэш, без сети
|
||||
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)
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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Жанры:")
|
||||
for g in digest.genre_stats(rows):
|
||||
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("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.add_argument("--source", metavar="URL", default=None, help=SOURCE_HELP)
|
||||
p_report.add_argument(
|
||||
|
|
@ -248,6 +319,8 @@ def main(argv: list[str] | None = None) -> None:
|
|||
commands = {
|
||||
"auth": cmd_auth,
|
||||
"fetch": cmd_fetch,
|
||||
"lyrics": cmd_lyrics,
|
||||
"analyze": cmd_analyze,
|
||||
"report": cmd_report,
|
||||
"apply": cmd_apply,
|
||||
"dedupe": cmd_dedupe,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ class Config:
|
|||
small_group_min: int
|
||||
digest_top_artists: 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
|
||||
cache_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"],
|
||||
digest_top_artists=data["digest"]["top_artists"],
|
||||
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"],
|
||||
cache_dir=ROOT / data["paths"]["cache_dir"],
|
||||
out_dir=ROOT / data["paths"]["out_dir"],
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ from . import classify, genres, report
|
|||
|
||||
DIGEST_FILE = "digest.md"
|
||||
DIGEST_SOURCE_FILE = "digest_source.md"
|
||||
LYRICS_DIGEST_FILE = "lyrics_digest.md"
|
||||
|
||||
DECADE_UNKNOWN = "unknown"
|
||||
DECADE_MIN_YEAR = 1900
|
||||
DECADE_MAX_YEAR = 2100
|
||||
DECADE_TOP_GENRES = 3 # константа вёрстки (сколько жанров показывать на десятилетие), не config-knob
|
||||
LYRICS_TOP_MOODS = 6
|
||||
LYRICS_TOP_THEMES = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -257,6 +260,60 @@ def _pct(n: int, total: int) -> int:
|
|||
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(
|
||||
rows: list[dict],
|
||||
tracks_cache: dict[str, dict],
|
||||
|
|
@ -264,6 +321,7 @@ def render_digest(
|
|||
top_artists: int,
|
||||
top_albums: int,
|
||||
wording: Wording = DEFAULT_WORDING,
|
||||
analysis: dict[str, dict] | None = None,
|
||||
) -> str:
|
||||
total = len(rows)
|
||||
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("")
|
||||
|
||||
lines.extend(lyrics_block(analysis or {}))
|
||||
|
||||
lines.append("## Десятилетия")
|
||||
lines.append("В скобках — доля от библиотеки; далее — три самые частые крупные корзины десятилетия.")
|
||||
for d in decade_rows:
|
||||
|
|
@ -379,6 +439,56 @@ def render_digest(
|
|||
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:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_file = out_dir / filename
|
||||
|
|
|
|||
95
sort_ym/lyrics.py
Normal file
95
sort_ym/lyrics.py
Normal 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
325
tests/test_analyze.py
Normal 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], "темы первого трека должны попасть в промпт второго"
|
||||
|
|
@ -16,3 +16,41 @@ def test_apply_empty_label_fails_before_any_network_access():
|
|||
def test_apply_without_yes_fails_regardless_of_source():
|
||||
with pytest.raises(SystemExit, match="--yes"):
|
||||
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
46
tests/test_config.py
Normal 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"
|
||||
|
|
@ -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)
|
||||
|
||||
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
136
tests/test_lyrics.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue