diff --git a/data/text_fixes.csv b/data/text_fixes.csv new file mode 100644 index 0000000..d98c429 --- /dev/null +++ b/data/text_fixes.csv @@ -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 diff --git a/docs/api-reference.md b/docs/api-reference.md index 196e267..5a00e5f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 5f48bcd..dc65258 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 раз. diff --git a/src/export_onnx.py b/src/export_onnx.py index f7c90d9..bd9065f 100644 --- a/src/export_onnx.py +++ b/src/export_onnx.py @@ -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), diff --git a/src/predict.py b/src/predict.py index d959cb8..65a3cb3 100644 --- a/src/predict.py +++ b/src/predict.py @@ -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, diff --git a/src/prepare_data.py b/src/prepare_data.py index 87c1a38..6eb16e3 100644 --- a/src/prepare_data.py +++ b/src/prepare_data.py @@ -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}", diff --git a/src/release.py b/src/release.py index 3b548bd..a737e1e 100644 --- a/src/release.py +++ b/src/release.py @@ -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, diff --git a/src/runs.py b/src/runs.py index ef4716a..48b2f80 100644 --- a/src/runs.py +++ b/src/runs.py @@ -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] diff --git a/src/spans.py b/src/spans.py index c558d57..0e3e5d0 100644 --- a/src/spans.py +++ b/src/spans.py @@ -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 []) diff --git a/src/studio_norm.py b/src/studio_norm.py index fcb64a8..d955067 100644 --- a/src/studio_norm.py +++ b/src/studio_norm.py @@ -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) diff --git a/src/text_fixes.py b/src/text_fixes.py new file mode 100644 index 0000000..3b58f6a --- /dev/null +++ b/src/text_fixes.py @@ -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 diff --git a/src/train.py b/src/train.py index efeccdd..22c53e9 100644 --- a/src/train.py +++ b/src/train.py @@ -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)] diff --git a/tests/run_tests.py b/tests/run_tests.py index 989ed2f..12beebf 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -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) diff --git a/tools/collect.py b/tools/collect.py index 0904e56..6d7a0c9 100644 --- a/tools/collect.py +++ b/tools/collect.py @@ -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: diff --git a/tools/harvest/harvest_core.py b/tools/harvest/harvest_core.py index fe3ceae..a5e65b7 100644 --- a/tools/harvest/harvest_core.py +++ b/tools/harvest/harvest_core.py @@ -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", ""),