Merge pull request #1 from Horizontal42/fix/adversarial-review

Fix defects from adversarial code review
This commit is contained in:
Horizontal42 2026-08-19 04:14:41 +03:00 • committed by GitHub
commit b19eadd744
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 105 additions and 55 deletions

View file

@ -134,7 +134,7 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
fields flatten out. fields flatten out.
`themes` are free-form but pattern-constrained to English snake_case `themes` are free-form but pattern-constrained to English snake_case
(`^[a-z][a-z0-9_]{2,29}$`) — in the pilot, an unconstrained field drifted between Russian and (`^[a-z0-9][a-z0-9_-]{2,29}$`) — in the pilot, an unconstrained field drifted between Russian and
English inside a single run, making the values impossible to aggregate; the pattern makes English inside a single run, making the values impossible to aggregate; the pattern makes
Cyrillic mechanically unrepresentable at decode time. They are English for the same reason Cyrillic mechanically unrepresentable at decode time. They are English for the same reason
genre slugs and `ROOT_BUCKET` keys are: they are aggregation keys. `mood` is a closed enum that genre slugs and `ROOT_BUCKET` keys are: they are aggregation keys. `mood` is a closed enum that

View file

@ -134,7 +134,7 @@ report/apply/digest --source <url> → source.parse_playlist_url() → (user_id,
ограничение вместо этого — таймаут запроса. `temperature: 0.65`, не 0: при 0 нарративные поля ограничение вместо этого — таймаут запроса. `temperature: 0.65`, не 0: при 0 нарративные поля
становятся одноообразными. становятся одноообразными.
`themes` свободного формата, но ограничены паттерном англ. snake_case (`^[a-z][a-z0-9_]{2,29}$`) — `themes` свободного формата, но ограничены паттерном англ. snake_case (`^[a-z0-9][a-z0-9_-]{2,29}$`) —
в пилоте неограниченное поле дрейфовало между русским и английским в одном прогоне, что сделало в пилоте неограниченное поле дрейфовало между русским и английским в одном прогоне, что сделало
значения невозможными для агрегации; паттерн делает кириллицу механически невозможной при разборе. значения невозможными для агрегации; паттерн делает кириллицу механически невозможной при разборе.
Они англ. по той же причине, что и слаги жанров, и `ROOT_BUCKET`: это ключи агрегации. `mood` — Они англ. по той же причине, что и слаги жанров, и `ROOT_BUCKET`: это ключи агрегации. `mood` —

View file

@ -5,7 +5,7 @@ import json
import urllib.request import urllib.request
from collections import Counter from collections import Counter
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@ -41,7 +41,7 @@ STANCES = ["sincere", "ironic", "bitter"]
# фиксируются схемой: в пилоте без паттерна язык плавал между английским и русским внутри # фиксируются схемой: в пилоте без паттерна язык плавал между английским и русским внутри
# одного прогона. Паттерн на уровне JSON Schema делает кириллицу и составные значения # одного прогона. Паттерн на уровне JSON Schema делает кириллицу и составные значения
# механически невозможными на этапе генерации, а не отлавливаемыми постфактум. # механически невозможными на этапе генерации, а не отлавливаемыми постфактум.
THEME_PATTERN = "^[a-z][a-z0-9_]{2,29}$" THEME_PATTERN = "^[a-z0-9][a-z0-9_-]{2,29}$"
def _enum(values: list[str]) -> dict: def _enum(values: list[str]) -> dict:
@ -193,9 +193,13 @@ def call_ollama(settings: OllamaSettings, prompt: str) -> dict:
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
try:
with urllib.request.urlopen(request, timeout=settings.timeout) as response: with urllib.request.urlopen(request, timeout=settings.timeout) as response:
body = json.loads(response.read().decode("utf-8")) body = json.loads(response.read().decode("utf-8"))
return json.loads(body["message"]["content"]) 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: def _atomic_write_json(path: Path, data: dict) -> None:
@ -248,7 +252,7 @@ def analyze_tracks(
"model": settings.model, "model": settings.model,
"prompt_version": settings.prompt_version, "prompt_version": settings.prompt_version,
"lyrics_hash": text_hash, "lyrics_hash": text_hash,
"analyzed_at": datetime.now().isoformat(timespec="seconds"), "analyzed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
} }
try: try:
data = call(settings, prompt) data = call(settings, prompt)

