Сегментатор: текст kinozal из raw JSON, кодек-строки не как негатив; e2e по 400 страниц

Обучение:
* page_text/kinozal_text: текст kinozal = h1 + «Техданные» + «Релиз» из
  data/raw/kinozal/*.json (в pages/kz-*.txt нет заголовка и «Техданных»,
  откуда половина меток kinozal; сервис видит обе вкладки).
* label_page: позитив — raw или audio_raw; неразмеченная кодек-строка
  русской дорожки («Аудио 3: … Русский (MVO, X)», «Аудио Rus: … | X»)
  получает -1 и в обучение не идёт: как негатив она учила отбрасывать
  кодек-строки. Сводка «Аудио: Русский, английский (…)» остаётся негативом.

Инференс (release):
* model_segments -> (основные, кодек); выбранные моделью кодек-строки идут
  второстепенным источником вместе с правилом codec_segments; иноязычные
  («Казахский (Dub, …)», «English Dubbing», «Original») отбрасываются.
* merge_extra_tracks(adopt_type): кодек-дорожка с типом «неизвестно»
  получает тип единственной дорожки страницы без студии.

e2e_segmenter: тот же текст kinozal, не больше E2E_MAX (400) страниц на
трекер, train-страницы только с --train-pages (меню парсит только тест).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-02 12:05:20 +07:00
1 parent 099d33bdaa
commit 6470f882f1
5 files changed
+191 -47

No files matched your search

+17 -7
View File
@@ -73,13 +73,23 @@ BIO-разметка строится по символьным спанам ч
используются, если `models/segmenter` нет или модель не нашла ни одной строки; используются, если `models/segmenter` нет или модель не нашла ни одной строки;
режим можно форсировать: `predict_release(..., segmenter="rules")`. режим можно форсировать: `predict_release(..., segmenter="rules")`.
* Обучение: `python -m src.segmenter --train` (~1 мин). Разметка строк — по * Обучение: `python -m src.segmenter --train --out models/segmenter_v2` (или меню
`raw` из `labels.csv` (362 страницы в `data/pages`); синтетика: `raw` всех `tools/collect.py` → «2»). Разметка строк — по `raw` и `audio_raw` из `labels.csv`
train-строк (в т.ч. nnm/kinozal без страниц) вставляются в контекст реальных (страницы `data/pages`); синтетика: `raw` всех train-строк вставляются в контекст
строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест). реальных строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
* Метрики: строки (test) P 0.936 / R 0.994; e2e на непросмотренных страницах * Текст kinozal берётся из `data/raw/kinozal/<id>.json` (`h1` + «Техданные» +
rutracker (n=64) — precision 0.904 → 0.946 против правил, recall 0.975 → 0.975 «Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни
(`tests/e2e_segmenter.py`; выборка мала, разброс большой). «Техданных», откуда взята половина меток kinozal, а сервис (`fetch_html`)
видит обе вкладки.
* Кодек-строка русской дорожки («Аудио 3: AC3 … Русский (MVO, LostFilm)»,
«Аудио Rus: … | LostFilm»), не попавшая в разметку, в обучение не идёт
(метка -1): у rutracker `raw` — строка «Перевод N:», и как негатив она учила
модель отбрасывать кодек-строки — а на kinozal студии только в них.
* В `predict_release` выбранные моделью кодек-строки — второстепенный источник
(`source: codec`): подключаются, только если у какой-то дорожки страницы нет
студии; если других строк нет (nnm), они и есть дорожки.
* e2e: `tests/e2e_segmenter.py [каталог]` — пары (тип, студия) на тестовых
страницах, не больше `E2E_MAX` (400) на трекер; `--train-pages` — ещё и train.
* Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена * Нарезка ВНУТРИ строки («Дублированный, многоголосый») в данных не размечена
(91 группа из 6735) — остаётся на правилах `_split_entries`, но только на (91 группа из 6735) — остаётся на правилах `_split_entries`, но только на
строках, выбранных моделью. строках, выбранных моделью.
+54 -22
View File
@@ -211,7 +211,8 @@ def same_studio(a: str | None, b: str | None) -> bool:
def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str, def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str,
match=lambda a, b: (a or "").casefold() == (b or "").casefold()) -> None: match=lambda a, b: (a or "").casefold() == (b or "").casefold(),
adopt_type: bool = False) -> None:
"""Добавляет дорожки второстепенного источника к дорожкам страницы (на месте). """Добавляет дорожки второстепенного источника к дорожкам страницы (на месте).
* такая студия уже есть (по `match`) -> пропуск; * такая студия уже есть (по `match`) -> пропуск;
@@ -219,13 +220,21 @@ def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str,
(source=page+<source>): это та же дорожка; (source=page+<source>): это та же дорожка;
* иначе -> новая дорожка (source=<source>). * иначе -> новая дорожка (source=<source>).
Дорожки без студии или с типом «неизвестно» не берутся: тип без студии Дорожки без студии не берутся: тип без студии страница уже даёт, а в
страница уже даёт, а в кодек-строках он часто грубее («одноголосый» кодек-строках он часто грубее («одноголосый» вместо «авт. одноголосый»).
вместо «авт. одноголосый»). Дорожка с типом «неизвестно» («Аудио Rus: AC3 … | LostFilm») берётся
только при `adopt_type` и если у страницы ровно один тип без студии
(«Перевод: Профессиональный многоголосый»): она получает этот тип.
""" """
bare = {x["translation_type"] for x in tracks
if not x.get("studio") and x["translation_type"] != UNKNOWN_TYPE}
for t in extra: for t in extra:
if not t.get("studio") or t["translation_type"] == UNKNOWN_TYPE: if not t.get("studio"):
continue continue
if t["translation_type"] == UNKNOWN_TYPE:
if not (adopt_type and len(bare) == 1):
continue
t["translation_type"] = next(iter(bare))
if any(match(x.get("studio"), t["studio"]) for x in tracks): if any(match(x.get("studio"), t["studio"]) for x in tracks):
continue continue
same = next((x for x in tracks if not x.get("studio") same = next((x for x in tracks if not x.get("studio")
@@ -245,11 +254,24 @@ def merge_title_tracks(tracks: list[dict], title_tracks: list[dict]) -> None:
merge_extra_tracks(tracks, title_tracks, "title") merge_extra_tracks(tracks, title_tracks, "title")
# кодек-строка дорожки с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; # кодек-строка: «Аудио 3: AC3 …», «Аудио Rus: …», «Аудиодорожка 1: …», «Audio #2: …»
# «Аудио 6: E-AC3 … English Dubbing» не подходит — нет русского маркера типа RE_AUDIO_PREFIX = re.compile(r"(?i)^\s*(?:Аудио\w*|Audio)\s*(?:№|#)?\s*(?:\d+|rus|ukr|eng)?\s*[:.]")
# с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»; «English Dubbing» — не тип
RE_CODEC_TRACK = re.compile( RE_CODEC_TRACK = re.compile(
r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b" r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b"
r"|дубл|многоголос|двухголос|одноголос|закадр|авторск)") r"|дубл|многоголос|двухголос|одноголос|закадр|авторск)")
RE_RU_TRACK = re.compile(r"(?i)русск|russian|\brus\b|украин|ukrain|\bukr\b")
RE_FOREIGN_TRACK = re.compile(
r"(?i)казах|english|англ|француз|немец|немецк|итальян|испан|японск|корейск|китайск"
r"|\boriginal\b|оригинал|\beng\b")
def codec_ok(line: str) -> bool:
"""Кодек-строка русской (украинской) дорожки: «Казахский (Dub, TK Qazaqstan)»,
«English Dubbing», «Original» — нет."""
if RE_RU_TRACK.search(line):
return True
return not RE_FOREIGN_TRACK.search(line) and bool(RE_CODEC_TRACK.match(line))
def _entries(line: str) -> list[str]: def _entries(line: str) -> list[str]:
@@ -258,31 +280,38 @@ def _entries(line: str) -> list[str]:
def codec_segments(page_text: str) -> list[str]: def codec_segments(page_text: str) -> list[str]:
"""Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)»). """Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)») по правилу.
Сегментатор обучен на `raw` датасета, где у rutracker это строки Запасной путь к тому, что выбирает сегментатор (`model_segments`): старые
«Перевод N:», поэтому кодек-строки он отбрасывает. Они нужны там, где веса сегментатора кодек-строки отбрасывают. Они нужны там, где студии есть
студии есть только в них (kinozal: вкладка «Релиз» против «Перевод:» без только в них (kinozal: вкладка «Релиз» против «Перевод:» без студий в
студий в «Техданных»), и идут второстепенным источником, см. «Техданных»), и идут второстепенным источником, см. `merge_extra_tracks`.
`merge_extra_tracks`.
""" """
out = [] out = []
for line in (page_text or "").splitlines(): for line in (page_text or "").splitlines():
if RE_CODEC_TRACK.match(line): if RE_CODEC_TRACK.match(line) and codec_ok(line):
out += _entries(line) out += _entries(line)
return out return out
def model_segments(segmenter, page_text: str) -> list[str]: def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]:
"""Дорожки страницы: строки выбирает обученный сегментатор. """Дорожки страницы: строки выбирает обученный сегментатор.
Возвращает (основные, кодек): строки «Перевод/Озвучка …» и кодек-строки
«Аудио N: AC3 … (MVO, LostFilm)». Кодек-строки обычно дублируют основные
в другом написании, поэтому они второстепенный источник (`predict_release`);
если основных нет (nnm: дорожки только в «Аудио N:») — они и есть дорожки.
Нарезка внутри строки («Дублированный, многоголосый») в данных не Нарезка внутри строки («Дублированный, многоголосый») в данных не
размечена и пока делается правилами `_split_entries`. размечена и пока делается правилами `_split_entries`.
""" """
out = [] main, codec = [], []
for line in segmenter.select_lines(page_text): for line in segmenter.select_lines(page_text):
out += _entries(line) if not RE_AUDIO_PREFIX.match(line):
return out main += _entries(line)
elif codec_ok(line):
codec += _entries(line)
return main, codec
def predict_release(predictor, *, html: str | None = None, text: str | None = None, def predict_release(predictor, *, html: str | None = None, text: str | None = None,
@@ -300,10 +329,12 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
if (html is None) == (text is None): if (html is None) == (text is None):
raise ValueError("нужно ровно одно из: html, text") raise ValueError("нужно ровно одно из: html, text")
page_text = html_to_text(html) if html is not None else text page_text = html_to_text(html) if html is not None else text
segs_page, used = [], "rules" segs_page, segs_codec, used = [], [], "rules"
seg_model = getattr(predictor, "segmenter", None) seg_model = getattr(predictor, "segmenter", None)
if segmenter == "model" and seg_model is not None: if segmenter == "model" and seg_model is not None:
segs_page = model_segments(seg_model, page_text) segs_page, segs_codec = model_segments(seg_model, page_text)
if not segs_page:
segs_page, segs_codec = segs_codec, []
used = "model" if segs_page else "rules" used = "model" if segs_page else "rules"
if not segs_page: if not segs_page:
segs_page = extract_segments(page_text) segs_page = extract_segments(page_text)
@@ -323,14 +354,15 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
# дорожки (kinozal: «Перевод: многоголосый» + «Аудио 3: … (MVO, LostFilm)»); # дорожки (kinozal: «Перевод: многоголосый» + «Аудио 3: … (MVO, LostFilm)»);
# иначе они дублируют «Перевод N:» в другом написании и дают ложные студии # иначе они дублируют «Перевод N:» в другом написании и дают ложные студии
seen = set(segs_page) seen = set(segs_page)
segs_codec = [x for x in codec_segments(page_text) if x not in seen] segs_codec = list(dict.fromkeys(x for x in segs_codec + codec_segments(page_text)
if x not in seen))
if segs_codec and (not tracks or any(not t.get("studio") for t in tracks)): if segs_codec and (not tracks or any(not t.get("studio") for t in tracks)):
codec = predictor._predict_batch(segs_codec) codec = predictor._predict_batch(segs_codec)
for t in codec: for t in codec:
ep = parse_episodes(t["segment"]) ep = parse_episodes(t["segment"])
t["episode_ranges"] = ep["ranges"] t["episode_ranges"] = ep["ranges"]
t["episodes"] = format_ranges(ep["ranges"]) or None t["episodes"] = format_ranges(ep["ranges"]) or None
merge_extra_tracks(tracks, codec, "codec", match=same_studio) merge_extra_tracks(tracks, codec, "codec", match=same_studio, adopt_type=True)
if title is None: if title is None:
title = title_from_html(html) if html is not None else title_from_text(text) title = title_from_html(html) if html is not None else title_from_text(text)
segs = title_segments(title or "") segs = title_segments(title or "")
+57 -8
View File
@@ -11,8 +11,10 @@ Python API:
seg = LineSegmenter("models/segmenter") seg = LineSegmenter("models/segmenter")
seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят) seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят)
Разметка: строка страницы положительна, если содержит `raw` строки датасета Разметка: строка страницы положительна, если содержит `raw` (или `audio_raw`)
этого релиза (или наоборот). Положительные примеры добавляются синтетически: строки датасета этого релиза (или наоборот); неразмеченные кодек-строки русских
дорожок в обучение не идут (см. `label_page`). Текст kinozal собирается из
data/raw/kinozal/*.json (`kinozal_text`). Положительные примеры добавляются синтетически:
`raw` всех train-строк датасета (включая nnm/kinozal, у которых нет страниц) `raw` всех train-строк датасета (включая nnm/kinozal, у которых нет страниц)
вставляются в контекст реальных строк страниц. Нарезка ВНУТРИ строки вставляются в контекст реальных строк страниц. Нарезка ВНУТРИ строки
(«Дублированный, многоголосый») в данных не размечена и остаётся на правилах. («Дублированный, многоголосый») в данных не размечена и остаётся на правилах.
@@ -69,8 +71,34 @@ def page_lines(text: str) -> list[str]:
# ---------------------------------------------------------------- данные # ---------------------------------------------------------------- данные
RAW_KINOZAL = Path("data/raw/kinozal")
def kinozal_text(release_id: str, raw_dir: Path = RAW_KINOZAL) -> str | None:
"""Текст страницы kinozal так, как его видит сервис: заголовок + «Техданные»
(«Перевод: …») + «Релиз» («Аудио N: … (MVO, LostFilm)»).
В data/pages/kz-*.txt нет ни заголовка, ни «Техданных», а метки kinozal
наполовину взяты именно оттуда, поэтому для kinozal берётся raw JSON.
"""
path = raw_dir / f"{str(release_id).removeprefix('kz-')}.json"
if not path.exists():
return None
j = json.loads(path.read_text(encoding="utf-8"))
return "\n".join(x for x in (j.get("h1"), j.get("tech_tab"), j.get("release_tab")) if x)
def page_text(path: Path) -> str:
"""Текст сохранённой страницы релиза (kinozal — из raw JSON, если он есть)."""
if path.stem.startswith("kz-"):
text = kinozal_text(path.stem, path.parent.parent / "raw" / "kinozal")
if text:
return text
return read_input(path)
def load_pages(pages_dir: Path = Path("data/pages")) -> dict[str, list[str]]: def load_pages(pages_dir: Path = Path("data/pages")) -> dict[str, list[str]]:
return {p.stem: page_lines(read_input(p)) for p in sorted(pages_dir.glob("*.txt"))} return {p.stem: page_lines(page_text(p)) for p in sorted(pages_dir.glob("*.txt"))}
def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]: def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
@@ -78,12 +106,28 @@ def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
return list(csv.DictReader(f)) return list(csv.DictReader(f))
# кодек-строка русской/украинской дорожки: «Аудио 2: AC3 … Русский (MVO, X)»,
# «Аудио Rus: AC3 … | LostFilm», «Аудиодорожка 1: … | Russian | TVShows»;
# сводка «Аудио: Русский, английский (AC3 …)» без номера — не дорожка
RE_AUDIO_TRACK = re.compile(
r"(?i)^\s*(?:(?:Аудио|Audio)\s*(?:rus|ukr)\s*[:.]"
r"|(?:Аудиодорожка\s*\d*|(?:Аудио|Audio)\s*(?:№|#)?\s*\d+)\s*[:.]"
r".*(?:русск|russian|\brus\b|украин|ukrain|\bukr\b))")
def label_page(lines: list[str], raws: list[str]) -> list[int]: def label_page(lines: list[str], raws: list[str]) -> list[int]:
"""1, если строка содержит raw (или raw содержит строку ≥ 15 символов).""" """1 — строка содержит raw (или raw содержит строку ≥ 15 символов);
-1 — неразмеченная кодек-строка русской дорожки: в обучение не идёт.
Кодек-строка «Аудио N: … (MVO, LostFilm)» — дорожка, но у rutracker
`raw` — строка «Перевод N:», а кодек-строка в разметку не попадает. Как
негатив она учила модель отбрасывать такие строки (на kinozal студии
только в них), поэтому неразмеченные кодек-строки пропускаются.
"""
out = [] out = []
for line in lines: for line in lines:
hit = any(r and (r in line or (len(line) >= 15 and line in r)) for r in raws) hit = any(r and (r in line or (len(line) >= 15 and line in r)) for r in raws)
out.append(int(hit)) out.append(1 if hit else (-1 if RE_AUDIO_TRACK.match(line) else 0))
return out return out
@@ -93,7 +137,10 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
rows = load_raws() rows = load_raws()
raws_by_rel: dict[str, list[str]] = {} raws_by_rel: dict[str, list[str]] = {}
for r in rows: for r in rows:
raws_by_rel.setdefault(r["release_id"], []).append(norm(r["raw"])) # audio_raw — кодек-строка той же дорожки («AC3 … Русский (MVO, X)»), тоже позитив
for text in (r["raw"], r.get("audio_raw")):
if norm(text):
raws_by_rel.setdefault(r["release_id"], []).append(norm(text))
want_test = split == "test" want_test = split == "test"
page_items = [] # (release_id, lines, labels) page_items = [] # (release_id, lines, labels)
@@ -105,7 +152,7 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
# все raw train-релизов (без тестовых страниц) — материал для синтетики # все raw train-релизов (без тестовых страниц) — материал для синтетики
test_pos = {l for rid, lines in pages.items() if is_test_release(rid) test_pos = {l for rid, lines in pages.items() if is_test_release(rid)
for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y} for l, y in zip(lines, label_page(lines, raws_by_rel.get(rid, []))) if y == 1}
pool = sorted({norm(r["raw"]) for r in rows pool = sorted({norm(r["raw"]) for r in rows
if r["raw"] and not is_test_release(r["release_id"]) if r["raw"] and not is_test_release(r["release_id"])
and norm(r["raw"]) not in test_pos}) and norm(r["raw"]) not in test_pos})
@@ -113,13 +160,15 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
ex: list[tuple] = [] ex: list[tuple] = []
for rid, lines, labels in page_items: for rid, lines, labels in page_items:
for i, (line, y) in enumerate(zip(lines, labels)): for i, (line, y) in enumerate(zip(lines, labels)):
if y < 0:
continue # неразмеченная кодек-строка: ни позитив, ни негатив
prev = lines[i - 1] if i else "" prev = lines[i - 1] if i else ""
nxt = lines[i + 1] if i + 1 < len(lines) else "" nxt = lines[i + 1] if i + 1 < len(lines) else ""
if not y and not HINT.search(line) and rng.random() > 0.15: if not y and not HINT.search(line) and rng.random() > 0.15:
continue # простые негативы прореживаем, трудные (с намёками) берём все continue # простые негативы прореживаем, трудные (с намёками) берём все
ex.append((*make_input(prev, line, nxt), y)) ex.append((*make_input(prev, line, nxt), y))
if not want_test: if not want_test:
negs = [l for l, y in zip(lines, labels) if not y] negs = [l for l, y in zip(lines, labels) if y == 0]
for _ in range(synth_per_page if negs else 0): for _ in range(synth_per_page if negs else 0):
raw = rng.choice(pool) raw = rng.choice(pool)
p, n = rng.choice(negs), rng.choice(negs) p, n = rng.choice(negs), rng.choice(negs)
+22 -10
View File
@@ -2,24 +2,29 @@
Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по
страницам, которых сегментатор не видел при обучении (is_test_release). страницам, которых сегментатор не видел при обучении (is_test_release).
Запуск: .venv/Scripts/python tests/e2e_segmenter.py Текст kinozal — из data/raw/kinozal/*.json (как у сервиса), см. segmenter.page_text.
На трекер берётся не больше E2E_MAX страниц (по умолчанию 400, детерминированно);
train-страницы считаются только с флагом --train-pages.
Запуск: .venv/Scripts/python tests/e2e_segmenter.py [каталог_сегментатора] [--train-pages]
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os
import sys import sys
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.html_extract import read_input # noqa: E402
from src.predict import DubPredictor # noqa: E402 from src.predict import DubPredictor # noqa: E402
from src.release import predict_release # noqa: E402 from src.release import predict_release # noqa: E402
from src.segmenter import is_test_release # noqa: E402 from src.segmenter import is_test_release, page_text # noqa: E402
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
MAX_PER_SOURCE = int(os.environ.get("E2E_MAX", "400"))
def run(predictor, rows, mode: str, test_only: bool) -> dict: def run(predictor, rows, mode: str, test_only: bool) -> dict:
@@ -27,17 +32,21 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
for r in rows: for r in rows:
by_rel[r["release_id"]].append(r) by_rel[r["release_id"]].append(r)
per_src = defaultdict(list) per_src = defaultdict(list)
for page in sorted((ROOT / "data" / "pages").glob("*.txt")): pages = [p for p in (ROOT / "data" / "pages").glob("*.txt")
if is_test_release(p.stem) == test_only and p.stem in by_rel]
pages.sort(key=lambda p: hashlib.md5(p.stem.encode()).hexdigest())
for page in pages:
rid = page.stem rid = page.stem
if is_test_release(rid) != test_only or rid not in by_rel: src = by_rel[rid][0]["source"]
if len(per_src[src]) >= MAX_PER_SOURCE:
continue continue
gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]} gold = {(r["translation_type"], r["studio"] or None) for r in by_rel[rid]}
res = predict_release(predictor, text=read_input(page), segmenter=mode) res = predict_release(predictor, text=page_text(page), segmenter=mode)
pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]} pred = {(t["translation_type"], t["studio"]) for t in res["tracks"]}
tp = len(gold & pred) tp = len(gold & pred)
p = tp / len(pred) if pred else 0.0 p = tp / len(pred) if pred else 0.0
r = tp / len(gold) if gold else 0.0 r = tp / len(gold) if gold else 0.0
per_src[by_rel[rid][0]["source"]].append((p, r)) per_src[src].append((p, r))
out = {} out = {}
for s, v in per_src.items(): for s, v in per_src.items():
out[s] = (len(v), sum(x[0] for x in v) / len(v), sum(x[1] for x in v) / len(v)) out[s] = (len(v), sum(x[0] for x in v) / len(v), sum(x[1] for x in v) / len(v))
@@ -46,13 +55,16 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
def main() -> None: def main() -> None:
rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8")) rows = json.loads((ROOT / "data" / "processed" / "dataset.json").read_text(encoding="utf-8"))
seg_dir = sys.argv[1] if len(sys.argv) > 1 else "models/segmenter" args = [a for a in sys.argv[1:] if not a.startswith("--")]
seg_dir = args[0] if args else "models/segmenter"
predictor = DubPredictor(segmenter_dir=seg_dir) predictor = DubPredictor(segmenter_dir=seg_dir)
print(f"сегментатор: {seg_dir}") print(f"сегментатор: {seg_dir}")
if predictor.segmenter is None: if predictor.segmenter is None:
sys.exit("нет models/segmenter: python -m src.segmenter --train") sys.exit("нет models/segmenter: python -m src.segmenter --train")
for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"), blocks = [(True, "тестовые страницы (не виделись сегментатору)")]
(False, "train-страницы (для справки, оптимистично)")): if "--train-pages" in sys.argv:
blocks.append((False, "train-страницы (для справки, оптимистично)"))
for test_only, name in blocks:
print(f"== {name} ==") print(f"== {name} ==")
for mode in ("rules", "model"): for mode in ("rules", "model"):
res = run(predictor, rows, mode, test_only) res = run(predictor, rows, mode, test_only)
+41
View File
@@ -246,6 +246,46 @@ merge_extra_tracks(_page2, [
check("codec: студия вписана, новая добавлена, без студии — нет", check("codec: студия вписана, новая добавлена, без студии — нет",
[(x["studio"], x["source"]) for x in _page2] [(x["studio"], x["source"]) for x in _page2]
== [("HDrezka", "page+codec"), ("LostFilm", "codec")]) == [("HDrezka", "page+codec"), ("LostFilm", "codec")])
_page3 = [dict(_t("проф. многоголосый", None), studio_span=None, studio_confidence=None)]
merge_extra_tracks(_page3, [
dict(_t("неизвестно", "LostFilm"), studio_span="LostFilm", studio_confidence=1),
dict(_t("неизвестно", "TVShows"), studio_span="TVShows", studio_confidence=1)],
"codec", match=same_studio, adopt_type=True)
check("codec: «Аудио Rus: … | LostFilm» получает тип дорожки страницы",
[(x["translation_type"], x["studio"]) for x in _page3]
== [("проф. многоголосый", "LostFilm"), ("проф. многоголосый", "TVShows")])
from src.release import codec_ok, model_segments # noqa: E402
from src.segmenter import kinozal_text, label_page # noqa: E402
class _FakeSeg:
def select_lines(self, text):
return text.splitlines()
_main, _codec = model_segments(_FakeSeg(), "Перевод: Профессиональный многоголосый\n"
"Аудио 3: AC3 - Русский (MVO, LostFilm)\n"
"Аудио 6: AC3 - Казахский (Dub, TK Qazaqstan)")
check("model_segments: основные и кодек-строки раздельно, иноязычная отброшена",
_main == ["Профессиональный многоголосый"] and _codec == ["AC3 - Русский (MVO, LostFilm)"])
check("codec_ok: «English Dubbing» и «Original» — не русская дорожка",
not codec_ok("Аудио 6: E-AC3 - English Dubbing") and not codec_ok("Аудио 5: E-AC3 - Original (французский)")
and codec_ok("Аудио Rus: AC3, 2 ch | LostFilm"))
check("label_page: raw — 1, неразмеченная кодек-строка — -1, сводка «Аудио:» — 0",
label_page(["Перевод: Дублированный", "Аудио 2: AC3 - Русский (MVO, X)",
"Аудио: Русский, английский (AC3)", "Размер: 6 ГБ"], ["Перевод: Дублированный"])
== [1, -1, 0, 0])
import json # noqa: E402
import tempfile as _tmp # noqa: E402
with _tmp.TemporaryDirectory() as _d:
Path(_d, "123.json").write_text(json.dumps({"h1": "X / Y / 2026 / ПМ (LostFilm)",
"tech_tab": "Перевод: Профессиональный многоголосый",
"release_tab": "Аудио Rus: AC3 | LostFilm"}), encoding="utf-8")
check("kinozal_text: заголовок + Техданные + Релиз из raw JSON",
kinozal_text("kz-123", Path(_d)).splitlines()
== ["X / Y / 2026 / ПМ (LostFilm)", "Перевод: Профессиональный многоголосый", "Аудио Rus: AC3 | LostFilm"]
and kinozal_text("kz-404", Path(_d)) is None)
_dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n" _dump = ("Меню\nГангстерленд / MobLand / 2025 / ПМ (LostFilm) / WEB\n"
"Гангстерленд / 2026 / ДБ (X) / WEB-DL") "Гангстерленд / 2026 / ДБ (X) / WEB-DL")
@@ -381,6 +421,7 @@ check("series_info: сезон, серии, total и «студия -> сери
print("== сборщик датасета (tools/collect.py) ==") print("== сборщик датасета (tools/collect.py) ==")
import csv as _csv # noqa: E402 import csv as _csv # noqa: E402
import json as _json # noqa: E402 import json as _json # noqa: E402
import json # noqa: E402
import tempfile as _tmp # noqa: E402 import tempfile as _tmp # noqa: E402
from pathlib import Path as _P # noqa: E402 from pathlib import Path as _P # noqa: E402