lyrics aggregate block in digest.md

This commit is contained in:
Andrew Pimenov 2026-08-16 19:40:28 +03:00
parent 74fcb6736d
commit 8eae62fb63
2 changed files with 115 additions and 0 deletions

View file

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

View file

@ -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) text = digest.render_digest(rows, tracks_cache, {}, top_artists=40, top_albums=15)
assert len(text.splitlines()) < 250 assert len(text.splitlines()) < 250
ANALYSIS = {
"100": {"mood": ["melancholy", "longing"], "themes": ["city", "memory"], "stance": "sincere", "pov": "confessional"},
"200": {"mood": ["melancholy"], "themes": ["city"], "stance": "ironic", "pov": "narrative"},
"300": {"error": "TimeoutError: модель зависла"},
}
def test_mood_stats_counts_every_mood_of_a_track():
assert digest.mood_stats(ANALYSIS) == [
{"mood": "melancholy", "tracks": 2},
{"mood": "longing", "tracks": 1},
]
def test_theme_stats_limits_and_orders():
assert digest.theme_stats(ANALYSIS, limit=1) == [{"theme": "city", "tracks": 2}]
def test_analyzed_entries_excludes_error_markers():
assert len(digest.analyzed_entries(ANALYSIS)) == 2
def test_lyrics_block_is_compact():
lines = digest.lyrics_block(ANALYSIS)
assert lines[0] == "## Лирика (RU-подмножество)"
assert len(lines) <= 7, "агрегат в digest.md должен оставаться компактным"
assert any("melancholy 2" in line for line in lines)
assert any("city 2" in line for line in lines)
def test_lyrics_block_empty_without_analysis():
assert digest.lyrics_block({}) == []
assert digest.lyrics_block({"300": {"error": "TimeoutError: ..."}}) == []
def test_render_digest_without_analysis_has_no_lyrics_section():
# Обратная совместимость: без analysis дайджест прежний, как в test_default_wording_matches_likes_output.
tracks_cache = {"100:1000": _track(100, "Тоска", ["Артист"], "rock")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10)
assert "## Лирика" not in text
def test_render_digest_with_analysis_includes_lyrics_section():
tracks_cache = {"100:1000": _track(100, "Тоска", ["Артист"], "rock")}
rows = _rows(tracks_cache)
text = digest.render_digest(rows, tracks_cache, {}, top_artists=10, top_albums=10, analysis=ANALYSIS)
assert "## Лирика (RU-подмножество)" in text