View file

@ -27,7 +27,7 @@ def _existing_track_ids(client: Client, playlist: Playlist, max_attempts: int =
for attempt in range(max_attempts): for attempt in range(max_attempts):
full = with_retries(lambda: client.users_playlists(playlist.kind)) full = with_retries(lambda: client.users_playlists(playlist.kind))
if full is not None: if full is not None:
return {t.track_id for t in full.tracks} return {str(t.track_id).split(":")[0] for t in full.tracks}
if attempt < max_attempts - 1: if attempt < max_attempts - 1:
wait = 2.0 * (2**attempt) wait = 2.0 * (2**attempt)
print(f" пустой ответ при чтении плейлиста «{playlist.title}», повтор через {wait:.0f}с...") print(f" пустой ответ при чтении плейлиста «{playlist.title}», повтор через {wait:.0f}с...")
@ -91,7 +91,7 @@ def _insert_track_verified(
fresh = with_retries(lambda: client.users_playlists(playlist.kind)) fresh = with_retries(lambda: client.users_playlists(playlist.kind))
if fresh is not None: if fresh is not None:
revision = fresh.revision or revision revision = fresh.revision or revision
if track_key in {t.track_id for t in fresh.tracks}: if track_key in {str(t.track_id).split(":")[0] for t in fresh.tracks}:
return revision return revision
if last_attempt: if last_attempt:
raise raise
@ -188,37 +188,50 @@ def dedupe_playlists(client: Client, delay: float, dry_run: bool = True) -> None
playlists = with_retries(lambda: client.users_playlists_list()) playlists = with_retries(lambda: client.users_playlists_list())
total_ranges = 0 total_ranges = 0
total_tracks = 0 total_found = 0
total_removed = 0
for playlist in playlists: for playlist in playlists:
full = with_retries(lambda: client.users_playlists(playlist.kind)) full = with_retries(lambda: client.users_playlists(playlist.kind))
if full is None: if full is None:
print(f" не удалось прочитать «{playlist.title}», пропускаю") print(f" не удалось прочитать «{playlist.title}», пропускаю")
continue continue
ids = [t.track_id for t in full.tracks] ids = [str(t.track_id).split(":")[0] for t in full.tracks]
ranges = find_duplicate_ranges(ids) ranges = find_duplicate_ranges(ids)
if not ranges: if not ranges:
continue continue
n = sum(to - frm for frm, to in ranges) n = sum(to - frm for frm, to in ranges)
total_ranges += len(ranges) total_ranges += len(ranges)
total_tracks += n total_found += n
if dry_run: if dry_run:
print(f"{playlist.title}: найдено дублей {n} ({len(ranges)} диапазон(ов))") print(f"{playlist.title}: найдено дублей {n} ({len(ranges)} диапазон(ов))")
continue continue
revision = full.revision or 1 revision = full.revision or 1
removed = 0
for frm, to in sorted(ranges, key=lambda r: -r[0]): for frm, to in sorted(ranges, key=lambda r: -r[0]):
updated = client.users_playlists_delete_track(playlist.kind, frm, to, revision=revision) try:
revision = updated.revision if updated is not None and updated.revision is not None else revision + 1 updated = with_retries(lambda: client.users_playlists_delete_track(playlist.kind, frm, to, revision=revision))
if updated is not None and updated.revision is not None:
revision = updated.revision
except NetworkError as e:
print(f" сетевая ошибка при удалении из «{playlist.title}» ({e}), прерываю")
break
except BadRequestError as e:
print(f" ошибка при удалении из «{playlist.title}» ({e}), прерываю")
break
removed += to - frm
time.sleep(delay) time.sleep(delay)
print(f"{playlist.title}: удалено {n} дублей") total_removed += removed
suffix = "" if removed == n else f" из {n} найденных"
print(f"{playlist.title}: удалено {removed} дублей{suffix}")
if dry_run: if dry_run:
if total_tracks: if total_found:
print(f"\nВсего дублей найдено: {total_tracks} в {total_ranges} диапазон(ов). Запустите с --yes для удаления.") print(f"\nВсего дублей найдено: {total_found} в {total_ranges} диапазон(ов). Запустите с --yes для удаления.")
else: else:
print("\nДублей не найдено.") print("\nДублей не найдено.")
else: else:
print(f"\nВсего удалено дублей: {total_tracks}") print(f"\nВсего удалено дублей: {total_removed}")

View file

@ -15,7 +15,10 @@ def load_token(token_file: Path) -> str | None:
def save_token(token_file: Path, token: str) -> None: def save_token(token_file: Path, token: str) -> None:
token_file.parent.mkdir(parents=True, exist_ok=True) token_file.parent.mkdir(parents=True, exist_ok=True)
token_file.write_text(token, encoding="utf-8") import os
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
with open(os.open(token_file, flags, 0o600), "w", encoding="utf-8") as f:
f.write(token)
def _on_code(code: DeviceCode) -> None: def _on_code(code: DeviceCode) -> None:

View file

@ -203,7 +203,8 @@ def cmd_apply(args: argparse.Namespace) -> None:
catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir) catalog = genres.load_or_fetch_catalog(client, cfg.cache_dir)
artist_genres = fetch.load_artist_genres(cfg.cache_dir) artist_genres = fetch.load_artist_genres(cfg.cache_dir)
lang_cache = language.load_lang_cache(cfg.cache_dir) ids_needing_lang = [str(t["id"]) for t in tracks_cache.values() if t["lyrics_available"]]
lang_cache = language.fetch_api_languages(client, ids_needing_lang, cfg.cache_dir, cfg.lyrics_request_delay)
rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min) rows = report.build_rows(tracks_cache, lang_cache, catalog, artist_genres, cfg.small_group_min)
apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit) apply_mod.apply_classification(client, rows, cfg.apply_request_delay, limit=args.limit)

