From d0682f8341bc2f90630cb031340b1080e1e29844 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 16 Aug 2026 19:42:33 +0300 Subject: [PATCH] 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