mirror of
https://github.com/Horizontal42/Sort_YM.git
synced 2026-11-10 15:42:02 +03:00
with_retries caught bare Exception with a type whitelist, masking real bugs as network errors under retry/backoff - narrowed back to NetworkError. fetch_api_languages was writing full lyrics text for every language into lyrics_text.json via its track_supplement side channel, breaking the documented RU-only invariant and letting analyze feed foreign-language lyrics through the Russian-only prompt - now gated on normalize_api_language. dedupe_playlists logged the found duplicate count as "removed" even when a mid-loop network/API error cut deletion short. THEME_PATTERN's relaxed regex allowed 2-char themes, contradicting its own test; current_themes was hoisted out of the analyze loop so later prompts never saw themes found earlier in the same run. Both reverted to intended behavior, docs updated to match.
276 lines
11 KiB
Python
276 lines
11 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import urllib.request
|
||
from collections import Counter
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
ANALYSIS_CACHE_FILE = "lyrics_analysis.json"
|
||
|
||
# Настроение - закрытый список: структурная часть схемы существует ради будущей
|
||
# фильтрации/агрегации ("плейлист по настроению"), а свободная строка в пилоте схлопывалась
|
||
# в составные значения вроде "aggressive/triumphant". Ирония/сарказм сюда намеренно не входят -
|
||
# это ось stance, смешение осей и порождало составные значения.
|
||
MOODS = [
|
||
"melancholy",
|
||
"longing",
|
||
"nostalgia",
|
||
"tenderness",
|
||
"sensuality",
|
||
"euphoria",
|
||
"serenity",
|
||
"playfulness",
|
||
"defiance",
|
||
"rage",
|
||
"anxiety",
|
||
"despair",
|
||
"grief",
|
||
"resolve",
|
||
]
|
||
EMOTIONAL_ARCS = ["static", "descent", "uplift", "turn"]
|
||
POVS = ["confessional", "narrative", "abstract", "dialogue"]
|
||
REGISTERS = ["poetic", "conversational", "slang", "archaic"]
|
||
CONCRETENESS = ["concrete", "mixed", "abstract"]
|
||
STANCES = ["sincere", "ironic", "bitter"]
|
||
|
||
# Темы не закрытый enum (специфичность вроде digital_aging ценна), но язык и формат
|
||
# фиксируются схемой: в пилоте без паттерна язык плавал между английским и русским внутри
|
||
# одного прогона. Паттерн на уровне JSON Schema делает кириллицу и составные значения
|
||
# механически невозможными на этапе генерации, а не отлавливаемыми постфактум.
|
||
THEME_PATTERN = "^[a-z0-9][a-z0-9_-]{2,29}$"
|
||
|
||
|
||
def _enum(values: list[str]) -> dict:
|
||
return {"type": "string", "enum": values}
|
||
|
||
|
||
RESPONSE_SCHEMA: dict = {
|
||
"type": "object",
|
||
"properties": {
|
||
"mood": {
|
||
"type": "array",
|
||
"items": _enum(MOODS),
|
||
"minItems": 1,
|
||
"maxItems": 3,
|
||
"uniqueItems": True,
|
||
},
|
||
"themes": {
|
||
"type": "array",
|
||
"items": {"type": "string", "pattern": THEME_PATTERN},
|
||
"minItems": 2,
|
||
"maxItems": 5,
|
||
},
|
||
"emotional_arc": _enum(EMOTIONAL_ARCS),
|
||
"pov": _enum(POVS),
|
||
"register": _enum(REGISTERS),
|
||
"concreteness": _enum(CONCRETENESS),
|
||
"stance": _enum(STANCES),
|
||
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||
"summary": {"type": "string"},
|
||
"resonance": {"type": "string"},
|
||
"key_line": {"type": "string"},
|
||
},
|
||
"required": [
|
||
"mood",
|
||
"themes",
|
||
"emotional_arc",
|
||
"pov",
|
||
"register",
|
||
"concreteness",
|
||
"stance",
|
||
"confidence",
|
||
"summary",
|
||
"resonance",
|
||
"key_line",
|
||
],
|
||
"additionalProperties": False,
|
||
}
|
||
|
||
|
||
def lyrics_hash(text: str) -> str:
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _normalized(text: str) -> str:
|
||
return " ".join(text.split()).casefold()
|
||
|
||
|
||
def verify_key_line(key_line: str, lyrics: str) -> bool:
|
||
"""Цитата ли key_line на самом деле - модель иногда пересказывает вместо цитирования."""
|
||
if not key_line.strip():
|
||
return False
|
||
return _normalized(key_line) in _normalized(lyrics)
|
||
|
||
|
||
def is_fresh(entry: dict | None, model: str, prompt_version: int, text_hash: str) -> bool:
|
||
if not entry or "error" in entry:
|
||
return False
|
||
return (
|
||
entry.get("model") == model
|
||
and entry.get("prompt_version") == prompt_version
|
||
and entry.get("lyrics_hash") == text_hash
|
||
)
|
||
|
||
|
||
def top_themes(cache: dict[str, dict], limit: int = 60) -> list[str]:
|
||
"""Самые частые уже присвоенные темы - подмешиваются в промпт, чтобы словарь тем
|
||
сходился по всей библиотеке, а не расползался в синонимы."""
|
||
counter: Counter[str] = Counter()
|
||
for entry in cache.values():
|
||
counter.update(entry.get("themes", []))
|
||
return [theme for theme, _ in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]]
|
||
|
||
|
||
def load_analysis(cache_dir: Path) -> dict[str, dict]:
|
||
cache_file = cache_dir / ANALYSIS_CACHE_FILE
|
||
if cache_file.exists():
|
||
return json.loads(cache_file.read_text(encoding="utf-8"))
|
||
return {}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OllamaSettings:
|
||
host: str
|
||
model: str
|
||
prompt_version: int
|
||
timeout: float
|
||
keep_alive: str
|
||
|
||
|
||
PROMPT_TEMPLATE = """Ты разбираешь текст песни для профиля музыкального вкуса одного слушателя.
|
||
|
||
Трек: «{title}» — {artists}
|
||
|
||
Текст:
|
||
\"\"\"
|
||
{lyrics}
|
||
\"\"\"
|
||
{themes_hint}
|
||
Заполни поля схемы. Требования:
|
||
- summary: 2-4 предложения по-русски о том, что в песне происходит и как это подано.
|
||
- resonance: 1-2 предложения по-русски о том, чем этот текст может цеплять слушателя.
|
||
- key_line: одна строка ДОСЛОВНО из текста выше, без изменений и без пересказа.
|
||
- themes: 2-5 тем на английском в snake_case (1-3 слова, единственное число).
|
||
- Остальные поля выбирай строго из допустимых значений схемы.
|
||
"""
|
||
|
||
THEMES_HINT_TEMPLATE = """
|
||
Уже использованные темы в этой библиотеке (переиспользуй подходящую, иначе придумай новую):
|
||
{themes}
|
||
"""
|
||
|
||
|
||
def build_prompt(title: str, artists: list[str], lyrics: str, known_themes: list[str]) -> str:
|
||
themes_hint = THEMES_HINT_TEMPLATE.format(themes=", ".join(known_themes)) if known_themes else ""
|
||
return PROMPT_TEMPLATE.format(
|
||
title=title,
|
||
artists=", ".join(artists),
|
||
lyrics=lyrics,
|
||
themes_hint=themes_hint,
|
||
)
|
||
|
||
|
||
def call_ollama(settings: OllamaSettings, prompt: str) -> dict:
|
||
"""think=True уводит рассуждение в отдельное поле message.thinking, а format (полная
|
||
JSON Schema, не строка "json") констрейнит только финальный ответ - модель думает
|
||
свободно, результат при этом гарантированно парсится. num_predict=-1: капать вывод
|
||
нельзя, обрыв на середине даст невалидный JSON вместо просто короткого ответа."""
|
||
payload = {
|
||
"model": settings.model,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"stream": False,
|
||
"think": True,
|
||
"format": RESPONSE_SCHEMA,
|
||
"keep_alive": settings.keep_alive,
|
||
"options": {"num_predict": -1, "temperature": 0.65},
|
||
}
|
||
request = urllib.request.Request(
|
||
f"{settings.host.rstrip('/')}/api/chat",
|
||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=settings.timeout) as response:
|
||
body = json.loads(response.read().decode("utf-8"))
|
||
return json.loads(body["message"]["content"])
|
||
except urllib.error.HTTPError as e:
|
||
error_body = e.read().decode("utf-8", errors="replace")
|
||
raise RuntimeError(f"HTTP Error {e.code}: {e.reason}\n{error_body}") from e
|
||
|
||
|
||
def _atomic_write_json(path: Path, data: dict) -> None:
|
||
tmp = path.with_suffix(".tmp")
|
||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
tmp.replace(path)
|
||
|
||
|
||
def _track_meta(tracks_cache: dict[str, dict]) -> dict[str, dict]:
|
||
"""Числовой id -> название и исполнители. Ключ tracks_cache составной ("id:album_id"),
|
||
а кэши текста и разбора живут на числовом id, как и cache/lyrics_lang.json."""
|
||
return {str(t["id"]): {"title": t["title"], "artists": t["artists"]} for t in tracks_cache.values()}
|
||
|
||
|
||
def analyze_tracks(
|
||
tracks_cache: dict[str, dict],
|
||
lyrics_cache: dict[str, str | None],
|
||
cache_dir: Path,
|
||
settings: OllamaSettings,
|
||
limit: int | None = None,
|
||
call: Callable[[OllamaSettings, str], dict] = call_ollama,
|
||
) -> dict[str, dict]:
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
cache_file = cache_dir / ANALYSIS_CACHE_FILE
|
||
cache = load_analysis(cache_dir)
|
||
meta = _track_meta(tracks_cache)
|
||
|
||
pending = []
|
||
for tid, text in lyrics_cache.items():
|
||
if not text:
|
||
continue
|
||
text_hash = lyrics_hash(text)
|
||
if is_fresh(cache.get(tid), settings.model, settings.prompt_version, text_hash):
|
||
continue
|
||
pending.append((tid, text, text_hash))
|
||
|
||
if limit is not None:
|
||
pending = pending[:limit]
|
||
|
||
if not pending:
|
||
print("Все тексты уже разобраны.")
|
||
return cache
|
||
|
||
print(f"Разбор текстов: {len(pending)} треков, модель {settings.model}")
|
||
failed = 0
|
||
for i, (tid, text, text_hash) in enumerate(pending, 1):
|
||
info = meta.get(tid, {"title": tid, "artists": []})
|
||
prompt = build_prompt(info["title"], info["artists"], text, top_themes(cache))
|
||
stamp = {
|
||
"model": settings.model,
|
||
"prompt_version": settings.prompt_version,
|
||
"lyrics_hash": text_hash,
|
||
"analyzed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||
}
|
||
try:
|
||
data = call(settings, prompt)
|
||
entry = {**data, **stamp, "key_line_verified": verify_key_line(data["key_line"], text)}
|
||
# Широкий except намеренно: трёхчасовой прогон не должен падать целиком из-за
|
||
# одного зависшего/битого ответа локальной модели - в том числе синтаксически
|
||
# валидного JSON без обязательного поля (KeyError здесь же, не только в call()).
|
||
# KeyboardInterrupt и SystemExit наследуются от BaseException и сюда не попадают -
|
||
# Ctrl+C прерывает прогон.
|
||
except Exception as e:
|
||
cache[tid] = {**stamp, "error": f"{type(e).__name__}: {e}"}
|
||
failed += 1
|
||
print(f" [{i}/{len(pending)}] {info['title']} - ошибка: {type(e).__name__}")
|
||
else:
|
||
cache[tid] = entry
|
||
print(f" [{i}/{len(pending)}] {info['title']} - {', '.join(entry.get('mood', []))}")
|
||
_atomic_write_json(cache_file, cache)
|
||
|
||
if failed:
|
||
print(f" не разобрано: {failed} - повторный запуск analyze попробует их снова")
|
||
return cache
|