Несколько студий в одной строке, разбор противоречий, правки парсеров студий
docker / build (push) Successful in 1m21s
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:
1 parent
0f359837c5
commit
8511744d3a
15 files changed
+560
-119
No files matched your search
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
Reference in new issue
Block a user