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