Сегментатор: текст 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:
1 parent
099d33bdaa
commit
6470f882f1
5 files changed
+191
-47
No files matched your search
+17
-7
@@ -73,13 +73,23 @@ BIO-разметка строится по символьным спанам ч
|
||||
используются, если `models/segmenter` нет или модель не нашла ни одной строки;
|
||||
режим можно форсировать: `predict_release(..., segmenter="rules")`.
|
||||
|
||||
* Обучение: `python -m src.segmenter --train` (~1 мин). Разметка строк — по
|
||||
`raw` из `labels.csv` (362 страницы в `data/pages`); синтетика: `raw` всех
|
||||
train-строк (в т.ч. nnm/kinozal без страниц) вставляются в контекст реальных
|
||||
строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
|
||||
* Метрики: строки (test) P 0.936 / R 0.994; e2e на непросмотренных страницах
|
||||
rutracker (n=64) — precision 0.904 → 0.946 против правил, recall 0.975 → 0.975
|
||||
(`tests/e2e_segmenter.py`; выборка мала, разброс большой).
|
||||
* Обучение: `python -m src.segmenter --train --out models/segmenter_v2` (или меню
|
||||
`tools/collect.py` → «2»). Разметка строк — по `raw` и `audio_raw` из `labels.csv`
|
||||
(страницы `data/pages`); синтетика: `raw` всех train-строк вставляются в контекст
|
||||
реальных строк страниц. Страницы делятся по хешу `release_id` (≈20% в тест).
|
||||
* Текст kinozal берётся из `data/raw/kinozal/<id>.json` (`h1` + «Техданные» +
|
||||
«Релиз», `segmenter.page_text`): в `data/pages/kz-*.txt` нет ни заголовка, ни
|
||||
«Техданных», откуда взята половина меток 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`, но только на
|
||||
строках, выбранных моделью.
|
||||
|
||||
+54
-22
@@ -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,
|
||||
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`) -> пропуск;
|
||||
@@ -219,13 +220,21 @@ def merge_extra_tracks(tracks: list[dict], extra: list[dict], source: str,
|
||||
(source=page+<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:
|
||||
if not t.get("studio") or t["translation_type"] == UNKNOWN_TYPE:
|
||||
if not t.get("studio"):
|
||||
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):
|
||||
continue
|
||||
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")
|
||||
|
||||
|
||||
# кодек-строка дорожки с типом озвучки: «Аудио 3: AC3 … (MVO, HDrezka Studio)»;
|
||||
# «Аудио 6: E-AC3 … English Dubbing» не подходит — нет русского маркера типа
|
||||
# кодек-строка: «Аудио 3: AC3 …», «Аудио Rus: …», «Аудиодорожка 1: …», «Audio #2: …»
|
||||
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(
|
||||
r"(?i)^\s*(?:Аудио|Audio)\s*(?:№|#)?\s*\d*\s*:.*(?:\b(?:MVO|DVO|AVO|DUB|VO)\b"
|
||||
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]:
|
||||
@@ -258,31 +280,38 @@ def _entries(line: str) -> list[str]:
|
||||
|
||||
|
||||
def codec_segments(page_text: str) -> list[str]:
|
||||
"""Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)»).
|
||||
"""Кодек-строки с типом озвучки («Аудио 2: AC3 … (MVO, LostFilm)») по правилу.
|
||||
|
||||
Сегментатор обучен на `raw` датасета, где у rutracker это строки
|
||||
«Перевод N:», поэтому кодек-строки он отбрасывает. Они нужны там, где
|
||||
студии есть только в них (kinozal: вкладка «Релиз» против «Перевод:» без
|
||||
студий в «Техданных»), и идут второстепенным источником, см.
|
||||
`merge_extra_tracks`.
|
||||
Запасной путь к тому, что выбирает сегментатор (`model_segments`): старые
|
||||
веса сегментатора кодек-строки отбрасывают. Они нужны там, где студии есть
|
||||
только в них (kinozal: вкладка «Релиз» против «Перевод:» без студий в
|
||||
«Техданных»), и идут второстепенным источником, см. `merge_extra_tracks`.
|
||||
"""
|
||||
out = []
|
||||
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)
|
||||
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`.
|
||||
"""
|
||||
out = []
|
||||
main, codec = [], []
|
||||
for line in segmenter.select_lines(page_text):
|
||||
out += _entries(line)
|
||||
return out
|
||||
if not RE_AUDIO_PREFIX.match(line):
|
||||
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,
|
||||
@@ -300,10 +329,12 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
|
||||
if (html is None) == (text is None):
|
||||
raise ValueError("нужно ровно одно из: html, 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)
|
||||
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"
|
||||
if not segs_page:
|
||||
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)»);
|
||||
# иначе они дублируют «Перевод N:» в другом написании и дают ложные студии
|
||||
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)):
|
||||
codec = predictor._predict_batch(segs_codec)
|
||||
for t in codec:
|
||||
ep = parse_episodes(t["segment"])
|
||||
t["episode_ranges"] = ep["ranges"]
|
||||
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:
|
||||
title = title_from_html(html) if html is not None else title_from_text(text)
|
||||
segs = title_segments(title or "")
|
||||
|
||||
+57
-8
@@ -11,8 +11,10 @@ Python API:
|
||||
seg = LineSegmenter("models/segmenter")
|
||||
seg.select_lines(page_text) -> list[str] # строки-дорожки (префикс не снят)
|
||||
|
||||
Разметка: строка страницы положительна, если содержит `raw` строки датасета
|
||||
этого релиза (или наоборот). Положительные примеры добавляются синтетически:
|
||||
Разметка: строка страницы положительна, если содержит `raw` (или `audio_raw`)
|
||||
строки датасета этого релиза (или наоборот); неразмеченные кодек-строки русских
|
||||
дорожок в обучение не идут (см. `label_page`). Текст kinozal собирается из
|
||||
data/raw/kinozal/*.json (`kinozal_text`). Положительные примеры добавляются синтетически:
|
||||
`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]]:
|
||||
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]:
|
||||
@@ -78,12 +106,28 @@ def load_raws(csv_path: Path = Path("data/labels.csv")) -> list[dict]:
|
||||
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]:
|
||||
"""1, если строка содержит raw (или raw содержит строку ≥ 15 символов)."""
|
||||
"""1 — строка содержит raw (или raw содержит строку ≥ 15 символов);
|
||||
-1 — неразмеченная кодек-строка русской дорожки: в обучение не идёт.
|
||||
|
||||
Кодек-строка «Аудио N: … (MVO, LostFilm)» — дорожка, но у rutracker
|
||||
`raw` — строка «Перевод N:», а кодек-строка в разметку не попадает. Как
|
||||
негатив она учила модель отбрасывать такие строки (на kinozal студии
|
||||
только в них), поэтому неразмеченные кодек-строки пропускаются.
|
||||
"""
|
||||
out = []
|
||||
for line in lines:
|
||||
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
|
||||
|
||||
|
||||
@@ -93,7 +137,10 @@ def build_examples(split: str, rng: random.Random, synth_per_page: int = 6) -> l
|
||||
rows = load_raws()
|
||||
raws_by_rel: dict[str, list[str]] = {}
|
||||
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"
|
||||
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-релизов (без тестовых страниц) — материал для синтетики
|
||||
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
|
||||
if r["raw"] and not is_test_release(r["release_id"])
|
||||
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] = []
|
||||
for rid, lines, labels in page_items:
|
||||
for i, (line, y) in enumerate(zip(lines, labels)):
|
||||
if y < 0:
|
||||
continue # неразмеченная кодек-строка: ни позитив, ни негатив
|
||||
prev = lines[i - 1] if i else ""
|
||||
nxt = lines[i + 1] if i + 1 < len(lines) else ""
|
||||
if not y and not HINT.search(line) and rng.random() > 0.15:
|
||||
continue # простые негативы прореживаем, трудные (с намёками) берём все
|
||||
ex.append((*make_input(prev, line, nxt), y))
|
||||
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):
|
||||
raw = rng.choice(pool)
|
||||
p, n = rng.choice(negs), rng.choice(negs)
|
||||
|
||||
+22
-10
@@ -2,24 +2,29 @@
|
||||
|
||||
Метрика — пары (тип, автор) на уровне релиза против меток датасета, только по
|
||||
страницам, которых сегментатор не видел при обучении (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
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
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.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]
|
||||
MAX_PER_SOURCE = int(os.environ.get("E2E_MAX", "400"))
|
||||
|
||||
|
||||
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:
|
||||
by_rel[r["release_id"]].append(r)
|
||||
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
|
||||
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
|
||||
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"]}
|
||||
tp = len(gold & pred)
|
||||
p = tp / len(pred) if pred 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 = {}
|
||||
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))
|
||||
@@ -46,13 +55,16 @@ def run(predictor, rows, mode: str, test_only: bool) -> dict:
|
||||
|
||||
def main() -> None:
|
||||
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)
|
||||
print(f"сегментатор: {seg_dir}")
|
||||
if predictor.segmenter is None:
|
||||
sys.exit("нет models/segmenter: python -m src.segmenter --train")
|
||||
for test_only, name in ((True, "тестовые страницы (не виделись сегментатору)"),
|
||||
(False, "train-страницы (для справки, оптимистично)")):
|
||||
blocks = [(True, "тестовые страницы (не виделись сегментатору)")]
|
||||
if "--train-pages" in sys.argv:
|
||||
blocks.append((False, "train-страницы (для справки, оптимистично)"))
|
||||
for test_only, name in blocks:
|
||||
print(f"== {name} ==")
|
||||
for mode in ("rules", "model"):
|
||||
res = run(predictor, rows, mode, test_only)
|
||||
|
||||
@@ -246,6 +246,46 @@ merge_extra_tracks(_page2, [
|
||||
check("codec: студия вписана, новая добавлена, без студии — нет",
|
||||
[(x["studio"], x["source"]) for x in _page2]
|
||||
== [("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"
|
||||
"Гангстерленд / 2026 / ДБ (X) / WEB-DL")
|
||||
@@ -381,6 +421,7 @@ check("series_info: сезон, серии, total и «студия -> сери
|
||||
print("== сборщик датасета (tools/collect.py) ==")
|
||||
import csv as _csv # noqa: E402
|
||||
import json as _json # noqa: E402
|
||||
import json # noqa: E402
|
||||
import tempfile as _tmp # noqa: E402
|
||||
from pathlib import Path as _P # noqa: E402
|
||||
|
||||
|
||||
Reference in new issue
Block a user