Несколько студий в одной строке, разбор противоречий, правки парсеров студий
docker / build (push) Successful in 1m21s

Парсеры (PARSE_VERSION 9): «т/к» и единицы битрейта не режутся по слэшу, техпараметры
(kb/s, Кбит/сек, 16 бит, channels, CBR, Atmos) и пол голоса (муж./жен.) не становятся студией,
«от X» -> X; «N-й канал», «5 канал», «Лексикон» больше не выбрасываются как мусор; строка без типа
со списком из 3+ имён (актёры, титры сабберов) не даёт дорожек; в кодек-строках имя рядом со
скобкой пола становится кандидатом, а строка только с мусором даёт дорожку с типом без студии.

Несколько авторов строки («Многоголосый (HDRezka Studio, LostFilm)» — верны все): prepare_data
склеивает строки релиза в одну со списком spans, обучение размечает все спаны BIO, span_exact
сравнивает наборы, decode_spans возвращает все прогоны; сервис отдаёт studio_spans / studios /
studio_confidences, в ответе релиза — дорожка на каждого автора. Противоречий в датасете
20 % -> 1.2 %; проверочная tiny2 находит весь набор студий в 71 % строк с 2+ авторами
(прежняя модель — 1 %).

Разбор противоречий (collect.py, пункт 8): тексты, которые релизы разметили по-разному;
решения в data/text_fixes.csv применяются в prepare_data поверх labels.csv.

Метки и модель не пересобраны: нужны collect.py build --relabel, prepare_data, переобучение.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-09 16:24:52 +07:00
1 parent 0f359837c5
commit 8511744d3a
15 files changed
+560 -119

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
text,translation_type,studio,action
Дублированный (Невафильм),проф. дубляж,Невафильм,set
Дублированный (Пифагор),проф. дубляж,Пифагор,set
Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing,проф. дубляж,Movie Dubbing,set
Дублированный (Red Head Sound),проф. дубляж,Red Head Sound,set
"Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio|",проф. многоголосый,HDRezka Studio,set
Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing,проф. дубляж,Bravo Records Georgia ; Movie Dubbing,set
"Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio|",проф. многоголосый,DniproFilm ; HDRezka Studio,set
1 text translation_type studio action
2 Дублированный (Невафильм) проф. дубляж Невафильм set
3 Дублированный (Пифагор) проф. дубляж Пифагор set
4 Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing проф. дубляж Movie Dubbing set
5 Дублированный (Red Head Sound) проф. дубляж Red Head Sound set
6 Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio| проф. многоголосый HDRezka Studio set
7 Профессиональный (дублированный) Bravo Records Georgia / Movie Dubbing проф. дубляж Bravo Records Georgia ; Movie Dubbing set
8 Ukrainian AC3 / 5.1 / 48 kHz / 384 kbps |MVO, DniproFilm / HDRezka Studio| проф. многоголосый DniproFilm ; HDRezka Studio set
+6
View File
@@ -106,6 +106,9 @@
| `studio_span` | string \| null | фрагмент текста, распознанный как автор/студия |
| `studio` | string \| null | каноническое имя автора из словаря алиасов; неизвестное имя возвращается как в тексте; `null` — автора нет |
| `studio_confidence` | float \| null | средняя вероятность токенов спана |
| `studio_spans` | array of string | все авторы, найденные в строке, по порядку в тексте («Многоголосый (HDRezka Studio, LostFilm)» → два); первый совпадает со `studio_span`; пусто — автора нет. В ответе релиза такая строка раскладывается на дорожку на каждого автора, и в каждой дорожке здесь один элемент |
| `studios` | array of string | канонические имена для `studio_spans`, в том же порядке |
| `studio_confidences` | array of float \| null | уверенность для каждого элемента `studio_spans` |
| `is_18plus` | bool | дорожка помечена 18+ (по модели или по строке «Аудио … 18+» страницы) |
| `age_confidence` | float 0..1 | вероятность предсказанного класса 18+ |
| `lang` | string \| null | язык дорожки, ISO-подобный 3-буквенный код: `RUS`, `UKR`, `ENG`, также `BEL`, `KAZ`, `POL`, `FRA`, `DEU`, `ITA`, `SPA`, `JPN`, `KOR`, `CHI`, `CZE`, `LAV`, `LIT`, `EST`, `GEO`, `ARM`, `AZE`, `UZB`, `TUR`, `POR`, `HIN`; `null` — не определён |
@@ -184,6 +187,9 @@ type Track = {
studio_span: string | null; // null — автора в тексте нет
studio: string | null; // null вместе со studio_span
studio_confidence: number | null; // null вместе со studio_span
studio_spans: string[]; // все авторы строки по порядку; [] — автора нет; [0] == studio_span
studios: string[]; // канонические имена studio_spans, тот же порядок
studio_confidences: (number | null)[]; // уверенность каждого автора
is_18plus: boolean;
age_confidence: number; // 0..1
lang: string | null; // 3-буквенный код или null
+8 -1
View File
@@ -42,10 +42,17 @@ BIO-разметка строится по символьным спанам ч
исправлена; см. `decode_span` — он дополнительно пропускает токены с
пустыми offsets).
Декодирование спана: первый `B`-токен, затем цепочка `I` → символьный
Декодирование (`decode_spans`): каждая цепочка `B`/`I` → символьный
диапазон через offsets → текст спана → каноническое имя автора через
словарь алиасов (`src/studio_norm.py`, порт из генератора датасета).
У строки может быть несколько авторов: «Многоголосый (HDRezka Studio,
LostFilm, TVShows)» значит, что верны все три. `prepare_data` склеивает
строки одного релиза с одинаковым текстом и типом в одну строку
(`spans` — все спаны, `span` — первый), BIO размечает все, `span_exact`
сравнивает наборы. Сервис отдаёт `studio_spans`/`studios`; в ответе
релиза такая строка раскладывается на дорожку на каждого автора.
## Почему автор — извлечение, а не классификация
В датасете 253+ уникальных авторов, 177 из них встречаются ровно 1 раз.
+2 -7
View File
@@ -27,7 +27,7 @@ from transformers import AutoTokenizer
from .model import load_model
from .studio_norm import STUDIO_ALIASES, STUDIO_PLACEHOLDERS
from .train import MAX_LEN, Rows, decode_span, make_collate
from .train import MAX_LEN, Rows, decode_spans, make_collate
from torch.utils.data import DataLoader
@@ -152,12 +152,7 @@ def parity(model_dir: str, onnx_dir: str, data_path: str, limit: int = 2000,
same_type += tpt[i] == pot[i]
same_age += apt[i] == apo[i]
attn = enc["attention_mask"][i].tolist()
st = decode_span(bpt[i], offsets[i], attn)
so = decode_span(bot[i], offsets[i], attn)
row = loader.dataset.rows[n - 1]
st_txt = row["text"][st[0]:st[1]] if st else None
so_txt = row["text"][so[0]:so[1]] if so else None
same_span += (st_txt or "") == (so_txt or "")
same_span += decode_spans(bpt[i], offsets[i], attn) == decode_spans(bot[i], offsets[i], attn)
return {"n": n, "type_match": same_type / max(n, 1),
"span_match": same_span / max(n, 1),
"age_match": same_age / max(n, 1),
+20 -10
View File
@@ -14,6 +14,7 @@ Python API:
Формат результата (JSON):
{"segment": "...", "translation_type": "...", "type_confidence": 0.99,
"studio_span": "..." | null, "studio": "..." | null, "studio_confidence": 0.97,
"studio_spans": [...], "studios": [...], "studio_confidences": [...], # все авторы строки, первый = studio
"is_18plus": false, "age_confidence": 0.99}
"""
@@ -32,7 +33,7 @@ from .html_extract import (RE_LINE_PREFIX, extract_segments, html_to_text,
read_input)
from .model import load_model, predict_with_lang
from .studio_norm import canonical
from .spans import MAX_LEN, decode_span
from .spans import MAX_LEN, decode_spans
from .trackinfo import audio_summary, lang_of_text, model_text, role_of
@@ -132,16 +133,22 @@ class DubPredictor:
seg = texts[i]
tid = int(type_prob[i].argmax())
aid = int(age_prob[i].argmax())
span = refine_span(text, decode_span(
bio_pred[i], offsets[i], enc["attention_mask"][i].tolist()))
span_text = text[span[0]:span[1]].strip() if span else None
# все авторы строки («Многоголосый (HDRezka Studio, LostFilm)»); основной — первый по тексту
spans, span_texts = [], []
for sp in decode_spans(bio_pred[i], offsets[i], enc["attention_mask"][i].tolist()):
sp = refine_span(text, sp)
piece = text[sp[0]:sp[1]].strip() if sp else ""
if piece and piece not in span_texts:
spans.append(sp)
span_texts.append(piece)
span_text = span_texts[0] if span_texts else None
ttype = guard_type(self.types[tid], text, span_text, self.types)
span_conf = None
if span is not None:
toks = [t for t, (a, z) in enumerate(offsets[i])
if z > a and a < span[1] and z > span[0]]
confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t in toks]
span_conf = float(np.mean(confs)) if confs else None
span_confs = []
for a0, z0 in spans:
confs = [float(bio_prob[i, t, bio_pred[i, t]]) for t, (a, z) in enumerate(offsets[i])
if z > a and a < z0 and z > a0]
span_confs.append(round(float(np.mean(confs)), 4) if confs else None)
span_conf = span_confs[0] if span_confs else None
lang, lang_conf, lang_src = "", None, None
if info and info["lang"]: # язык, прямо названный в строке «Аудио», надёжнее нейросети
lang, lang_src = info["lang"], "audio"
@@ -159,6 +166,9 @@ class DubPredictor:
"studio_span": span_text,
"studio": canonical(span_text) if span_text else None,
"studio_confidence": round(span_conf, 4) if span_conf else None,
"studio_spans": span_texts, # все авторы строки, по порядку в тексте
"studios": [canonical(t) for t in span_texts],
"studio_confidences": span_confs,
"is_18plus": bool(aid == 1) or bool(info and info["adult"]), # 18+ в строке «Аудио» — прямое свидетельство
"age_confidence": round(float(age_prob[i, aid]), 4),
"lang": lang or None,
+100 -39
View File
@@ -32,6 +32,7 @@ import re
from collections import Counter, defaultdict
from pathlib import Path
from . import text_fixes
from .html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries
from .manual import REPEAT, manual_ids
from .studio_norm import JUNK_STUDIO_RE, find_studio_span
@@ -75,6 +76,52 @@ def build_text(row: dict) -> str:
return re.sub(r"\s+", " ", body).strip()
FIX_SEP = ";" # несколько авторов в ручном решении data/text_fixes.csv
def add_span(studios: list[str], spans: list[list[int]], name: str, span: list[int]) -> None:
"""Добавить автора строки: спаны по порядку в тексте, пересекающиеся не дублируются."""
if any(span[0] < z and a < span[1] for a, z in spans):
return
i = sum(1 for a, _ in spans if a < span[0])
studios.insert(i, name)
spans.insert(i, span)
def row_label(r: dict, fixes: dict[str, dict] | None = None) -> dict:
"""Строка labels.csv -> текст дорожки, тип и автор (общая часть prepare_data и разбора противоречий).
drop: причина отбраковки («empty», «nodub», «fix», «multi», «type») или "". junk — автор
оказался мусором и снят; fixed — метка взята из data/text_fixes.csv.
"""
out = {"drop": "", "text": build_text(r), "type": "", "studio": "", "junk": False, "fixed": False}
text = out["text"] # без хвоста «Аудио»: спан автора ищем в чистом тексте дорожки
if not text:
return {**out, "drop": "empty"}
if RE_NO_DUB_TEXT.match(text):
return {**out, "drop": "nodub"}
fix = (fixes or {}).get(text_fixes.norm(text))
if fix and fix["action"] == "drop":
return {**out, "drop": "fix"}
# несколько дорожек в одной строке при одной метке — неоднозначный пример (после переиндексации
# такие строки уже разложены сборщиком; здесь отсекаются остатки старых данных); ручное решение важнее
nested = [p for p in _split_entries(text) if RE_TYPE_HINT.search(p)]
if not fix and len(nested) >= 2 and not (r.get("source") == "nnm" and r.get("studio_source") == "tech"):
return {**out, "drop": "multi"}
ttype = ((fix or r).get("translation_type") or "").strip()
if ttype in UNKNOWN_TYPES:
ttype = "неизвестно"
if DROP_TYPES_RE.search(ttype):
return {**out, "drop": "type", "type": ttype}
studio = ((fix or r).get("studio") or "").strip()
junk = False
if studio == NO_STUDIO:
studio = ""
elif JUNK_STUDIO_RE.match(studio):
studio, junk = "", True
return {**out, "type": ttype, "studio": studio, "junk": junk, "fixed": bool(fix)}
class UnionFind:
def __init__(self, n: int):
self.p = list(range(n))
@@ -115,40 +162,37 @@ def main() -> None:
rows = []
dropped_empty = dropped_junk = dropped_type = dropped_multi = dropped_nodub = 0
span_misses: list[tuple[str, str, str]] = []
fixes = text_fixes.load()
n_fixed = dropped_fix = 0
for r in raw_rows:
text = build_text(r) # без хвоста «Аудио»: спан автора ищем в чистом тексте дорожки
if not text:
lab = row_label(r, fixes)
if lab["drop"] == "empty":
dropped_empty += 1
continue
if RE_NO_DUB_TEXT.match(text):
if lab["drop"] == "nodub":
dropped_nodub += 1
continue
# несколько дорожек в одной строке при одной метке — неоднозначный пример (после переиндексации
# такие строки уже разложены сборщиком; здесь отсекаются остатки старых данных)
nested = [p for p in _split_entries(text) if RE_TYPE_HINT.search(p)]
if len(nested) >= 2 and not (r.get("source") == "nnm" and r.get("studio_source") == "tech"):
if lab["drop"] == "fix":
dropped_fix += 1
continue
if lab["drop"] == "multi":
dropped_multi += 1
continue
ttype = (r.get("translation_type") or "").strip()
if ttype in UNKNOWN_TYPES:
ttype = "неизвестно"
if DROP_TYPES_RE.search(ttype):
if lab["drop"] == "type":
dropped_type += 1
continue
studio = (r.get("studio") or "").strip()
if studio == NO_STUDIO:
studio = ""
elif JUNK_STUDIO_RE.match(studio):
studio = ""
dropped_junk += 1
span = None
if studio:
found = find_studio_span(text, studio)
text, ttype, studio = lab["text"], lab["type"], lab["studio"]
dropped_junk += lab["junk"]
n_fixed += lab["fixed"]
# ручное решение может назвать несколько авторов строки: «HDRezka Studio ; LostFilm»
names = [n.strip() for n in studio.split(FIX_SEP)] if lab["fixed"] else [studio]
studios, spans = [], []
for name in filter(None, names):
found = find_studio_span(text, name)
if found:
span = [found[0], found[1]]
else:
span_misses.append((r.get("source", ""), studio, text))
studio = "__nospan__" # автор есть, но спан не найден
add_span(studios, spans, name, [found[0], found[1]])
else: # автор есть, но спан не найден: строка идёт в обучение без него
span_misses.append((r.get("source", ""), name, text))
# 18+: колонка age_18, пометка в note или «18+» в самой строке дорожки
is_18 = (r.get("age_18", "").strip() in {"1", "True", "true"}
or "18+" in (r.get("note") or "")
@@ -156,8 +200,10 @@ def main() -> None:
rows.append({
"text": text,
"translation_type": ttype,
"studio": studio,
"span": span,
"studio": studios[0] if studios else "",
"span": spans[0] if spans else None,
"studios": studios,
"spans": spans,
"is_18plus": is_18,
"source": r.get("source", ""),
"release_id": r.get("release_id", ""),
@@ -169,19 +215,32 @@ def main() -> None:
rows[-1]["text"] = model_text(text, info)
rows[-1]["tail"] = context(info) # хвост входа: по нему сравнение моделей без аудио-контекста снимает его
# --- 4б: несколько авторов одной строки ---------------------------
# «Многоголосый (HDRezka Studio, LostFilm, TVShows)»: сборщик даёт строку на каждую студию, а верны
# все сразу — склеиваем в одну строку со списком спанов (BIO размечает их все), иначе один и тот же
# вход получает несколько разных «правильных» ответов
merged: dict[tuple, dict] = {}
for row in rows:
key = (row["source"], row["release_id"], row["text"], row["translation_type"])
m = merged.setdefault(key, row)
if m is row:
continue
m["is_18plus"] = m["is_18plus"] or row["is_18plus"]
m["lang"] = m["lang"] or row["lang"]
for name, sp in zip(row["studios"], row["spans"]):
add_span(m["studios"], m["spans"], name, sp)
m["studio"], m["span"] = m["studios"][0] if m["studios"] else "", m["spans"][0] if m["spans"] else None
n_merged = len(rows) - len(merged)
rows = list(merged.values())
# релизы, добавленные вручную по ссылке: усиливаем (копии попадут в ту же группу)
manual = manual_ids()
man_rows = [r for r in rows if r["release_id"] in manual]
for _ in range(REPEAT - 1):
rows.extend(dict(r) for r in man_rows)
# строки с ненайденным спаном: из BIO-обучения их исключаем (флаг ниже),
# но для классификации типа они пригодны — оставляем с studio="" и span=null
# строки с ненайденным спаном автора для классификации типа пригодны — остаются без этого автора
n_span_miss = len(span_misses)
for row in rows:
if row["studio"] == "__nospan__":
row["studio"] = ""
row["span"] = None
# --- аугментация морфологических вариантов -------------------------
rng = random.Random(args.seed)
@@ -194,13 +253,11 @@ def main() -> None:
new_text = row["text"].replace(src_w, dst_w, 1)
if new_text == row["text"]:
continue
span = None
if row["span"]:
found = find_studio_span(new_text, row["studio"])
if not found:
break # автора не нашли в варианте — не добавляем
span = [found[0], found[1]]
rows.append({**row, "text": new_text, "span": span})
found = [find_studio_span(new_text, st) for st in row["studios"]]
if not all(found):
break # автора не нашли в варианте — не добавляем
spans = [[a, z] for a, z in found]
rows.append({**row, "text": new_text, "spans": spans, "span": spans[0] if spans else None})
n_aug += 1
break
@@ -306,6 +363,10 @@ def main() -> None:
f"не-озвучка (оригинал/субтитры) {dropped_type}, отказ от озвучки {dropped_nodub}, "
f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)}), "
f"несколько дорожек в одной строке {dropped_multi}, лишние повторы (> {args.max_dup} одинаковых) {n_capped}",
f"несколько авторов в одной строке: склеено {n_merged} строк, строк с 2+ авторами "
f"{sum(len(r['spans']) > 1 for r in rows)}",
f"ручные решения data/text_fixes.csv: {len(fixes)} текстов, метка заменена в {n_fixed} строках, "
f"выброшено {dropped_fix} строк",
f"спан автора найден: {with_studio} ({with_studio / max(len(rows), 1):.1%}), "
f"не найден: {n_span_miss}",
f"аугментировано морфологических вариантов: {n_aug}",
+24 -3
View File
@@ -393,6 +393,27 @@ def fill_audio(predictor, tracks: list[dict], lines: list[dict]) -> None:
have.add(info["lang"])
def expand_studios(tracks: list[dict]) -> list[dict]:
"""Строка с несколькими авторами («Многоголосый (HDRezka Studio, LostFilm)») -> дорожка на каждого.
Так их видят сведение дорожек релиза, эпизоды по студиям и сопоставление со строками «Аудио».
"""
out = []
for t in tracks:
names = t.get("studios") or []
if len(names) < 2:
out.append(t)
continue
seen = set()
for name, span, conf in zip(names, t["studio_spans"], t.get("studio_confidences") or [None] * len(names)):
if name.casefold() in seen:
continue
seen.add(name.casefold())
out.append({**t, "studio": name, "studio_span": span, "studio_confidence": conf,
"studios": [name], "studio_spans": [span], "studio_confidences": [conf]})
return out
def predict_release(predictor, *, html: str | None = None, text: str | None = None,
title: str | None = None, segmenter: str = "model") -> dict:
"""Предсказание для релиза целиком (html или текст страницы).
@@ -423,7 +444,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
for info in (match_audio(seg, lines) or [None]):
texts.append(seg)
infos.append(info)
tracks = predictor._predict_batch(texts, infos) if texts else []
tracks = expand_studios(predictor._predict_batch(texts, infos)) if texts else []
for t in tracks:
t["source"] = "page"
ep = parse_episodes(t["segment"])
@@ -436,7 +457,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
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)
codec = expand_studios(predictor._predict_batch(segs_codec))
for t in codec:
ep = parse_episodes(t["segment"])
t["episode_ranges"] = ep["ranges"]
@@ -446,7 +467,7 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No
title = title_from_html(html) if html is not None else title_from_text(text)
segs = title_segments(title or "")
if segs:
merge_title_tracks(tracks, predictor._predict_batch(segs))
merge_title_tracks(tracks, expand_studios(predictor._predict_batch(segs)))
fill_audio(predictor, tracks, lines)
result = aggregate(tracks)
warn = segmenter_warning(seg_model is not None and segmenter == "model", used,
+1 -1
View File
@@ -47,7 +47,7 @@ def test_rows(dataset: Path | str | None = None) -> list[dict]:
def fingerprint(rows: list[dict]) -> str:
"""Отпечаток тестовой выборки: те же строки с теми же метками (порядок не важен)."""
keys = sorted(json.dumps([r["text"], r["translation_type"], r.get("span"), bool(r.get("is_18plus")),
keys = sorted(json.dumps([r["text"], r["translation_type"], r.get("spans") or r.get("span"), bool(r.get("is_18plus")),
r.get("lang")], ensure_ascii=False) for r in rows)
return hashlib.sha1("\n".join(keys).encode("utf-8")).hexdigest()[:10]
+30 -24
View File
@@ -1,4 +1,4 @@
"""Общие для обучения и сервиса: длина входа и разбор BIO-предсказания в символьный спан.
"""Общие для обучения и сервиса: длина входа и разбор BIO-предсказания в символьные спаны.
Вынесено из train.py, чтобы контейнеру с сервисом не нужны были sklearn и остальные пакеты обучения.
"""
@@ -8,33 +8,39 @@ from __future__ import annotations
MAX_LEN = 128
def decode_span(bio_pred, offsets, attention) -> tuple[int, int] | None:
"""B/I-прогон -> символьный спан.
def decode_spans(bio_pred, offsets, attention) -> list[tuple[int, int]]:
"""B/I-прогоны -> символьные спаны всех авторов строки («Многоголосый (HDRezka Studio, LostFilm)»).
Учитываются только реальные токены: с attention=1 и непустым offsets
(спецтокены имеют offsets (0, 0) и пропускаются).
Прогон начинается с B или I и продолжается токенами I; B начинает новый прогон, O и спецтокен
его заканчивают. Учитываются только реальные токены: с attention=1 и непустым offsets
(спецтокены имеют offsets (0, 0)).
"""
bi = None
out: list[tuple[int, int]] = []
cur: list[int] | None = None
for t in range(len(bio_pred)):
if not attention[t]:
break
a, z = offsets[t]
if z <= a:
tag = int(bio_pred[t]) if z > a else 0
if tag == 2 and cur is not None:
cur[1] = int(z)
continue
if bio_pred[t] in (1, 2):
bi = t
break
if bi is None:
return None
ei = bi
for t in range(bi + 1, len(bio_pred)):
if not attention[t]:
break
a, z = offsets[t]
if z <= a:
break
if bio_pred[t] == 2:
ei = t
else:
break
return int(offsets[bi][0]), int(offsets[ei][1])
if cur is not None:
out.append((cur[0], cur[1]))
cur = None
if tag in (1, 2):
cur = [int(a), int(z)]
if cur is not None:
out.append((cur[0], cur[1]))
return out
def decode_span(bio_pred, offsets, attention) -> tuple[int, int] | None:
"""Первый B/I-прогон -> символьный спан (основной автор строки)."""
spans = decode_spans(bio_pred, offsets, attention)
return spans[0] if spans else None
def row_spans(row: dict) -> list[list[int]]:
"""Золотые спаны строки датасета: «spans» (все авторы), у старых датасетов — один «span»."""
return row.get("spans") or ([row["span"]] if row.get("span") else [])
+3 -3
View File
@@ -64,11 +64,11 @@ JUNK_STUDIO_RE = re.compile(
r"|отсутств\w*|отсутсву\w*|без\s*перевода|не\s*требу\w*|немо[йе]|без\s*слов"
r"|в\s*составе\s*контейнера|поверх\s*\w+|по\s*умолчанию|оригинал\w*"
r"|(?:русск|украин|англ|япон|китай|корей|немец|француз|итальян|испан|казах)\w*"
r"|\d+\s*(?:ch|канал\w*|сери\w*)|.*\b(?:kbps|кбит|khz|кгц)\b.*"
r"|\d+\s*(?:ch|канал(?:а|ов)|сери\w*)|.*\b(?:kbps|кбит|khz|кгц)\b.*"
r"|(?:e-?ac-?3|ac-?3|dts|aac|flac|mp3|opus|pcm|truehd)(?:\s+[\d.]+)?"
r"|\(?\d+\)?\s*[-–.)]\s*\S.*|\d+\s*[-–]?\s*\d*\s*эп\w*"
r"|\(?\d+\)?\s*(?:[.)]|[-–](?!\s*(?:й|я|ый|ой|ий)\b))\s*\S.*|\d+\s*[-–]?\s*\d*\s*эп\w*"
r"|тайминг\w*|редактор\w*|оформлени\w*|неизвестн\w*"
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал\w*)"
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал(?:а|ов))"
r"|anidb|mal|shikimori|world\s*art|описание|список)$", re.I)
+87
View File
@@ -0,0 +1,87 @@
"""Ручные решения по противоречивым строкам: одинаковый текст дорожки получил разные метки.
data/text_fixes.csv (text, translation_type, studio, action): action «set» — всем строкам с этим
текстом ставится указанный тип и авторы (studio пусто — автора нет; несколько авторов строки —
через «;»: «HDRezka Studio ; LostFilm»), «drop» — строки выбрасываются.
Ключ — текст дорожки без хвоста «Аудио» (build_text), без учёта регистра и пробелов. Решения
применяет prepare_data поверх labels.csv, поэтому они переживают пересборку меток из data/raw.
Файл дописывается построчно; при повторном решении по тому же тексту действует последнее.
Разбор — tools/collect.py → «Разбор противоречий».
"""
from __future__ import annotations
import csv
import re
from collections import defaultdict
from pathlib import Path
FIXES_FILE = Path("data/text_fixes.csv")
FIELDS = ["text", "translation_type", "studio", "action"]
ACTIONS = {"set", "drop"}
def norm(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
def load(path: Path | None = None) -> dict[str, dict]:
p = path or FIXES_FILE
if not p.exists():
return {}
with p.open(encoding="utf-8-sig", newline="") as f:
return {norm(r["text"]): r for r in csv.DictReader(f) if r.get("action") in ACTIONS}
def save(fix: dict, path: Path | None = None) -> None:
"""Дописать одно решение (сразу на диск: разбор можно прервать в любой момент)."""
assert fix.get("action") in ACTIONS and fix.get("text"), fix
p = path or FIXES_FILE
fresh = not p.exists() or p.stat().st_size == 0
with p.open("a", encoding="utf-8-sig" if fresh else "utf-8", newline="") as f:
w = csv.DictWriter(f, FIELDS)
if fresh:
w.writeheader()
w.writerow({k: fix.get(k, "") for k in FIELDS})
def conflicts(raw_rows: list[dict], fixes: dict[str, dict] | None = None) -> list[dict]:
"""Тексты, которые разные релизы разметили по-разному (ещё не решённые).
Вариант разметки — тип и набор авторов строки в релизе: «Многоголосый (HDRezka Studio, LostFilm)»
даёт в релизе две строки, но это один вариант (оба автора верны, prepare_data склеит их в одну).
Автор берётся так, как его видит модель, — фрагментом текста («NewStudio» и «Newstudio» совпадают).
"""
from .prepare_data import FIX_SEP, row_label # prepare_data сам импортирует этот модуль
from .studio_norm import find_studio_span
fixes = load() if fixes is None else fixes
# текст -> релиз -> тип -> {начало спана: автор}
by_text: dict[str, dict] = {}
for r in raw_rows:
lab = row_label(r)
if lab["drop"] or norm(lab["text"]) in fixes:
continue
key = re.sub(r"\s+", " ", lab["text"].strip()) # регистр различается моделью: «LostFilm» ≠ «Lostfilm»
item = by_text.setdefault(key, {"text": lab["text"], "rel": defaultdict(lambda: defaultdict(dict)),
"rows": 0})
names = item["rel"][(r.get("source", ""), r.get("release_id", ""))][lab["type"]]
for name in filter(None, (n.strip() for n in lab["studio"].split(FIX_SEP))):
span = find_studio_span(lab["text"], name)
if span:
names[span[0]] = lab["text"][span[0]:span[1]]
item["rows"] += 1
out = []
for item in by_text.values():
variants: dict[tuple, list] = defaultdict(list)
for rel, per_type in item["rel"].items():
for ttype, names in per_type.items():
studios = tuple(dict.fromkeys(names[k] for k in sorted(names)))
variants[(ttype, studios)].append(rel)
if len(variants) < 2:
continue
out.append({"text": item["text"], "rows": item["rows"],
"variants": sorted(((t, list(st), sorted(rels)) for (t, st), rels in variants.items()),
key=lambda v: (-len(v[2]), v[0], v[1]))})
out.sort(key=lambda i: (-i["rows"], i["text"]))
return out
+13 -17
View File
@@ -30,7 +30,7 @@ from torch.utils.data import DataLoader, Dataset
from transformers import AutoTokenizer, get_linear_schedule_with_warmup
from .model import DubTagger, predict_with_lang, save_model
from .spans import MAX_LEN, decode_span # noqa: F401 (прежнее место; сервису не нужны sklearn/matplotlib)
from .spans import MAX_LEN, decode_span, decode_spans, row_spans # noqa: F401 (прежнее место; сервису не нужны sklearn/matplotlib)
MODEL_CONFIGS = {
"tiny2": {"backbone": "cointegrated/rubert-tiny2", "lr": 1e-4,
@@ -89,15 +89,12 @@ def make_collate(tokenizer, type2id: dict[str, int], max_len: int = MAX_LEN,
bio = torch.zeros_like(enc["input_ids"])
bio[enc["attention_mask"] == 0] = -100
for i, b in enumerate(batch):
span = b.get("span")
if not span:
continue
s, e = span
first = True
for t, (a, z) in enumerate(offsets[i]):
if z > a and a < e and z > s: # токен пересекает спан
bio[i, t] = 1 if first else 2 # B / I
first = False
for s, e in row_spans(b): # все авторы строки: «(HDRezka Studio, LostFilm)»
first = True
for t, (a, z) in enumerate(offsets[i]):
if z > a and a < e and z > s: # токен пересекает спан
bio[i, t] = 1 if first else 2 # B / I
first = False
types = torch.tensor([type2id[b["translation_type"]] for b in batch])
ages = torch.tensor([int(bool(b.get("is_18plus"))) for b in batch])
if lang2id: # неизвестный язык (None) = -100: в лосс и метрику не входит
@@ -117,8 +114,8 @@ def _NORM(s: str | None) -> str | None:
def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dict:
"""Метрики на выборке: тип (acc, macro-F1), спан (exact), 18+ (acc, F1).
Exact match спана сравнивает нормализованный текст предсказанного спана
с текстом золотого спана (None == None считается совпадением).
Exact match спана сравнивает набор нормализованных текстов предсказанных спанов
с набором золотых (у строки может быть несколько авторов; пустой == пустой — совпадение).
joint_exact — строка распознана полностью: тип + автор + 18+.
"""
model.eval()
@@ -143,11 +140,10 @@ def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dic
base = len(span_ok)
rows = loader.dataset.rows
for i in range(len(types)):
pred = decode_span(bio_pred[i], offsets[i], attn[i])
gold = rows[base + i].get("span")
pred_txt = rows[base + i]["text"][pred[0]:pred[1]] if pred else None
gold_txt = rows[base + i]["text"][gold[0]:gold[1]] if gold else None
span_ok.append(_NORM(pred_txt) == _NORM(gold_txt))
row = rows[base + i]
pred = {_NORM(row["text"][a:z]) for a, z in decode_spans(bio_pred[i], offsets[i], attn[i])}
gold = {_NORM(row["text"][a:z]) for a, z in row_spans(row)}
span_ok.append(pred == gold) # весь набор авторов строки
y = [id2type[v] for v in y_ids]
p = [id2type[v] for v in p_ids]
type_ok = [a == b for a, b in zip(y, p)]
+125
View File
@@ -1392,5 +1392,130 @@ try:
except Exception as e: # noqa: BLE001
print(f" пропущено (нет models/segmenter): {e}")
print("== чистка студий: пол, техпараметры, т/к, каналы, списки ==")
import harvest_core as _hc3 # noqa: E402
from src.studio_norm import JUNK_STUDIO_RE as _JUNK # noqa: E402
_pst = _hc3.part_studios
check("пол голоса не студия: «(муж.) от Ancord», «женская одноголосая: Persona99»",
_pst("одноголосое закадровое (муж.) от Ancord") == ["Ancord"]
and _pst("Озвучка: женская одноголосая: Persona99") == ["Persona99"], str(_pst("одноголосое закадровое (муж.) от Ancord")))
check("техпараметры не студия: «192 kb/s», «Кбит/сек», «16 бит», «2 channels»",
_pst("Русский (AC3, 2 ch, 192 kb/s) Многоголосый") == []
and _pst("Русский АС-3 2.0 192 Кбит/сек 48,0 КГц 16 бит - авторский, одноголосый Андрей Гаврилов") == ["Андрей Гаврилов"]
and all(_hc3.is_studio_junk(x) for x in ("муж", "женская", "2 channels", "16 бит", "CBR", "192 kb", "сек", "2 AC3")))
check("«т/к Россия» -> «Россия», слэш между студиями по-прежнему делит",
_hc3.split_studios("т/к Россия") == ["Россия"]
and _hc3.split_studios("Paramount Comedy / Кураж-Бамбей") == ["Paramount Comedy", "Кураж-Бамбей"]
and _hc3.split_studios("Пифагор/Кинопоиск") == ["Пифагор", "Кинопоиск"])
check("настоящие студии не мусор: «1-й канал», «5-й канал СПб», «Лексикон», «Первый канал», «1+1», «2x2»",
not any(_JUNK.match(x) for x in ("1-й канал", "5-й канал СПб", "5 канал", "Лексикон", "Первый канал", "1+1", "2x2"))
and _JUNK.match("1 - Пифагор") and _JUNK.match("6 каналов") and not _hc3.is_studio_junk("5 канал") and not _hc3.is_studio_junk("Лексикон") and not _hc3.is_studio_junk("Мужской клуб"))
check("имя рядом со скобкой пола в кодек-строке",
[m.group(1) for m in _hc3.RE_AFTER_GENDER.finditer("двухголосая (муж.+жен.) GreyMoon & Aska")] == ["GreyMoon & Aska"]
and not list(_hc3.RE_AFTER_GENDER.finditer("Ура (Женя) Жуков")))
check("список из 3+ имён без типа — не дорожки, одиночная студия без типа — дорожка",
_hc3.untyped_list("неизвестно", ["a", "b", "c"]) and not _hc3.untyped_list("неизвестно", ["CrunchyRoll"])
and not _hc3.untyped_list("проф. многоголосый", ["a", "b", "c"]))
print("== разбор противоречий (src/text_fixes.py) ==")
import tempfile # noqa: E402
from src import text_fixes as _tf # noqa: E402
from src.prepare_data import row_label as _rl # noqa: E402
def _lr(rid: str, raw: str, ttype: str, studio: str) -> dict:
return {"source": "rutracker", "release_id": rid, "raw": raw, "translation_type": ttype, "studio": studio}
_cr = [_lr("1", "Перевод: Дублированный (Red Head Sound)", "проф. дубляж", "Red Head Sound"),
_lr("2", "Перевод: Дублированный (Red Head Sound)", "проф. дубляж", "(не указана)"),
_lr("3", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "HDRezka Studio"),
_lr("3", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "NewStudio"),
_lr("5", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "NewStudio"),
_lr("5", "Перевод: Многоголосый (HDRezka Studio, NewStudio)", "многоголосый", "HDRezka Studio"),
_lr("4", "Перевод: Авторский (Гаврилов)", "авт. одноголосый", "Гаврилов"),
_lr("6", "Перевод: Дублированный | LostFilm", "проф. дубляж", "LostFilm"),
_lr("7", "Перевод: Дублированный | Lostfilm", "проф. дубляж", "Lostfilm")]
_ci = {i["text"]: i for i in _tf.conflicts(_cr, {})}
check("противоречия: только расхождения между релизами; список студий одного релиза — один вариант",
set(_ci) == {"Дублированный (Red Head Sound)"}
and sorted(v[1] for v in _ci["Дублированный (Red Head Sound)"]["variants"]) == [[], ["Red Head Sound"]],
str({k: v["variants"] for k, v in _ci.items()}))
with tempfile.TemporaryDirectory(dir=".") as _td:
_fp = Path(_td) / "fixes.csv"
_tf.save({"text": "Дублированный (Red Head Sound)", "translation_type": "проф. дубляж",
"studio": "Red Head Sound", "action": "set"}, _fp)
_tf.save({"text": "Многоголосый (HDRezka Studio, NewStudio)", "action": "drop"}, _fp)
_fx = _tf.load(_fp)
check("решение «set» заменяет метку у всех строк текста, «drop» выбрасывает",
_rl(_cr[1], _fx)["studio"] == "Red Head Sound" and _rl(_cr[1], _fx)["fixed"]
and _rl(_cr[2], _fx)["drop"] == "fix" and not _rl(_cr[6], _fx)["fixed"])
check("решённые тексты не показываются снова", _tf.conflicts(_cr, _fx) == [])
print("== несколько авторов в одной строке ==")
import subprocess as _sp9 # noqa: E402
from src.release import expand_studios as _xs # noqa: E402
from src.spans import decode_span as _ds1, decode_spans as _dss # noqa: E402
_off = [(0, 0), (0, 3), (3, 6), (6, 7), (8, 10), (10, 12), (0, 0), (0, 0)]
check("decode_spans: все прогоны B/I, B начинает новый, O и спецтокен заканчивают; decode_span — первый",
_dss([0, 1, 2, 0, 1, 2, 0, 0], _off, [1] * 8) == [(0, 6), (8, 12)]
and _dss([0, 2, 1, 2, 2, 0, 0, 0], _off, [1] * 8) == [(0, 3), (3, 10)]
and _dss([1, 1, 2, 0, 1, 2, 1, 1], _off, [1, 1, 1, 1, 1, 1, 0, 0]) == [(0, 6), (8, 12)]
and _ds1([0, 1, 2, 0, 1, 2, 0, 0], _off, [1] * 8) == (0, 6) and _dss([0] * 8, _off, [1] * 8) == [])
_tr = {"segment": "x", "translation_type": "многоголосый", "studio": "HDrezka", "studio_span": "HDRezka Studio",
"studio_confidence": 0.9, "studio_spans": ["HDRezka Studio", "LostFilm"], "studios": ["HDrezka", "LostFilm"],
"studio_confidences": [0.9, 0.8]}
_xt = _xs([_tr, {**_tr, "studios": ["LostFilm"], "studio_spans": ["LostFilm"], "studio_confidences": [0.7]}])
check("ответ релиза: строка с двумя авторами -> дорожка на каждого",
[(t["studio"], t["studio_span"], t["studio_confidence"]) for t in _xt]
== [("HDrezka", "HDRezka Studio", 0.9), ("LostFilm", "LostFilm", 0.8), ("HDrezka", "HDRezka Studio", 0.9)])
with tempfile.TemporaryDirectory(dir=".") as _td:
_wd2 = Path(_td).resolve()
(_wd2 / "data").mkdir()
_ml = [_lr("1", "Перевод: Многоголосый (HDRezka Studio, LostFilm)", "многоголосый", "HDRezka Studio"),
_lr("1", "Перевод: Многоголосый (HDRezka Studio, LostFilm)", "многоголосый", "LostFilm"),
_lr("2", "Перевод: Многоголосый (HDRezka Studio, LostFilm)", "многоголосый", "LostFilm"),
_lr("3", "Перевод: Дублированный (Пифагор, Кинопоиск)", "проф. дубляж", "Пифагор")]
with (_wd2 / "l.csv").open("w", encoding="utf-8-sig", newline="") as _f:
_w = _csv2.DictWriter(_f, fieldnames=_hc.FIELDS, restval="")
_w.writeheader()
_w.writerows(_ml)
_tf.save({"text": "Дублированный (Пифагор, Кинопоиск)", "translation_type": "проф. дубляж",
"studio": "Пифагор ; Кинопоиск", "action": "set"}, _wd2 / "data" / "text_fixes.csv")
_pr = _sp9.run([_sys3.executable, "-c",
f"import sys; sys.path.insert(0, r'{_o.getcwd()}'); from src.prepare_data import main; main()",
"--csv", str(_wd2 / "l.csv"), "--out", str(_wd2 / "out")], cwd=_wd2, capture_output=True,
text=True, encoding="utf-8", env={**_o.environ, "PYTHONUTF8": "1"})
_mds = _js3.loads((_wd2 / "out" / "dataset.json").read_text(encoding="utf-8")) if _pr.returncode == 0 else []
_by = {(r["release_id"], r["text"]): [r["text"][a:z] for a, z in r["spans"]] for r in _mds}
check("prepare_data: строки одного релиза склеены в одну со всеми авторами, порядок по тексту",
_by.get(("1", "Многоголосый (HDRezka Studio, LostFilm)")) == ["HDRezka Studio", "LostFilm"]
and _by.get(("2", "Многоголосый (HDRezka Studio, LostFilm)")) == ["LostFilm"]
and all(r["span"] == (r["spans"][0] if r["spans"] else None) for r in _mds),
(_pr.stderr or "")[-300:] + str(_by))
check("prepare_data: ручное решение с несколькими авторами через «;»",
_by.get(("3", "Дублированный (Пифагор, Кинопоиск)")) == ["Пифагор", "Кинопоиск"], str(_by))
try:
from transformers import AutoTokenizer as _AT
from src.train import make_collate as _mc
_tok = _AT.from_pretrained("cointegrated/rubert-tiny2")
_txt = "Многоголосый (HDRezka Studio, LostFilm)"
_a, _b = _txt.index("HDRezka"), _txt.index("LostFilm")
_enc, _, _bio, _, _ = _mc(_tok, {"многоголосый": 0})(
[{"text": _txt, "translation_type": "многоголосый", "is_18plus": False,
"spans": [[_a, _a + len("HDRezka Studio")], [_b, _b + len("LostFilm")]], "span": [_a, _a + 14]}])
_tags = _bio[0].tolist()
_got = _dss(_tags, _enc["offset_mapping"][0].tolist() if "offset_mapping" in _enc else
_tok(_txt, return_offsets_mapping=True)["offset_mapping"], [1] * len(_tags))
check("обучение: BIO размечает всех авторов строки (два B)",
_tags.count(1) == 2 and [_txt[a:z] for a, z in _got] == ["HDRezka Studio", "LostFilm"], str(_tags))
except Exception as _e: # noqa: BLE001
print(f" пропущено (нет токенизатора rubert-tiny2): {_e}")
print(f"\nитог: {PASS} ok, {FAIL} failed")
sys.exit(1 if FAIL else 0)
+79 -1
View File
@@ -1032,7 +1032,7 @@ def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | No
# Версия разбора: поднимите при правке парсеров (harvest_core, html_to_text) — тогда
# кэш «уже разобрано» сбросится сам и данные пересоберутся один раз.
PARSE_VERSION = 8
PARSE_VERSION = 9
def load_json(path: Path) -> dict:
@@ -1680,6 +1680,81 @@ def settings_screen() -> None:
f"режим {cfg['mode']}, раздач со страницы {cfg['per_page']}, страниц списка {cfg['pages']}")
TOPIC_URL = {"rutracker": "https://rutracker.org/forum/viewtopic.php?t={}",
"nnm": "https://nnmclub.to/forum/viewtopic.php?t={}",
"kinozal": "https://kinozal.me/details.php?id={}"}
def topic_url(site: str, release_id: str) -> str:
rid = real_id(site, release_id).removesuffix(".legacy")
return TOPIC_URL[site].format(rid) if site in TOPIC_URL else release_id
def wizard_conflicts() -> None:
"""Разбор противоречий: один и тот же текст дорожки размечен по-разному -> выбрать верную метку.
Решения пишутся в data/text_fixes.csv сразу после ответа и применяются в prepare_data поверх
labels.csv (переживают пересборку меток). Повторный запуск показывает только нерешённые тексты.
"""
from rich.markup import escape
from src import text_fixes
from src.prepare_data import FIX_SEP
from src.studio_norm import find_studio_span
rows = read_labels()
todo = text_fixes.conflicts(rows)
say(f"Нерешённых противоречий: {len(todo)}. Решено раньше: {len(text_fixes.load())} "
f"(data/text_fixes.csv). Строка со списком студий — это один вариант: верны все студии.")
if not todo or ask("Начать разбор? (Y/n)", "y").lower() == "n":
return
types = sorted({r.get("translation_type", "") for r in rows} - {""})
done = 0
for n, item in enumerate(todo, 1):
ui.clear()
ui.console.print(f"[dim]{n}/{len(todo)} · решено за сеанс {done} · строк с этим текстом "
f"{item['rows']}[/dim]\n")
ui.console.print(f"[bold]{escape(item['text'])}[/bold]\n")
table = []
for k, (ttype, studios, rels) in enumerate(item["variants"], 1):
links = ", ".join(topic_url(s, rid) for s, rid in rels[:2]) + (" …" if len(rels) > 2 else "")
table.append([k, ttype, ", ".join(studios) or "—", len(rels), links])
ui.console.print(ui.summary_table("Варианты разметки", ["№", "тип", "авторы", "релизов", "примеры"], table))
ui.console.print("[dim]№ — выбрать вариант · e — своя метка · d — выбросить текст из обучения · "
"Enter — пропустить · q — выйти[/dim]")
ans = ask("Решение", "").lower()
if ans == "q":
break
fix = None
if ans.isdigit() and 1 <= int(ans) <= len(item["variants"]):
ttype, studios, _ = item["variants"][int(ans) - 1]
fix = {"text": item["text"], "translation_type": ttype, "studio": f" {FIX_SEP} ".join(studios),
"action": "set"}
elif ans == "e":
ttype = ask(f"Тип ({', '.join(types)})", item["variants"][0][0])
if ttype not in types:
say(f"нет такого типа: {ttype} — пропускаю")
ui.pause("Enter — дальше")
continue
studio = ask(f"Авторы через «{FIX_SEP}» (пусто — не указан)", f" {FIX_SEP} ".join(item["variants"][0][1]))
missing = [n for n in (x.strip() for x in studio.split(FIX_SEP)) if n and not find_studio_span(item["text"], n)]
if missing:
say(f"нет в тексте: {', '.join(missing)} — для них строка пойдёт в обучение без спана автора")
ui.pause("Enter — дальше")
fix = {"text": item["text"], "translation_type": ttype, "studio": studio, "action": "set"}
elif ans == "d":
fix = {"text": item["text"], "action": "drop"}
if fix:
text_fixes.save(fix)
done += 1
ui.clear()
say(f"Решено за сеанс: {done}. Они применятся при следующей сборке data/processed.")
if done and ask("Пересобрать data/processed сейчас? (y/N)", "n").lower() == "y":
import retrain
if not retrain.prepare():
say("prepare_data завершился с ошибкой")
def menu() -> None:
while True:
ui.clear()
@@ -1692,6 +1767,7 @@ def menu() -> None:
("5", "Настройки [dim](вкладок на трекер, пауза, лимиты)[/dim]"),
("6", "Обучение по ссылкам [dim](добавить раздачи вручную, поправить метки, переобучить)[/dim]"),
("7", "Модели [dim](сравнить обученные на одной выборке, выбрать лучшую, откатиться)[/dim]"),
("8", "Разбор противоречий [dim](релизы разметили один текст по-разному: выбрать верную метку)[/dim]"),
("0", "Выход")]))
choice = ask("Пункт", "").lower()
if choice in ("0", "q", ""):
@@ -1713,6 +1789,8 @@ def menu() -> None:
wizard_links()
elif choice == "7":
wizard_models()
elif choice == "8":
wizard_conflicts()
else:
continue
except KeyboardInterrupt:
+54 -13
View File
@@ -1100,11 +1100,18 @@ def mark_18(note: str, age: bool) -> str:
return (note or "").strip()
# пол голоса перед именем или вместо него: «(муж.)», «женская : Persona99», «муж./жен.»
RE_GENDER_LEAD = re.compile(
r"(?i)^(?:(?:мужск\w*|женск\w*|мужчин\w*|(?:муж|жен)\b\.?)\s*(?:[+/&,]|\s+и\s+)?\s*)+[:,\-–—]?\s*")
def clean_studio(raw: str) -> tuple[str, str]:
note = ""
s = (raw or "").strip().strip("|").strip()
s = RE_LANG_PREFIX.sub("", s) # «Русский, - AlexFilm» -> «AlexFilm»
s = re.sub(r"(?i)^(?:озвучил\w*|озвучено|озвучивание|перевёл|перевел)\s*[-–—:]?\s*", "", s).strip()
s = re.sub(r"(?i)^(?:озвучил\w*|озвучено|озвучивание|озвучк\w*|перевёл|перевел)\s*[-–—:]?\s*", "", s).strip()
s = RE_GENDER_LEAD.sub("", s.lstrip(" :,;-–—"))
s = re.sub(r"(?i)^от\s+", "", s) # «(муж.) от Ancord» -> «Ancord»
m = RE_ON_ORDER.match(s) # «X по заказу Y» -> «X»
if m:
s = m.group(1)
@@ -1250,7 +1257,10 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
if pm:
role = pm.group(1)
kind, pro = translation_type(body)
for name in part_studios(body) or [""]:
names = part_studios(body)
if untyped_list(kind, names):
continue
for name in names or [""]:
translations.append({"studio": name, "type": kind, "pro": pro,
"role": role, "actors": actors, "note": "",
"line": raw.strip(), "src": src})
@@ -1467,7 +1477,7 @@ def _rt_parse_core(rec: dict) -> tuple[list[dict], dict]:
continue
# несколько студий в одной части: «(многоголосый) — Jaskier, TVShows, LostFilm»
names = part_studios(part) or ([e["studio"]] if e["studio"] else [])
if e["type"] == "неизвестно" and not names:
if (e["type"] == "неизвестно" and not names) or untyped_list(e["type"], names):
continue
for name in names or [""]:
entries.append({**e, "studio": name})
@@ -1596,13 +1606,27 @@ STUDIO_SEP = re.compile(r"\s*[,;|/]\s*")
SOURCE_RE = re.compile(r"(?i)^(?:blu-?ray|bd\b|dvd|web-?dl|webrip|hdtv|uhd|itunes|netflix|amazon|r5)")
RE_CODEC_PARAM_ANY = re.compile(
r"(?i)\b(?:e-?ac-?3|ac-?3|ас3|dts|aac|truehd|flac|mp3|opus|l?pcm|mp2|dolby|ddp?)\b"
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал\w*)\b")
r"|\d+(?:[.,]\d+)?\s*(?:kbps|кбит|kb/s|khz|кгц|гц|hz|ch|канал(?:а|ов))\b")
# описания дорожки, которые остаются после снятия слов типа: пол, голос, сокращения типа, единицы
NOT_STUDIO_DESCR = re.compile(
r"(?i)(?:мужск\w*|женск\w*|мужчин\w*|муж|жен|голос\w*|проф|люб|авт|сек|cbr|vbr|avg|kb|kbps"
r"|\d+\s*(?:channels?|бит|bits?|kb)|\d+\s+(?:e-?ac-?3|ac-?3|dts|aac|mp3))\.?")
# техпараметры в тексте перед разбиением на имена: «192 kb/s», «Кбит/сек», «2 channels», «5.1»
RE_TECH_SCRUB = re.compile(
r"(?i)\d+(?:[.,]\d+)?\s*(?:kbps|kb/s|kbit/s|кбит/с(?:ек)?|кб/с|kb|кбит|khz|кгц|гц|hz|ch|channels?|бит|bits?)\b"
r"|\b(?:kb|kbit|кбит|кб)\s*/\s*(?:s|с|сек)\b"
r"|\b(?:e-?ac-?3|ac-?3|ас-?3|dts(?:-hd)?|aac|truehd|atmos|flac|mp3|opus|l?pcm|dolby\s+digital|dolby|cbr|vbr|avg)\b"
r"|\b\d\.\d\b")
RE_TV_PREFIX = re.compile(r"(?i)\bт\s*/\s*[кc]\.?\s*") # «т/к Россия» -> «Россия» (слэш — не разделитель)
def is_studio_junk(s: str) -> bool:
"""Не имя студии: источник, тип, язык, техпараметры, «по умолчанию», число."""
low = (s or "").strip().lower()
return (len(low) < 3 or low in SOURCE_LIKE or low in STUDIO_PLACEHOLDERS
or bool(NOT_STUDIO_DESCR.fullmatch(low))
or bool(NOT_STUDIO_WORD.match(low)) or bool(CODEC_NOT_STUDIO.match(low))
or bool(SOURCE_RE.match(low)) or bool(RE_NO_DUB.search(low))
or bool(RE_CODEC_PARAM_ANY.match(low))
@@ -1614,7 +1638,8 @@ def is_studio_junk(s: str) -> bool:
def split_studios(text: str) -> list[str]:
"""Перечисление студий -> имена: «Jaskier, TVShows, HDrezka Studio, LostFilm»."""
out: list[str] = []
for part in STUDIO_SEP.split((text or "").strip(" |.-–—")):
text = RE_TECH_SCRUB.sub(" ", RE_TV_PREFIX.sub("", text or ""))
for part in STUDIO_SEP.split(text.strip(" |.-–—")):
st, _ = clean_studio(part.strip(" .|"))
st, _ = canonical_studio(st)
if st and not is_studio_junk(st) and st.casefold() not in {x.casefold() for x in out}:
@@ -1650,6 +1675,11 @@ def split_translation_parts(body: str) -> list[str]:
return parts if len(parts) > 1 and (roots >= 2 or starts >= 2) else [text]
def untyped_list(kind: str, names: list[str]) -> bool:
"""Строка без типа озвучки с перечнем из 3+ имён — актёры (оригинала или дубляжа), а не дорожки."""
return kind == "неизвестно" and len(names) >= 3
def part_studios(part: str) -> list[str]:
"""Студии из части «Перевод:»: хвост после тире, скобки, остаток без слов типа."""
names: list[str] = []
@@ -1806,7 +1836,7 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
if ttype in ("оригинал", "субтитры"):
continue
names = part_studios(part)
if ttype == "неизвестно" and not names:
if (ttype == "неизвестно" and not names) or untyped_list(ttype, names):
continue
# студия — если названа в части («Дублированный (Невафильм)»); без неё тип всё равно ценен
for name in names or [""]:
@@ -1830,10 +1860,9 @@ def _nnm_parse_core(rec: dict) -> tuple[list[dict], dict]:
if "RUS" not in lang_tokens(body):
continue
names = audio_names(body)
for team in re.findall(r"\[([^\[\]]{2,60})\]", body):
for n in split_studios(team):
if n.casefold() not in {x.casefold() for x in names}:
names.append(n)
teams = [n for team in re.findall(r"\[([^\[\]]{2,60})\]", body) for n in split_studios(team)]
if teams: # «Русский (Berofu, Takera [AniMaunt])»: автор — команда, а не актёры
names = list(dict.fromkeys(teams))
for name in names[:8]:
rows.append({
"source": "nnm", "release_id": rec["id"], "ru_title": ru_title,
@@ -2069,7 +2098,7 @@ RE_LANG_WORD = re.compile(
r"|француз|french|немецк|german|итальян|испан|корей|китайск")
# не студия: язык, тех. параметры, тип, источник, пометки
CODEC_NOT_STUDIO = re.compile(
r"(?i)^(?:rus|ukr|eng|ru|ua|int|ext|pic|dub|mvo|dvo|avo|vo|лицензи\w*|ненормативн\w*.*|.*лексик\w*"
r"(?i)^(?:rus|ukr|eng|ru|ua|int|ext|pic|dub|mvo|dvo|avo|vo|лицензи\w*|ненормативн\w*.*|.*\bлексик[аеиоу]\b.*"
r"|присутствует.*|внешн\w*|в составе.*|серии.*|эп\w*\.?\s*\d.*|[\d\s.,:/\-]+|.*\b(?:kbps|ch|khz|кбит)\b.*"
r"|.*(?:русск|английск|украинск|russian|english|ukrainian|оригинал|original|японск|китайск"
r"|корейск|немецк|французск|итальянск|испанск|казахск)\w*.*"
@@ -2127,12 +2156,19 @@ def _rec_base(rec: dict) -> dict:
"quality": info["quality"], "is_series": info["is_series"]}
RE_AFTER_GENDER = re.compile(r"(?i)\([^()]*\b(?:муж|жен)(?:\.|ск\w*|\b)[^()]*\)\s*:?\s*([^()\[\]|;,:\s][^()\[\]|;,:]{1,59}?)\s*(?=[(\[|;,]|$)")
RE_BEFORE_GENDER = re.compile(r"(?i)\s[-–—]\s*([^()\[\]|;,:\s][^()\[\]|;,:]{1,59}?)\s*\([^()]*\b(?:муж|жен)(?:\.|ск\w*|\b)")
def _line_names(line: str, info: dict, known: re.Pattern) -> list[tuple[str, bool]]:
"""Кандидаты в студии кодек-строки: словарь, `|…|`, `<…>`, `[…]`, `(…)`."""
cands: list[tuple[str, bool]] = [(m.group(1), True) for m in known.finditer(line)]
cands += [(n, False) for n in info["names"]]
for rx in (r"\|([^|]{2,60})\|", r"<([^<>]{2,60})>", r"\[([^\[\]]{2,60})\]", r"\(([^()]{2,80})\)"):
cands += [(m.group(1), False) for m in re.finditer(rx, line)]
# имена рядом со скобкой пола: «двухголосая (муж.+жен.) GreyMoon & Aska», «- Dejz & Mikrobelka (мужская/женская)»
for rx in (RE_AFTER_GENDER, RE_BEFORE_GENDER):
cands += [(m.group(1), False) for m in rx.finditer(line)]
seen, out = set(), []
for name, voc in cands:
k = name.strip().casefold()
@@ -2187,6 +2223,7 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
kind = next(iter(types)) if len(types) == 1 else "неизвестно"
pro = kind.split()[0] if kind.split()[0] in ("проф.", "люб.", "авт.") else ""
names = _line_names(line, info, known)
found = False # строка назвала настоящую студию (новую или уже известную)
for name, in_vocab in names:
studio, note = clean_studio(name)
studio, extra = canonical_studio(studio)
@@ -2195,6 +2232,7 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
if CODEC_NAME_NOISE.search(studio) or not in_vocab and re.search(r"\d", studio):
continue
if any(same_studio(studio, x) for x in have):
found = True
continue
entries = _entries(line)
seg = next((e for e in entries if name.lower() in e.lower()), entries[0] if entries else line)
@@ -2210,8 +2248,11 @@ def codec_line_rows(rec: dict, text: str, rows: list[dict], known: re.Pattern |
"raw": seg, "lang": info["lang"] or ("UKR" if re.search(r"(?i)\bukr\b", line) else "RUS"),
"audio_raw": line})
have.append(studio)
if not rows and not names and kind and kind != "неизвестно":
# дорожка без имени: датасету ценен хотя бы тип; только для релизов без строк вовсе
found = True
junk_only = bool(names) and not found and kind not in types
if (not rows and not names or junk_only) and kind and kind != "неизвестно":
# дорожка без имени: датасету ценен хотя бы тип; для релизов без строк вовсе или когда
# все кандидаты строки — мусор («(муж.+жен.)», «192 kb/s»), а такого типа у релиза ещё нет
entries = _entries(line)
out.append({
"source": base.get("source") or rec.get("source", ""), "release_id": rec.get("id", ""),