View file

@ -4,7 +4,7 @@ import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path.cwd()
@dataclass(frozen=True) @dataclass(frozen=True)
@ -29,6 +29,7 @@ class Config:
def load_config(path: Path | None = None) -> Config: def load_config(path: Path | None = None) -> Config:
path = path or ROOT / "config.toml" path = path or ROOT / "config.toml"
try:
with path.open("rb") as f: with path.open("rb") as f:
data = tomllib.load(f) data = tomllib.load(f)
@ -50,3 +51,5 @@ def load_config(path: Path | None = None) -> Config:
cache_dir=ROOT / data["paths"]["cache_dir"], cache_dir=ROOT / data["paths"]["cache_dir"],
out_dir=ROOT / data["paths"]["out_dir"], out_dir=ROOT / data["paths"]["out_dir"],
) )
except (KeyError, tomllib.TOMLDecodeError) as e:
raise SystemExit(f"Ошибка в конфигурационном файле {path}: {type(e).__name__}: {e}") from e

View file

@ -492,5 +492,7 @@ def render_lyrics_digest(tracks_cache: dict[str, dict], analysis: dict[str, dict
def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path: def write_digest(text: str, out_dir: Path, filename: str = DIGEST_FILE) -> Path:
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
out_file = out_dir / filename out_file = out_dir / filename
out_file.write_text(text, encoding="utf-8") tmp = out_file.with_suffix(".tmp")
tmp.write_text(text, encoding="utf-8")
tmp.replace(out_file)
return out_file return out_file

View file

@ -100,7 +100,10 @@ def fetch_liked_tracks(
# пересчитанному из albums[0] вернувшегося Track - для трека, доступного на нескольких # пересчитанному из albums[0] вернувшегося Track - для трека, доступного на нескольких
# альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут. # альбомах, "основной" альбом в ответе может отличаться от того, под которым трек лайкнут.
# Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый". # Иначе такой трек тут же попадает под stale-очистку ниже как "больше не лайкнутый".
for original_id, track in zip(batch, tracks): by_id = {str(t.id): t for t in tracks}
for original_id in batch:
track = by_id.get(original_id.split(":")[0])
if track is not None:
cache[original_id] = serialize_track(track, added_at=added_at_by_id.get(original_id.split(":")[0])) cache[original_id] = serialize_track(track, added_at=added_at_by_id.get(original_id.split(":")[0]))
_atomic_write_json(cache_file, cache) _atomic_write_json(cache_file, cache)
print(f" загружено {len(cache)}/{len(all_ids)}") print(f" загружено {len(cache)}/{len(all_ids)}")

View file

@ -6,6 +6,7 @@ from pathlib import Path
from yandex_music import Client, Genre from yandex_music import Client, Genre
from .fetch import _atomic_write_json
from .ymclient import with_retries from .ymclient import with_retries
GENRE_CATALOG_FILE = "genre_catalog.json" GENRE_CATALOG_FILE = "genre_catalog.json"
@ -114,7 +115,7 @@ def load_or_fetch_catalog(client: Client, cache_dir: Path) -> dict[str, dict]:
roots = with_retries(lambda: client.genres()) roots = with_retries(lambda: client.genres())
flat = flatten_catalog(roots) flat = flatten_catalog(roots)
cache_file = cache_dir / GENRE_CATALOG_FILE cache_file = cache_dir / GENRE_CATALOG_FILE
cache_file.write_text(json.dumps(flat, ensure_ascii=False, indent=2), encoding="utf-8") _atomic_write_json(cache_file, flat)
return flat return flat

View file

@ -45,6 +45,8 @@ def alphabet_heuristic(title: str, artists: list[str]) -> str | None:
text = " ".join([title, *artists]) text = " ".join([title, *artists])
cyrillic, latin = _script_counts(text) cyrillic, latin = _script_counts(text)
if cyrillic == 0 and latin == 0: if cyrillic == 0 and latin == 0:
if any(ch.isalpha() for ch in text):
return "INT"
return None return None
if cyrillic > latin: if cyrillic > latin:
return "RU" return "RU"
@ -99,14 +101,14 @@ def load_lang_cache(cache_dir: Path) -> dict[str, str | None]:
return {} return {}
def _fetch_one_language(client: Client, track_id: str) -> str | None: def _fetch_supplement(client: Client, track_id: str) -> tuple[str | None, str | None]:
try: try:
supplement = with_retries(lambda: client.track_supplement(track_id)) supplement = with_retries(lambda: client.track_supplement(track_id))
except NotFoundError: except NotFoundError:
return None return None, None
if supplement is None or supplement.lyrics is None: if supplement is None or supplement.lyrics is None:
return None return None, None
return supplement.lyrics.text_language return supplement.lyrics.text_language, supplement.lyrics.full_lyrics
def fetch_api_languages( def fetch_api_languages(
@ -119,17 +121,28 @@ def fetch_api_languages(
cache_file = cache_dir / LYRICS_LANG_CACHE_FILE cache_file = cache_dir / LYRICS_LANG_CACHE_FILE
cache = load_lang_cache(cache_dir) cache = load_lang_cache(cache_dir)
text_cache_file = cache_dir / "lyrics_text.json"
if text_cache_file.exists():
text_cache = json.loads(text_cache_file.read_text(encoding="utf-8"))
else:
text_cache = {}
missing = [tid for tid in track_ids if tid not in cache] missing = [tid for tid in track_ids if tid not in cache]
if not missing: if not missing:
return cache return cache
print(f"Запрос языка текста песни для {len(missing)} треков...") print(f"Запрос языка текста песни для {len(missing)} треков...")
for i, tid in enumerate(missing, 1): for i, tid in enumerate(missing, 1):
cache[tid] = _fetch_one_language(client, tid) lang, text = _fetch_supplement(client, tid)
cache[tid] = lang
if text is not None and normalize_api_language(lang) == "RU":
text_cache[tid] = text
if i % 20 == 0: if i % 20 == 0:
_atomic_write_json(cache_file, cache) _atomic_write_json(cache_file, cache)
_atomic_write_json(text_cache_file, text_cache)
print(f" обработано {i}/{len(missing)}") print(f" обработано {i}/{len(missing)}")
time.sleep(delay) time.sleep(delay)
_atomic_write_json(cache_file, cache) _atomic_write_json(cache_file, cache)
_atomic_write_json(text_cache_file, text_cache)
return cache return cache

View file

@ -103,6 +103,8 @@ def build_rows(
for t, fine, lang in prelim: for t, fine, lang in prelim:
if group_sizes[(fine, lang)] < small_group_min: if group_sizes[(fine, lang)] < small_group_min:
bucket = genres.coarse_of(fine) bucket = genres.coarse_of(fine)
if bucket == fine:
bucket = "other"
else: else:
bucket = fine bucket = fine
playlist = classify.playlist_name(bucket, lang) playlist = classify.playlist_name(bucket, lang)

View file

@ -18,7 +18,7 @@ from yandex_music.exceptions import NotFoundError, UnauthorizedError
from . import fetch from . import fetch
from .ymclient import chunked, with_retries from .ymclient import chunked, with_retries
HOST_RE = re.compile(r"^(?:www\.)?music\.yandex\.[a-z]{2,3}$", re.IGNORECASE) HOST_RE = re.compile(r"^(?:www\.|m\.)?music\.yandex\.[a-z\.]+$", re.IGNORECASE)
# "lk." (личный кабинет) - префикс у ссылок на персональные плейлисты (например, "Мне нравится"), # "lk." (личный кабинет) - префикс у ссылок на персональные плейлисты (например, "Мне нравится"),
# в отличие от чистого uuid у редакционных плейлистов - подтверждено вживую через client.playlist(): # в отличие от чистого uuid у редакционных плейлистов - подтверждено вживую через client.playlist():
# без префикса API отвечает NotFoundError, префикс - часть самого uuid, а не что-то, что надо отрезать. # без префикса API отвечает NotFoundError, префикс - часть самого uuid, а не что-то, что надо отрезать.

View file

@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import time import time
from typing import Callable, Iterator, List, TypeVar from collections.abc import Callable, Iterator, Sequence
from typing import TypeVar
from yandex_music import Client from yandex_music import Client
from yandex_music.exceptions import BadRequestError, NetworkError, NotFoundError from yandex_music.exceptions import BadRequestError, NetworkError, NotFoundError
@ -16,7 +17,9 @@ def make_client(token: str, request_timeout: float = 20) -> Client:
return client return client
def chunked(items: List[T], size: int) -> Iterator[List[T]]: def chunked(items: Sequence[T], size: int) -> Iterator[Sequence[T]]:
if size <= 0:
raise ValueError("size must be > 0")
for i in range(0, len(items), size): for i in range(0, len(items), size):
yield items[i : i + size] yield items[i : i + size]
@ -28,6 +31,8 @@ def with_retries(fn: Callable[[], T], max_attempts: int = 4, base_delay: float =
(данные точно не найдены / запрос точно невалиден), а не сбои сети, хотя формально (данные точно не найдены / запрос точно невалиден), а не сбои сети, хотя формально
оба тоже унаследованы от NetworkError. Повтор такого запроса даст тот же результат. оба тоже унаследованы от NetworkError. Повтор такого запроса даст тот же результат.
""" """
if max_attempts <= 0:
max_attempts = 1
last_exc: NetworkError | None = None last_exc: NetworkError | None = None
for attempt in range(max_attempts): for attempt in range(max_attempts):
try: try:
@ -38,7 +43,7 @@ def with_retries(fn: Callable[[], T], max_attempts: int = 4, base_delay: float =
last_exc = e last_exc = e
if attempt < max_attempts - 1: if attempt < max_attempts - 1:
wait = base_delay * (2**attempt) wait = base_delay * (2**attempt)
print(f" сетевая ошибка ({e}), повтор через {wait:.0f}с...") print(f" сетевая ошибка ({type(e).__name__}: {e}), повтор через {wait:.0f}с...")
time.sleep(wait) time.sleep(wait)
assert last_exc is not None assert last_exc is not None