mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 20:21:59 +03:00
lyrics aggregate block in digest.md
This commit is contained in:
parent
74fcb6736d
commit
8eae62fb63
2 changed files with 115 additions and 0 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue