From 165aa4fabc4368eb43751fe34142484b4db17904 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:26:50 +0300 Subject: [PATCH 01/11] ollama settings in config.toml --- config.toml | 12 ++++++++++++ sort_ym/config.py | 10 ++++++++++ tests/test_config.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 tests/test_config.py diff --git a/config.toml b/config.toml index a5096f1..0a5c22e 100644 --- a/config.toml +++ b/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" diff --git a/sort_ym/config.py b/sort_ym/config.py index 0730f12..fbda3c5 100644 --- a/sort_ym/config.py +++ b/sort_ym/config.py @@ -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"], diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..906dd19 --- /dev/null +++ b/tests/test_config.py @@ -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" From f1731b47106a22c05743952329f334de435847e2 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:31:55 +0300 Subject: [PATCH 02/11] lyrics text cache for ru tracks --- sort_ym/lyrics.py | 87 +++++++++++++++++++++++++++++++ tests/test_lyrics.py | 119 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 sort_ym/lyrics.py create mode 100644 tests/test_lyrics.py diff --git a/sort_ym/lyrics.py b/sort_ym/lyrics.py new file mode 100644 index 0000000..f440d7d --- /dev/null +++ b/sort_ym/lyrics.py @@ -0,0 +1,87 @@ +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]) -> list[str]: + """Числовые id RU-треков, у которых вообще есть текст. + + Гейт двойной: lyrics_available (иначе track_supplement заведомо вернёт запись без lyrics) + и RU - анализ INT-лирики вне скоупа фичи. Дедупликация нужна потому, что один и тот же + трек может быть лайкнут с разных альбомов: ключи 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 + lang = language.detect_language(t["title"], t["artists"], t["genre_raw"], lang_cache.get(tid)) + if lang == "RU": + 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 diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py new file mode 100644 index 0000000..48e67c9 --- /dev/null +++ b/tests/test_lyrics.py @@ -0,0 +1,119 @@ +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"] + + +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 From 010a4550ef9d174dd23c2e1b4721496444326f10 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:33:35 +0300 Subject: [PATCH 03/11] lyrics analysis schema and cache helpers --- sort_ym/analyze.py | 128 ++++++++++++++++++++++++++++++++++++++++++ tests/test_analyze.py | 87 ++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 sort_ym/analyze.py create mode 100644 tests/test_analyze.py diff --git a/sort_ym/analyze.py b/sort_ym/analyze.py new file mode 100644 index 0000000..65ad920 --- /dev/null +++ b/sort_ym/analyze.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import hashlib +import json +from collections import Counter +from pathlib import Path + +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 {} diff --git a/tests/test_analyze.py b/tests/test_analyze.py new file mode 100644 index 0000000..117521e --- /dev/null +++ b/tests/test_analyze.py @@ -0,0 +1,87 @@ +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) == {} From 2b715d2a75bc5580a4dafa11ddb98db9496a85d6 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:35:39 +0300 Subject: [PATCH 04/11] ollama call with schema-constrained output --- sort_ym/analyze.py | 68 +++++++++++++++++++++++++++++++++++ tests/test_analyze.py | 84 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/sort_ym/analyze.py b/sort_ym/analyze.py index 65ad920..5bee9a5 100644 --- a/sort_ym/analyze.py +++ b/sort_ym/analyze.py @@ -2,7 +2,9 @@ from __future__ import annotations import hashlib import json +import urllib.request from collections import Counter +from dataclasses import dataclass from pathlib import Path ANALYSIS_CACHE_FILE = "lyrics_analysis.json" @@ -126,3 +128,69 @@ def load_analysis(cache_dir: Path) -> dict[str, dict]: 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"]) diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 117521e..731bb48 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -1,3 +1,4 @@ +import json import re from pathlib import Path @@ -85,3 +86,86 @@ def test_top_themes_orders_by_frequency_and_limits(): 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 From 74fcb6736d6706beee9d81b2af5957ecec86eba9 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:37:57 +0300 Subject: [PATCH 05/11] resumable ollama analysis run with per-track writes --- sort_ym/analyze.py | 73 ++++++++++++++++++++ tests/test_analyze.py | 154 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) diff --git a/sort_ym/analyze.py b/sort_ym/analyze.py index 5bee9a5..3a1532a 100644 --- a/sort_ym/analyze.py +++ b/sort_ym/analyze.py @@ -5,7 +5,9 @@ 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" @@ -194,3 +196,74 @@ def call_ollama(settings: OllamaSettings, prompt: str) -> dict: 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 diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 731bb48..a9b237c 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -169,3 +169,157 @@ 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], "темы первого трека должны попасть в промпт второго" From 8eae62fb639f852caddee7d7b48330d0d55dfe14 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:40:28 +0300 Subject: [PATCH 06/11] lyrics aggregate block in digest.md --- sort_ym/digest.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_digest.py | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/sort_ym/digest.py b/sort_ym/digest.py index b768df2..10bf6f7 100644 --- a/sort_ym/digest.py +++ b/sort_ym/digest.py @@ -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: diff --git a/tests/test_digest.py b/tests/test_digest.py index edad7be..ebe9cbf 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -275,3 +275,58 @@ 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 From d0682f8341bc2f90630cb031340b1080e1e29844 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:42:33 +0300 Subject: [PATCH 07/11] render lyrics digest grouped by mood --- sort_ym/digest.py | 50 ++++++++++++++++++++++++++++++ tests/test_digest.py | 73 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/sort_ym/digest.py b/sort_ym/digest.py index 10bf6f7..04d1df1 100644 --- a/sort_ym/digest.py +++ b/sort_ym/digest.py @@ -439,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 diff --git a/tests/test_digest.py b/tests/test_digest.py index ebe9cbf..79abadf 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -330,3 +330,76 @@ def test_render_digest_with_analysis_includes_lyrics_section(): 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 From d8f4e16f147de175e90a54c95dbac42f6a5c2d19 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:45:01 +0300 Subject: [PATCH 08/11] lyrics and analyze commands --- sort_ym/cli.py | 69 +++++++++++++++++++++++++++++++++++++++++++++-- tests/test_cli.py | 27 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/sort_ym/cli.py b/sort_ym/cli.py index 255f1e2..0bd47c5 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -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,51 @@ 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") + + # Тот же вызов, что в 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) + if args.limit is not None: + ids = ids[: args.limit] + + print(f"RU-треков с текстом: {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 +214,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 +227,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 +261,17 @@ 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_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 +311,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, diff --git a/tests/test_cli.py b/tests/test_cli.py index 92c8a93..49c549c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,3 +16,30 @@ 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)) + + cli.main(["lyrics", "--limit", "5"]) + + assert called == {"limit": 5} + + +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} From 2344889418438a8036682a1b43a0994b37ebdcd8 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:49:12 +0300 Subject: [PATCH 09/11] document lyrics and analyze commands --- ARCHITECTURE.md | 48 +++++++++++++++++++++++++++++++++++++++++++++- ARCHITECTURE.ru.md | 47 ++++++++++++++++++++++++++++++++++++++++++++- README.md | 34 ++++++++++++++++++++++++++++++-- README.ru.md | 34 ++++++++++++++++++++++++++++++-- 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index aeb6ed3..31b5c92 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,6 +21,10 @@ sort_ym/ genres.py — the real Yandex genre tree; classify_track — resolves track genre and artist genres into a single sub-genre (root_id); ROOT_BUCKET — sub-genre -> 11 top-level buckets language.py — hybrid language detection (lyrics API / genre hint / alphabet) + lyrics.py — text lyrics of RU-tracks: track_supplement().lyrics.full_lyrics -> + cache/lyrics_text.json (incrementally, atomic-write) + analyze.py — lyric analysis via local Ollama (/api/chat, think + JSON Schema) -> + cache/lyrics_analysis.json (written after each track) classify.py — (sub-genre | top-level bucket, language) -> playlist name; with_label — source label suffix for apply --source --label report.py — two-pass report row building, writes out/report.csv, summaries @@ -34,12 +38,15 @@ sort_ym/ tests/ test_genres.py — classify_track, dominant, bucket_for, genre tree test_language.py — language detection + test_lyrics.py — lyrics text download, cache structure, resumability + test_analyze.py — local Ollama integration, JSON Schema parsing, cache atomicity test_report.py — two-pass collapsing of small sub-genre groups test_fetch.py, test_apply.py — track/artist cache, apply idempotency test_digest.py — artist/album aggregation, long-tail collapsing, digest size bounds test_source.py — playlist URL parsing, matching client.tracks() response by id, handling private/inaccessible playlists - test_cli.py — --source/--label flag validation before any network access + test_cli.py, test_config.py — --source/--label flag validation before any network access; + config.toml reading including [ollama] block cache/ — API cache (in .gitignore) out/ — reports (in .gitignore) @@ -59,6 +66,9 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json apply --yes → users_playlists_create / users_playlists_insert_track digest → genres.load_catalog() + language.load_lang_cache() (cache only, no network) → build_rows() → out/digest.md +lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json +analyze → cache/lyrics_text.json → Ollama /api/chat → cache/lyrics_analysis.json +digest → + cache/lyrics_analysis.json → out/digest.md (aggregate block) + out/lyrics_digest.md report/apply/digest --source → source.parse_playlist_url() → (user_id, kind) → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) @@ -107,6 +117,36 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, 3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, `unicodedata`). 4. Otherwise — `UNKNOWN` → a separate "Undetermined" playlist. +- **Lyric analysis (`lyrics.py`, `analyze.py`)**: only Russian-language tracks are analyzed. The + reason is signal validity, not cost — on a foreign-language track the meaning arrives through + the music and the vocal delivery, not through parsed words, so an LLM reading the literal text + would add a signal the listener never perceives. Text fetching and analysis are **two separate + commands** on purpose: the fetch is network-bound against the deprecated `track_supplement`, + the analysis is a multi-hour local GPU job, and a prompt or model change must be re-runnable + without touching Yandex again. `analyze` and `digest` construct no `Client` at all. + + The Ollama call (`/api/chat`) combines `think: true` with a full JSON Schema in `format`: + reasoning goes to a separate `message.thinking` field (never cached) while only the final + answer is grammar-constrained, so the model reasons freely and the result still parses. + `num_predict: -1` — a token cap would truncate mid-JSON and turn a slow answer into an invalid + one; the bound is the request timeout instead. `temperature: 0.65`, not 0: at 0 the narrative + fields flatten out. + + `themes` are free-form but pattern-constrained to English snake_case + (`^[a-z][a-z0-9_]{2,29}$`) — in the pilot, an unconstrained field drifted between Russian and + English inside a single run, making the values impossible to aggregate; the pattern makes + Cyrillic mechanically unrepresentable at decode time. They are English for the same reason + genre slugs and `ROOT_BUCKET` keys are: they are aggregation keys. `mood` is a closed enum that + deliberately excludes irony/sarcasm — that axis is `stance`, and mixing the two produced + compound values like `"aggressive/triumphant"`. + + Resumability: the cache is written atomically after **every single track** (not batched by 20 + as in `language.py`) — inference costs tens of seconds, so a crash must cost at most one track. + An entry is reused only when `(model, prompt_version, lyrics_hash)` all match, so a corrected + lyrics text or a bumped prompt invalidates exactly what it should. A timeout or a broken + response is stored as an `error` marker and the batch continues; error entries are never + considered fresh, so the next run retries precisely those tracks. + - **Track identifiers**: a track has a composite `track_id` (`"id:album_id"`, used as the cache key and to check against tracks already in a playlist) and separate `id`/`album_id` (needed as separate parameters for `track_supplement`, `tracks_lyrics`, and `users_playlists_insert_track`). @@ -178,6 +218,12 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, `report --extra`-only purpose as `added_at` above. - `cache/genre_catalog.json` — a flat snapshot of Yandex's genre tree (id -> title, root_id). - `cache/lyrics_lang.json` — lyrics language per track (id -> language code | null). +- `cache/lyrics_text.json` — lyrics text per track (numeric id -> text | null, RU tracks only). +- `cache/lyrics_analysis.json` — per-track analysis record: `{track_id: {model, prompt_version, + lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}`. +- `out/lyrics_digest.md` — full per-track reading grouped by primary mood. +- `config.toml` — includes `[ollama]` block with `host`, `model`, `prompt_version`, `timeout`, + `keep_alive`. - `out/report.csv` — a preview of the playlist breakdown before `apply` (including the `fine_bucket` column — the sub-genre before any collapsing into a top-level bucket). `report --order playlist` keeps source track order instead of sorting by target playlist; diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index f187076..69d910e 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -21,6 +21,10 @@ sort_ym/ genres.py — реальное дерево жанров Яндекса; classify_track — сведение жанра трека и жанров артиста в один под-жанр (root_id); ROOT_BUCKET — под-жанр -> 11 крупных корзин language.py — гибридная детекция языка (API текста / жанр-подсказка / алфавит) + lyrics.py — тексты песен RU-треков: track_supplement().lyrics.full_lyrics -> + cache/lyrics_text.json (инкрементально, atomic-write) + analyze.py — разбор текстов локальной Ollama (/api/chat, think + JSON Schema) -> + cache/lyrics_analysis.json (запись после каждого трека) classify.py — (под-жанр | крупная корзина, язык) -> имя плейлиста; with_label — суффикс метки источника для apply --source --label report.py — двухпроходная сборка строк отчёта, запись out/report.csv, сводки @@ -34,13 +38,16 @@ sort_ym/ tests/ test_genres.py — classify_track, dominant, bucket_for, дерево жанров test_language.py — детекция языка + test_lyrics.py — загрузка текстов песен, структура кэша, резюмируемость + test_analyze.py — интеграция локальной Ollama, разбор JSON Schema, атомарность кэша test_report.py — двухпроходное схлопывание мелких под-жанровых групп test_fetch.py, test_apply.py — кэш треков/артистов, идемпотентность применения test_digest.py — агрегация по исполнителям и альбомам, схлопывание длинного хвоста, границы размера дайджеста test_source.py — разбор ссылки на плейлист, сопоставление ответа client.tracks() по id, обработка приватных/недоступных плейлистов - test_cli.py — валидация флагов --source/--label до обращения к сети + test_cli.py, test_config.py — валидация флагов --source/--label до обращения к сети; + чтение config.toml включая блок [ollama] cache/ — кэш API (в .gitignore) out/ — отчёты (в .gitignore) @@ -60,6 +67,9 @@ report → genres.load_or_fetch_catalog() → cache/genre_catalog.json apply --yes → users_playlists_create / users_playlists_insert_track digest → genres.load_catalog() + language.load_lang_cache() (только кэш, сеть не используется) → build_rows() → out/digest.md +lyrics → ru_lyric_track_ids() → client.track_supplement() → cache/lyrics_text.json +analyze → cache/lyrics_text.json → Ollama /api/chat → cache/lyrics_analysis.json +digest → + cache/lyrics_analysis.json → out/digest.md (агрегат) + out/lyrics_digest.md report/apply/digest --source → source.parse_playlist_url() → (user_id, kind) → client.users_playlists(kind, user_id=...) → TrackShort[] → client.tracks(batch) @@ -108,6 +118,35 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, 3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, `unicodedata`). 4. Иначе — `UNKNOWN` → отдельный плейлист «Не определено». +- **Разбор текстов (`lyrics.py`, `analyze.py`)**: разбираются только русскоязычные треки. + Причина — валидность сигнала, не стоимость: на иноязычном треке смысл передаётся музыкой и + подачей вокала, а не разобранными словами, поэтому LLM, читающая буквальный текст, добавит + сигнал, который слушатель при прослушивании не считывает. Загрузка текста и анализ — это + **две отдельные команды** намеренно: загрузка привязана к сети и устаревшему `track_supplement`, + анализ — это многочасовая работа локального GPU, а смена prompt или модели должна быть + переиспользуема без нового обращения к Яндексу. `analyze` и `digest` вообще не создают `Client`. + + Вызов Ollama (`/api/chat`) комбинирует `think: true` с полной JSON Schema в `format`: + рассуждение идёт в отдельное поле `message.thinking` (никогда не кэшируется), а финальный ответ + ограничивается грамматикой, так что модель рассуждает свободно, а результат всё равно парсится. + `num_predict: -1` — ограничение по токенам обрежет JSON и превратит медленный ответ в невалидный; + ограничение вместо этого — таймаут запроса. `temperature: 0.65`, не 0: при 0 нарративные поля + становятся одноообразными. + + `themes` свободного формата, но ограничены паттерном англ. snake_case (`^[a-z][a-z0-9_]{2,29}$`) — + в пилоте неограниченное поле дрейфовало между русским и английским в одном прогоне, что сделало + значения невозможными для агрегации; паттерн делает кириллицу механически невозможной при разборе. + Они англ. по той же причине, что и слаги жанров, и `ROOT_BUCKET`: это ключи агрегации. `mood` — + закрытый enum, намеренно исключающий иронию/сарказм — это ось `stance`, и смешивание двух осей + дало составные значения типа `"aggressive/triumphant"`. + + Резюмируемость: кэш пишется атомарно после **каждого трека** (не батчами по 20, как в + `language.py`) — инференс занимает десятки секунд, так что крах должен стоить максимум один трек. + Запись переиспользуется, только если совпадают `(model, prompt_version, lyrics_hash)`, поэтому + исправленный текст или bumped prompt инвалидирует ровно то, что надо. Таймаут или повреждённый + ответ сохраняются как `error` marker, прогон продолжается; error-записи никогда не считаются + свежими, так что следующий прогон повторит точно эти треки. + - **Идентификаторы треков**: у трека есть composite `track_id` (`"id:album_id"`, используется как ключ кэша и для сверки с уже добавленными треками в плейлисте) и отдельные `id`/`album_id` (нужны как раздельные параметры для `track_supplement`, `tracks_lyrics` и @@ -177,6 +216,12 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, и `added_at` выше, только для `report --extra`. - `cache/genre_catalog.json` — плоский снимок дерева жанров Яндекса (id -> title, root_id). - `cache/lyrics_lang.json` — язык текста песни по треку (id -> language code | null). +- `cache/lyrics_text.json` — текст песни по треку (numeric id -> текст | null, только RU-треки). +- `cache/lyrics_analysis.json` — запись анализа на трек: `{track_id: {model, prompt_version, + lyrics_hash, mood, themes, emotional_arc, pov, tone, description, error?}}`. +- `out/lyrics_digest.md` — полный построчный разбор, сгруппированный по основному настроению. +- `config.toml` — включает блок `[ollama]` с полями `host`, `model`, `prompt_version`, `timeout`, + `keep_alive`. - `out/report.csv` — предпросмотр распределения по плейлистам перед `apply` (включая колонку `fine_bucket` — под-жанр до возможного схлопывания в крупную корзину). `report --order playlist` сохраняет исходный порядок треков вместо сортировки по целевому плейлисту; отдельные флаги diff --git a/README.md b/README.md index 8fae56c..f95c7c4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ For development (tests): .venv\Scripts\python -m sort_ym apply --limit 20 --yes # dry run: create playlists and add the first 20 tracks .venv\Scripts\python -m sort_ym apply --yes # create playlists and add all tracks (changes the account) .venv\Scripts\python -m sort_ym digest # library summary (top artists, genres) to out/digest.md - to paste into an LLM chat; no network used +.venv\Scripts\python -m sort_ym lyrics # download the lyrics text of Russian-language tracks (cache/lyrics_text.json); does not change the account +.venv\Scripts\python -m sort_ym analyze # analyze those lyrics with a local Ollama model (cache/lyrics_analysis.json); no internet, runs for hours, resumable ``` On repeated runs `fetch` only downloads new tracks, and `apply` does not duplicate tracks already added. @@ -80,11 +82,13 @@ To start over from scratch (re-fetch everything), delete the `cache/` folder: Remove-Item -Recurse -Force cache ``` -The cache consists of four files: +The cache consists of six files: - `cache/tracks.json` — liked tracks and their metadata (including artist ids); - `cache/artist_genres.json` — genre tags for each artist (an artist can have several); - `cache/genre_catalog.json` — a snapshot of Yandex's genre tree as of the last run; -- `cache/lyrics_lang.json` — detected lyrics language per track. +- `cache/lyrics_lang.json` — detected lyrics language per track; +- `cache/lyrics_text.json` — lyrics text of Russian-language tracks; +- `cache/lyrics_analysis.json` — per-track lyric analysis from the local model. Deleting `cache/` makes `fetch` and `report` re-fetch everything from scratch on the next run. @@ -159,6 +163,32 @@ Digest saved: out/digest.md Example `out/digest.md` (abridged): +## Lyric analysis (Russian tracks) + +`lyrics` downloads the lyrics text, `analyze` reads it with a local model and writes a per-track +profile: mood, themes, emotional arc, point of view, tone, plus a short description of what the +song is about and why it might resonate. + +``` +.venv\Scripts\python -m sort_ym lyrics # lyrics text of Russian-language tracks +.venv\Scripts\python -m sort_ym analyze # analysis via local Ollama, hours of local compute +.venv\Scripts\python -m sort_ym digest # out/lyrics_digest.md + an aggregate block in out/digest.md +``` + +Only Russian-language tracks are analyzed. On a foreign-language track the meaning is carried by +the music and the vocal delivery rather than by parsed words, so a literal reading of the text +would add a signal the listener never actually perceives. + +Requirements: Ollama running locally with the model named in `config.toml` under `[ollama]` +(`ollama list` to check). No internet is used — `analyze` never talks to Yandex. + +Expect roughly 40 seconds per track — about three hours for a ~266-track library. The run is +interruptible: results are written after every single track, so Ctrl+C costs at most one track, +and re-running continues where it stopped. Tracks that timed out are retried on the next run. +Raising `prompt_version` in `config.toml` invalidates the whole cache and re-analyzes everything. + +Example `out/digest.md` (abridged): + ``` ## Genres (top-level buckets) - Indie & Alternative — 680 (34%): INT 571 / RU 108 diff --git a/README.ru.md b/README.ru.md index 8ca916a..1a77ffb 100644 --- a/README.ru.md +++ b/README.ru.md @@ -42,6 +42,8 @@ python -m venv .venv .venv\Scripts\python -m sort_ym apply --limit 20 --yes # пробный запуск: создать плейлисты и добавить в них первые 20 треков .venv\Scripts\python -m sort_ym apply --yes # создать плейлисты и добавить в них все треки (меняет аккаунт) .venv\Scripts\python -m sort_ym digest # сводка библиотеки (топ-исполнители, жанры) в out/digest.md - чтобы вставить в чат с LLM; сеть не используется +.venv\Scripts\python -m sort_ym lyrics # загрузить тексты песен русскоязычных треков (cache/lyrics_text.json), аккаунт не меняется +.venv\Scripts\python -m sort_ym analyze # разобрать эти тексты локальной моделью Ollama (cache/lyrics_analysis.json), без интернета, идёт часами, продолжается после обрыва ``` При повторных запусках `fetch` докачивает только новые треки, а `apply` не дублирует уже добавленные треки. @@ -80,11 +82,13 @@ python -m venv .venv Remove-Item -Recurse -Force cache ``` -Кэш состоит из четырёх файлов: +Кэш состоит из шести файлов: - `cache/tracks.json` — список лайкнутых треков и их метаданные (включая id артистов); - `cache/artist_genres.json` — жанровые теги каждого артиста (может быть несколько на артиста); - `cache/genre_catalog.json` — снимок дерева жанров Яндекса на момент последнего запуска; -- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека. +- `cache/lyrics_lang.json` — определённые языки текстов для каждого трека; +- `cache/lyrics_text.json` — тексты песен русскоязычных треков; +- `cache/lyrics_analysis.json` — разбор текста каждого трека локальной моделью. Удаление `cache/` заставит `fetch` и `report` переполучить всё с нуля при следующем запуске. @@ -160,6 +164,32 @@ $ python -m sort_ym digest Пример `out/digest.md` (сокращённо): +## Разбор текстов (русскоязычные треки) + +`lyrics` загружает текст песни, `analyze` читает его локальной моделью и пишет профиль каждого +трека: настроение, темы, эмоциональную арку, ракурс, тон, плюс короткое описание того, о чём +песня и чем она может цеплять. + +``` +.venv\Scripts\python -m sort_ym lyrics # тексты песен русскоязычных треков +.venv\Scripts\python -m sort_ym analyze # разбор локальной Ollama, часы локальных вычислений +.venv\Scripts\python -m sort_ym digest # out/lyrics_digest.md + агрегат в out/digest.md +``` + +Разбираются только русскоязычные треки. На иноязычном треке смысл передаётся музыкой и подачей +вокала, а не разобранными словами, поэтому буквальное чтение текста добавило бы сигнал, которого +слушатель при прослушивании не считывает. + +Что нужно: запущенная локально Ollama с моделью из `config.toml`, блок `[ollama]` (проверить — +`ollama list`). Интернет не используется: `analyze` не обращается к Яндексу вообще. + +Скорость — примерно 40 секунд на трек, около трёх часов на ~266 треков. Прогон прерываемый: +результат пишется после каждого трека, Ctrl+C стоит максимум один трек, повторный запуск +продолжает с места обрыва. Треки, упавшие по таймауту, повторяются при следующем запуске. +Увеличение `prompt_version` в `config.toml` обесценивает весь кэш и запускает разбор заново. + +Пример `out/digest.md` (сокращённо): + ``` ## Жанры (крупные корзины) - Инди и альтернатива — 680 (34%): INT 571 / RU 108 From 1c0f6928bf62923cef3e1738798889d8bb8d27a3 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:50:35 +0300 Subject: [PATCH 10/11] lyrics --all-languages flag to opt out of the ru filter --- sort_ym/cli.py | 20 ++++++++++++++------ sort_ym/lyrics.py | 28 ++++++++++++++++++---------- tests/test_cli.py | 15 +++++++++++++-- tests/test_lyrics.py | 17 +++++++++++++++++ 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/sort_ym/cli.py b/sort_ym/cli.py index 0bd47c5..fcfcbd1 100644 --- a/sort_ym/cli.py +++ b/sort_ym/cli.py @@ -71,16 +71,19 @@ def cmd_lyrics(args: argparse.Namespace) -> None: if not tracks_cache: raise SystemExit("Кэш треков пуст. Сначала запустите: python -m sort_ym fetch") - # Тот же вызов, что в 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) + 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) + 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] - print(f"RU-треков с текстом: {len(ids)}") + 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) @@ -263,6 +266,11 @@ def main(argv: list[str] | None = None) -> None: 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", diff --git a/sort_ym/lyrics.py b/sort_ym/lyrics.py index f440d7d..1864c83 100644 --- a/sort_ym/lyrics.py +++ b/sort_ym/lyrics.py @@ -13,13 +13,19 @@ 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]) -> list[str]: - """Числовые id RU-треков, у которых вообще есть текст. +def ru_lyric_track_ids( + tracks_cache: dict[str, dict], + lang_cache: dict[str, str | None], + all_languages: bool = False, +) -> list[str]: + """Числовые id треков, у которых вообще есть текст. - Гейт двойной: lyrics_available (иначе track_supplement заведомо вернёт запись без lyrics) - и RU - анализ INT-лирики вне скоупа фичи. Дедупликация нужна потому, что один и тот же - трек может быть лайкнут с разных альбомов: ключи tracks_cache при этом разные, а числовой - id (и, соответственно, ключ кэша текстов) - один. + По умолчанию - только RU: на незнакомом языке текст песни несёт для языковой модели меньше + сигнала, чем разбор трека, который слушатель понимает дословно. all_languages=True снимает + языковой фильтр, оставляя только lyrics_available (иначе track_supplement заведомо вернёт + запись без lyrics). Дедупликация нужна потому, что один и тот же трек может быть лайкнут с + разных альбомов: ключи tracks_cache при этом разные, а числовой id (и, соответственно, ключ + кэша текстов) - один. """ ids: list[str] = [] seen: set[str] = set() @@ -29,10 +35,12 @@ def ru_lyric_track_ids(tracks_cache: dict[str, dict], lang_cache: dict[str, str tid = str(t["id"]) if tid in seen: continue - lang = language.detect_language(t["title"], t["artists"], t["genre_raw"], lang_cache.get(tid)) - if lang == "RU": - seen.add(tid) - ids.append(tid) + 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 49c549c..2e4586d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,11 +20,22 @@ def test_apply_without_yes_fails_regardless_of_source(): def test_lyrics_command_is_wired(monkeypatch): called = {} - monkeypatch.setattr(cli, "cmd_lyrics", lambda args: called.update(limit=args.limit)) + 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} + 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): diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py index 48e67c9..fc4ddd7 100644 --- a/tests/test_lyrics.py +++ b/tests/test_lyrics.py @@ -55,6 +55,23 @@ def test_ru_lyric_track_ids_deduplicates_same_numeric_id(): 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 From 8fc99b8959eefa547ac08fb70700175e5dfdc481 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:51:46 +0300 Subject: [PATCH 11/11] neutral rationale for ru-only default, document --all-languages --- ARCHITECTURE.md | 11 ++++++----- ARCHITECTURE.ru.md | 11 ++++++----- README.md | 7 ++++--- README.ru.md | 7 ++++--- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 31b5c92..cfb6ff7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -117,11 +117,12 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, 3. An alphabet heuristic on the title/artist (Cyrillic vs Latin, `unicodedata`). 4. Otherwise — `UNKNOWN` → a separate "Undetermined" playlist. -- **Lyric analysis (`lyrics.py`, `analyze.py`)**: only Russian-language tracks are analyzed. The - reason is signal validity, not cost — on a foreign-language track the meaning arrives through - the music and the vocal delivery, not through parsed words, so an LLM reading the literal text - would add a signal the listener never perceives. Text fetching and analysis are **two separate - commands** on purpose: the fetch is network-bound against the deprecated `track_supplement`, +- **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. diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index 69d910e..788f647 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -118,11 +118,12 @@ report/apply/digest --source → source.parse_playlist_url() → (user_id, 3. Эвристика по алфавиту названия/исполнителя (кириллица vs латиница, `unicodedata`). 4. Иначе — `UNKNOWN` → отдельный плейлист «Не определено». -- **Разбор текстов (`lyrics.py`, `analyze.py`)**: разбираются только русскоязычные треки. - Причина — валидность сигнала, не стоимость: на иноязычном треке смысл передаётся музыкой и - подачей вокала, а не разобранными словами, поэтому LLM, читающая буквальный текст, добавит - сигнал, который слушатель при прослушивании не считывает. Загрузка текста и анализ — это - **две отдельные команды** намеренно: загрузка привязана к сети и устаревшему `track_supplement`, +- **Разбор текстов (`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`. diff --git a/README.md b/README.md index f95c7c4..e27da81 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,10 @@ song is about and why it might resonate. .venv\Scripts\python -m sort_ym digest # out/lyrics_digest.md + an aggregate block in out/digest.md ``` -Only Russian-language tracks are analyzed. On a foreign-language track the meaning is carried by -the music and the vocal delivery rather than by parsed words, so a literal reading of the text -would add a signal the listener never actually perceives. +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. diff --git a/README.ru.md b/README.ru.md index 1a77ffb..5445f93 100644 --- a/README.ru.md +++ b/README.ru.md @@ -176,9 +176,10 @@ $ python -m sort_ym digest .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` не обращается к Яндексу вообще.