Sort_YM/sort_ym/digest.py
2026-08-16 20:23:19 +03:00

496 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import statistics
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
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)
class Wording:
"""Тексты дайджеста, зависящие от источника (лайки vs произвольный плейлист по --source).
Дефолты - буквально сегодняшние строки для лайков, чтобы render_digest(...) без явного
wording давал побайтово тот же результат, что и раньше.
"""
heading: str = "Дайджест музыкальной библиотеки"
subtitle: str | None = None
tracks_label: str = "Лайкнутых треков"
no_years_note: str = (
"> Годы релиза и названия альбомов ещё не загружены - запустите `python -m sort_ym "
"fetch` (кэш треков обновится один раз). Секции «Десятилетия» и «Топ альбомов» "
"пока неинформативны."
)
album_counter_note: str = "Счётчик — сколько треков с альбома лайкнуто."
rest_albums_note: str = "Ещё {n} альбомов с {lo}-{hi} лайкнутыми треками."
deep_albums_note: str = "Треков с альбомов, где лайкнуто 2 и более: {deep} из {total} ({pct}%)."
DEFAULT_WORDING = Wording()
def playlist_wording(title: str, owner: str, url: str) -> Wording:
"""Wording для дайджеста произвольного плейлиста (cmd_digest --source)."""
return Wording(
heading=f"Дайджест плейлиста «{title}»",
subtitle=f"Источник: плейлист «{title}» (владелец: {owner}), ссылка: {url}",
tracks_label="Треков в плейлисте",
no_years_note="> Год выхода и название альбома неизвестны для всех треков этого плейлиста.",
album_counter_note="Счётчик — сколько треков плейлиста относится к альбому.",
rest_albums_note="Ещё {n} альбомов с {lo}-{hi} треками в плейлисте.",
deep_albums_note="Треков с альбомов, где в плейлисте 2 и более трека: {deep} из {total} ({pct}%).",
)
def _by_track_key(tracks_cache: dict[str, dict]) -> dict[tuple, dict]:
"""(id, album_id) из ЗНАЧЕНИЙ кэша -> сама запись кэша.
Ключ tracks_cache - исходный id из likes.tracks_ids, а не f"{id}:{album_id}" (у треков без
альбома это разные строки), поэтому склейка со строками report.build_rows идёт по значениям.
"""
return {(t["id"], t["album_id"]): t for t in tracks_cache.values()}
def _most_common_deterministic(counter: Counter):
"""Самое частое значение; при равенстве частот - детерминированный тай-брейк по значению."""
return sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[0][0]
def _artist_ids_by_name(tracks_cache: dict[str, dict]) -> dict[str, list[str]]:
"""Имя артиста -> id (в порядке первого появления, без дублей).
fetch.serialize_track кладёт в artists всех артистов с именем, а в artist_ids - только тех, у кого
есть и имя, и id, поэтому списки могут расходиться по длине для одного трека. Берём только
треки, где длины совпадают - иначе zip присвоил бы имени чужой id. Имя, встречающееся только
в рассинхронных треках, остаётся без id (и без тегов) - лучше пусто, чем неверно.
"""
ids_by_name: dict[str, list[str]] = {}
for t in tracks_cache.values():
artists = t["artists"]
artist_ids = t["artist_ids"]
if len(artists) != len(artist_ids):
continue
for name, aid in zip(artists, artist_ids):
ids = ids_by_name.setdefault(name, [])
sid = str(aid)
if sid not in ids:
ids.append(sid)
return ids_by_name
def artist_tags(
name: str,
ids_by_name: dict[str, list[str]],
artist_genres: dict[str, dict],
limit: int = 3,
) -> list[str]:
"""Собственные жанровые слаги артиста (объединение по всем безопасно найденным id)."""
tags: list[str] = []
for aid in ids_by_name.get(name, []):
entry = artist_genres.get(aid)
if not entry:
continue
for tag in entry.get("genres", []):
if tag not in tags:
tags.append(tag)
return tags[:limit]
def artist_stats(
rows: list[dict],
tracks_cache: dict[str, dict],
artist_genres: dict[str, dict],
) -> list[dict]:
"""[{"name", "tracks", "top_fine", "tags"}], сортировка: tracks desc, затем name.
Считает по ИМЕНИ артиста, а не через zip(artists, artist_ids) - списки не всегда выровнены.
top_fine - самый частый fine_bucket среди треков этого исполнителя.
"""
by_key = _by_track_key(tracks_cache)
ids_by_name = _artist_ids_by_name(tracks_cache)
counts: dict[str, int] = {}
fines: dict[str, Counter] = {}
for row in rows:
t = by_key.get((row["id"], row["album_id"]))
if t is None:
continue
for name in t["artists"]:
counts[name] = counts.get(name, 0) + 1
fines.setdefault(name, Counter())[row["fine_bucket"]] += 1
result = [
{
"name": name,
"tracks": tracks,
"top_fine": fines[name].most_common(1)[0][0],
"tags": artist_tags(name, ids_by_name, artist_genres),
}
for name, tracks in counts.items()
]
result.sort(key=lambda a: (-a["tracks"], a["name"]))
return result
def genre_stats(rows: list[dict]) -> list[dict]:
"""[{"bucket", "tracks", "langs"}], максимум 11 строк (число крупных корзин).
bucket = genres.coarse_of(row["fine_bucket"]), а не row["bucket"] - оно смешивает под-жанр
и крупную корзину после схлопывания мелких групп в report.build_rows.
"""
counts: dict[str, int] = {}
langs: dict[str, Counter] = {}
for row in rows:
bucket = genres.coarse_of(row["fine_bucket"])
counts[bucket] = counts.get(bucket, 0) + 1
langs.setdefault(bucket, Counter())[row["lang"]] += 1
result = [{"bucket": b, "tracks": n, "langs": dict(langs[b])} for b, n in counts.items()]
result.sort(key=lambda g: (-g["tracks"], g["bucket"]))
return result
def fine_stats(rows: list[dict]) -> list[dict]:
"""[{"fine", "tracks"}], максимум ~34 строки (размер genres.ROOT_BUCKET)."""
counts = Counter(row["fine_bucket"] for row in rows)
result = [{"fine": f, "tracks": n} for f, n in counts.items()]
result.sort(key=lambda g: (-g["tracks"], g["fine"]))
return result
def lang_stats(rows: list[dict]) -> dict[str, int]:
return dict(Counter(row["lang"] for row in rows))
def decade_key(year) -> str:
"""Год -> "1990"/"2010"/... или DECADE_UNKNOWN (None, не int, либо вне разумных границ)."""
if not isinstance(year, int) or year < DECADE_MIN_YEAR or year > DECADE_MAX_YEAR:
return DECADE_UNKNOWN
return str((year // 10) * 10)
def decade_label(key: str) -> str:
return "Неизвестно" if key == DECADE_UNKNOWN else f"{key}-е"
def decade_stats(rows: list[dict], tracks_cache: dict[str, dict]) -> list[dict]:
"""[{"key", "tracks", "top_genres"}], сортировка по году desc, "Неизвестно" последним.
top_genres - топ-DECADE_TOP_GENRES крупных корзин десятилетия (не полная матрица жанр x
десятилетие - она была бы до 11 x ~13 строк).
"""
by_key = _by_track_key(tracks_cache)
counts: dict[str, int] = {}
genre_counts: dict[str, Counter] = {}
for row in rows:
t = by_key.get((row["id"], row["album_id"]))
year = t.get("year") if t else None
key = decade_key(year)
counts[key] = counts.get(key, 0) + 1
bucket = genres.coarse_of(row["fine_bucket"])
genre_counts.setdefault(key, Counter())[bucket] += 1
result = [
{
"key": key,
"tracks": n,
"top_genres": [
{"bucket": b, "tracks": c}
for b, c in genre_counts[key].most_common(DECADE_TOP_GENRES)
],
}
for key, n in counts.items()
]
result.sort(key=lambda d: (d["key"] == DECADE_UNKNOWN, -int(d["key"]) if d["key"] != DECADE_UNKNOWN else 0))
return result
def album_stats(tracks_cache: dict[str, dict]) -> list[dict]:
"""[{"album_id", "title", "year", "artists", "tracks"}], сортировка tracks desc, затем title.
Треки с album_id is None (сторонние загрузки без привязки к альбому) пропускаются - они не
участвуют в подсчёте "альбом vs синглы", но остаются во всех остальных срезах.
title/year/artists представителя альбома - самое частое непустое значение среди его треков
(детерминированный тай-брейк), а не "первое попавшееся".
"""
groups: dict[int, list[dict]] = {}
for t in tracks_cache.values():
album_id = t.get("album_id")
if album_id is None:
continue
groups.setdefault(album_id, []).append(t)
result = []
for album_id, tracks in groups.items():
titles = Counter(t["album_title"] for t in tracks if t.get("album_title"))
title = _most_common_deterministic(titles) if titles else f"без названия (#{album_id})"
years = Counter(t["year"] for t in tracks if t.get("year") is not None)
year = _most_common_deterministic(years) if years else None
artist_tuples = Counter(tuple(t["artists"]) for t in tracks)
artists = ", ".join(_most_common_deterministic(artist_tuples)) if artist_tuples else ""
result.append(
{
"album_id": album_id,
"title": title,
"year": year,
"artists": artists,
"tracks": len(tracks),
}
)
result.sort(key=lambda a: (-a["tracks"], a["title"]))
return result
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 [
"## Лирика",
f"Разбор текстов {len(entries)} треков локальной моделью; подробнее — 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],
artist_genres: dict[str, dict],
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"]}
genre_rows = genre_stats(rows)
fine_rows = fine_stats(rows)
lang_rows = lang_stats(rows)
decade_rows = decade_stats(rows, tracks_cache)
artists = artist_stats(rows, tracks_cache, artist_genres)
albums = album_stats(tracks_cache)
other = report.other_breakdown(rows)
known_years = sorted(t["year"] for t in tracks_cache.values() if t.get("year") is not None)
lines: list[str] = [f"# {wording.heading}", ""]
if wording.subtitle:
lines.append(wording.subtitle)
lines.append("")
lines.append("## Итоги")
lines.append(f"- {wording.tracks_label}: {total}")
lines.append(f"- Уникальных исполнителей: {len(unique_artists)}")
lines.append(
f"- Задействовано крупных корзин: {len(genre_rows)} из {len(classify.BUCKET_LABELS)}, "
f"под-жанров: {len(fine_rows)}"
)
if known_years:
median_year = round(statistics.median(known_years))
lines.append(
f"- Год релиза: медиана {median_year}, известен у {len(known_years)} из {total} "
f"({_pct(len(known_years), total)}%)"
)
else:
lines.append(wording.no_years_note)
lines.append("")
lines.append("## Жанры (крупные корзины)")
for g in genre_rows:
label = classify.BUCKET_LABELS.get(g["bucket"], g["bucket"])
lang_parts = [
f"{classify.LANG_LABELS.get(code, code)} {n}"
for code, n in sorted(g["langs"].items(), key=lambda kv: -kv[1])
]
lines.append(f"- {label} — {g['tracks']} ({_pct(g['tracks'], total)}%): {' / '.join(lang_parts)}")
lines.append("")
lines.append("## Под-жанры")
for f in fine_rows:
label = classify.FINE_LABELS.get(f["fine"], f["fine"])
lines.append(f"- {label} — {f['tracks']}")
lines.append("")
lines.append("## Язык")
for code, n in sorted(lang_rows.items(), key=lambda kv: -kv[1]):
label = classify.LANG_LABELS.get(code, code)
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:
label = decade_label(d["key"])
top_genres_str = ", ".join(
f"{classify.BUCKET_LABELS.get(tg['bucket'], tg['bucket'])} {tg['tracks']}"
for tg in d["top_genres"]
)
lines.append(f"- {label} — {d['tracks']} ({_pct(d['tracks'], total)}%): {top_genres_str}")
lines.append("")
shown_artists = artists[:top_artists]
rest_artists = artists[top_artists:]
lines.append(f"## Топ-{len(shown_artists)} исполнителей")
lines.append("Счётчик — упоминания в треках (у трека может быть несколько исполнителей).")
lines.append("В скобках — самый частый под-жанр исполнителя, затем его собственные теги из каталога.")
for i, a in enumerate(shown_artists, 1):
fine_label = classify.FINE_LABELS.get(a["top_fine"], a["top_fine"])
tag_part = f"; {', '.join(a['tags'])}" if a["tags"] else ""
lines.append(f"{i}. {a['name']} — {a['tracks']} ({fine_label}{tag_part})")
if rest_artists:
rest_tracks = sum(a["tracks"] for a in rest_artists)
one_track = sum(1 for a in rest_artists if a["tracks"] == 1)
lines.append("")
lines.append(
f"Ещё {len(rest_artists)} исполнителей с {rest_artists[-1]['tracks']}-"
f"{rest_artists[0]['tracks']} треками, {rest_tracks} упоминаний суммарно; "
f"из них {one_track} — по одному треку."
)
lines.append("")
shown_albums = albums[:top_albums]
rest_albums = albums[top_albums:]
lines.append(f"## Топ-{len(shown_albums)} альбомов")
lines.append(wording.album_counter_note)
for i, alb in enumerate(shown_albums, 1):
year_part = f" ({alb['year']})" if alb["year"] else ""
lines.append(f"{i}. {alb['artists']} — {alb['title']}{year_part} — {alb['tracks']}")
if rest_albums:
deep_tracks = sum(a["tracks"] for a in albums if a["tracks"] >= 2)
lines.append("")
lines.append(
wording.rest_albums_note.format(
n=len(rest_albums), lo=rest_albums[-1]["tracks"], hi=rest_albums[0]["tracks"]
)
+ " "
+ wording.deep_albums_note.format(deep=deep_tracks, total=total, pct=_pct(deep_tracks, total))
)
lines.append("")
if other:
lines.append("## Разное (нераспознанные сырые жанры)")
for genre_raw, n in sorted(other.items(), key=lambda kv: -kv[1])[:5]:
lines.append(f"- {genre_raw}: {n}")
lines.append("")
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 = [
"# Разбор текстов",
"",
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
out_file.write_text(text, encoding="utf-8")
return out_file