diff --git a/docs/api-reference.md b/docs/api-reference.md index c3e0702..196e267 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -67,6 +67,7 @@ | `subtitles` | object \| null | субтитры из строк «Субтитры:», «Субтитры 2:», «Язык субтитров:» (нет строк — языки из «Sub (Rus, Eng)» заголовка), см. ниже; `null` — о субтитрах ничего не сказано | | `title` | string \| null | заголовок раздачи, если найден | | `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами | +| `segmenter_warning` | string (необязательно) | модель-сегментатор и запасные правила разошлись (модель ничего не выбрала или правила нашли больше): страница — кандидат на дообучение, см. `docs/retrain-segmenter.md` | | `tracker` | string | только для `url`: `rutracker`, `nnm`, `kinozal` или `lostfilm` | `video` (все поля, кроме `hdr` и `raw`, могут быть `null`): @@ -226,6 +227,7 @@ type Release = { } | null; // null — о субтитрах на странице ничего нет title: string | null; segmenter: "model" | "rules"; + segmenter_warning?: string; // только если модель и правила разошлись tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url }; ``` diff --git a/docs/retrain-segmenter.md b/docs/retrain-segmenter.md new file mode 100644 index 0000000..1aa1f79 --- /dev/null +++ b/docs/retrain-segmenter.md @@ -0,0 +1,93 @@ +# Дообучение сегментатора страниц + +Сегментатор решает, какие строки страницы описывают дорожки озвучки +(`src/segmenter.py`, rubert-tiny2). Дообучать его нужно, когда `/predict` теряет +дорожки или в ответе появилось `segmenter_warning` (модель и правила +разошлись). Делается на машине, где лежат `data/raw`, `data/pages` и `models/segmenter` +(в git их нет; в репозитории на машине разработки их не оказалось, пути ниже — относительно каталога проекта, где они есть): без них обучение выдаст более слабые веса, чем рабочие. + +## 0. Понять причину (по желанию, 1 минута) + +Какие оценки модель ставит строкам страницы (`docker compose exec` — на +сервере, либо то же в `.venv` локально): + +```bash +docker compose exec grabber-ai python -c "from src.fetch import fetch_html; from src.html_extract import html_to_text; from src.segmenter import LineSegmenter, page_lines; h,_=fetch_html('https://rutracker.org/forum/viewtopic.php?t=6919056', timeout=60); L=page_lines(html_to_text(h)); s=LineSegmenter('/app/models/segmenter'); [print(round(float(p),3), l[:90]) for l,p in zip(L, s.score(L)) if p>0.01]" +``` + +* оценки 0.3–0.49 у строк-дорожек — упёрлись в порог (`THRESHOLD = 0.5`); +* оценки около 0 — модель не знает такую вёрстку, нужны примеры (шаги ниже). + +## 1. Добавить проблемные раздачи + +```powershell +cd <каталог проекта, где лежат data\raw и models> +.venv\Scripts\python tools\collect.py +``` + +Главное меню → **6** («Обучение по ссылкам»): вставьте ссылки на раздачи, у +которых дорожки определились неверно (например, +`https://rutracker.org/forum/viewtopic.php?t=6919056`). Страницы скачиваются +вашим Chrome (вход на трекер делаете сами), парсер строит метки. Проверьте +таблицу: строк-дорожек должно быть столько же, сколько на странице (для +6919056 — четыре, у двух HDRezka разные типы). Правки: «2 18» — переключить +18+, «3 d» — удалить строку, «1 t <тип>» — тип, «1 s <студия>» — студия. +Сохраните: id попадёт в `data/manual_ids.txt`, такие релизы всегда в train и +повторяются при обучении ×5. + +Чем больше похожих раздач (3–10 штук с разными студиями и той же вёрсткой), +тем надёжнее; одна страница редко меняет поведение модели. + +## 2. Переобучить + +Главное меню → **2** («Переобучить и протестировать»). Шаги идут сами: +`prepare_data`, обучение сегментатора в `models/segmenter_v2` +(≈ минуты на RTX 5070 Ti), `tests/run_tests.py`, таблица «правила / старые веса / +новые веса» по трекерам. + +Без меню: + +```powershell +.venv\Scripts\python -m src.prepare_data +.venv\Scripts\python -m src.segmenter --train --out models/segmenter_v2 +.venv\Scripts\python tests\run_tests.py +.venv\Scripts\python tests\e2e_segmenter.py models/segmenter_v2 +``` + +## 3. Принять новые веса + +Меню покажет, лучше ли новые веса. На «y» они заменят `models/segmenter`, старые +уйдут в `models/segmenter_old_<время>`. Если хуже — не принимайте: добавьте +больше примеров вёрстки и повторите шаг 2. + +Исправленная раздача проверяется на сервере после пересборки образа (шаг 5). + +## 4. Упаковать модели и пересобрать образ + +```powershell +.venv\Scripts\python tools\pack_models.py --stage docker-models +docker compose up -d --build +``` + +(в CI — workflow `.gitea/workflows/docker.yml`, см. `docs/docker.md`). + +## 5. Проверить на сервере + +```powershell +irm http://192.168.100.67:7654/health # "segmenter": "model" +irm http://192.168.100.67:7654/predict -Method Post -ContentType "application/json; charset=utf-8" -Headers @{Authorization="Bearer <токен>"} -Body ([Text.Encoding]::UTF8.GetBytes('{"url":"https://rutracker.org/forum/viewtopic.php?t=6919056"}')) | ConvertTo-Json -Depth 10 +``` + +Должно быть `"segmenter": "model"`, четыре дорожки, без `segmenter_warning`. + +## Что делает сервис, пока модель не дообучена + +* **Достройка блока** (`release.complete_block`): модель выбрала часть строк + списка под «Перевод:» — соседние строки с типом озвучки (Проф…, Любит…, …голос…) + добавляются в тот же список. +* **Запасные правила**: модель не нашла ни одной строки — дорожки берут правила + `html_extract.extract_segments`; в ответе `"segmenter": "rules"`. +* **`segmenter_warning`** в ответе и строка `segmenter: …` в логе сервиса — модель + и правила разошлись. На глоссариях типов озвучки правила дают ложные + срабатывания, поэтому предупреждение — повод посмотреть страницу, а не + всегда ошибка. diff --git a/src/html_extract.py b/src/html_extract.py index 7f2212a..1eb588b 100644 --- a/src/html_extract.py +++ b/src/html_extract.py @@ -49,7 +49,7 @@ RE_SEP = re.compile( # строка, начинающаяся со слова типа озвучки (продолжение списка под «Перевод:») RE_TYPE_START = re.compile( - r"^\s*(?:Проф|Люб|Авт|Дубл|Многоголос|Двухголос|Одноголос)", re.I) + r"^\s*(?:Профессиональн|Любительск|Авторск|Дублирован|Многоголос|Двухголос|Одноголос)", re.I) SELECTORS = ("div.post_body", "div[id^=post-text-]", ".postbody", "#tabs") @@ -128,9 +128,15 @@ def extract_segments(text: str) -> list[str]: body, i = lines[j].strip(), j + 1 # rutracker: «Перевод:» и под ним по строке на дорожку — берём # подряд идущие строки, начинающиеся со слова типа - while i < len(lines) and RE_TYPE_START.match(lines[i]): - body += " ; " + lines[i].strip() - i += 1 + # (в HTML после
стоит ещё и \n исходника — строки через пустую) + while True: + k = i + while k < len(lines) and not lines[k].strip(): + k += 1 + if k >= len(lines) or not RE_TYPE_START.match(lines[k]): + break + body += " ; " + lines[k].strip() + i = k + 1 else: i += 1 continue diff --git a/src/release.py b/src/release.py index 0f483dd..69c815a 100644 --- a/src/release.py +++ b/src/release.py @@ -11,6 +11,7 @@ from __future__ import annotations +import logging import re from bs4 import BeautifulSoup @@ -19,8 +20,10 @@ from .audio18 import apply_audio_18 from .techinfo import tech_info from .trackinfo import audio_lines_all, audio_summary, match_audio from .episodes import format_ranges, merge_ranges, parse_episodes -from .html_extract import (RE_LINE_PREFIX, _split_entries, extract_segments, - html_to_text) +from .html_extract import (RE_LINE_PREFIX, RE_TYPE_START, _split_entries, + extract_segments, html_to_text) + +log = logging.getLogger(__name__) # аббревиатуры типа озвучки в заголовках раздач -> фраза, понятная модели TITLE_TYPES = { @@ -295,6 +298,28 @@ def codec_segments(page_text: str) -> list[str]: return out +def complete_block(selected: list[str], page_text: str) -> list[str]: + """Достраивает список дорожек, из которого модель выбрала не все строки. + + Сегментатор оценивает строки по отдельности и может взять две из четырёх + строк списка под «Перевод:». Соседние с выбранной строки, начинающиеся со + слова типа озвучки, добавляются в тот же список; строки вдали от выбранных + не трогаются (глоссарии типов правилами не подхватываются). + """ + lines = [re.sub(r"\s+", " ", l).strip() for l in (page_text or "").splitlines()] + lines = [l for l in lines if l] + chosen = set(selected) + idx = [i for i, l in enumerate(lines) if l in chosen] + add = set() + for step in (1, -1): # вниз и вверх от каждой выбранной + for i in idx: + j = i + step + while 0 <= j < len(lines) and lines[j] not in chosen and RE_TYPE_START.match(lines[j]) and not RE_AUDIO_PREFIX.match(lines[j]): + add.add(j) + j += step + return [l for i, l in enumerate(lines) if l in chosen or i in add] + + def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]: """Дорожки страницы: строки выбирает обученный сегментатор. @@ -307,7 +332,7 @@ def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]: размечена и пока делается правилами `_split_entries`. """ main, codec = [], [] - for line in segmenter.select_lines(page_text): + for line in complete_block(segmenter.select_lines(page_text), page_text): if not RE_AUDIO_PREFIX.match(line): main += _entries(line) elif codec_ok(line): @@ -315,6 +340,31 @@ def model_segments(segmenter, page_text: str) -> tuple[list[str], list[str]]: return main, codec +def rule_segments(page_text: str) -> list[str]: + """Правила по строкам без «Аудио»: кодек-строки модельный путь отбирает сам (`codec_ok`).""" + kept = [l for l in (page_text or "").splitlines() if not RE_AUDIO_PREFIX.match(l)] + return extract_segments("\n".join(kept)) + + +def segmenter_warning(model_on: bool, used: str, segs_page: list[str], + rule_segs: list[str]) -> str | None: + """Сегментатор расходится с правилами — кандидат на дообучение. + + Возвращает текст предупреждения или None. «Нашёл меньше правил» не всегда + ошибка модели (правила дают ложные срабатывания на глоссариях), но такие + страницы стоит просмотреть и добавить в обучение. + """ + if not model_on: + return None + if used == "rules" and rule_segs: + return f"модель не выбрала строк, дорожки по правилам ({len(rule_segs)})" + have = {re.sub(r"\s+", " ", x).strip() for x in segs_page} + missed = [x for x in rule_segs if x not in have] + if used == "model" and missed: + return f"правила нашли дорожек больше, чем модель: +{len(missed)} ({missed[0][:60]})" + return None + + def predict_release(predictor, *, html: str | None = None, text: str | None = None, title: str | None = None, segmenter: str = "model") -> dict: """Предсказание для релиза целиком (html или текст страницы). @@ -370,6 +420,11 @@ def predict_release(predictor, *, html: str | None = None, text: str | None = No if segs: merge_title_tracks(tracks, predictor._predict_batch(segs)) result = aggregate(tracks) + warn = segmenter_warning(seg_model is not None and segmenter == "model", used, + segs_page + segs_codec, rule_segments(page_text)) + if warn: + log.warning("segmenter: %s", warn) + result["segmenter_warning"] = warn result["original"] = original_info(lines) apply_audio_18(result, page_text) for t in result["tracks"]: diff --git a/tests/run_tests.py b/tests/run_tests.py index 84231f9..fa32e41 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -115,6 +115,16 @@ check("список строк под «Перевод:» (rt 6919056)", segs == "Профессиональный (многоголосый закадровый) | HDRezka Studio", "Профессиональный (многоголосый закадровый) | TVShows"], f"got {segs}") +_rt_html = ('
Перевод:
\n' + 'Профессиональный (дублированный) | Cinema Tone Production
\n' + 'Профессиональный (псевдодублированный) | HDRezka Studio
\n' + 'Профессиональный (многоголосый закадровый) | HDRezka Studio
\n' + 'Профессиональный (многоголосый закадровый) | TVShows
\n
\n' + 'Оригинальная аудиодорожка: Английская
') +check("rutracker:
+\\n между дорожками (t=6919056)", + len(extract_segments(html_to_text(_rt_html))) == 4, + f"got {extract_segments(html_to_text(_rt_html))}") + segs = extract_segments("Перевод: Профессиональный (двухголосый закадровый) + Любительский (одноголосый закадровый)") check("разделитель +", len(segs) == 2, f"got {segs}") @@ -127,6 +137,21 @@ check("codec-строка без типа пропущена", len(segs) == 0, f check("_split_entries одиночная", _split_entries("Дублированный") == ["Дублированный"]) +# ---------------------------------------------------------------- сегментатор: достройка блока +print("== complete_block / segmenter_warning ==") +from src.release import complete_block, segmenter_warning # noqa: E402 +_pg = ("Перевод:\nПрофессиональный (дублированный) | A\n\nПрофессиональный (псевдодублированный) | B\n" + "Профессиональный (многоголосый закадровый) | C\nПрофессиональный (многоголосый закадровый) | D\n" + "Субтитры: русские\n\nПрофессиональный дублированный: глоссарий\n") +_got = complete_block(["Профессиональный (псевдодублированный) | B"], _pg) +check("модель взяла 1 из 4 -> достроены все 4, глоссарий не тронут", len(_got) == 4 + and all("глоссарий" not in x for x in _got), f"got {_got}") +check("ничего не выбрано -> ничего не добавляется", complete_block([], _pg) == []) +check("предупреждение: модель пуста, правила нашли", "не выбрала" in (segmenter_warning(True, "rules", [], ["x"]) or "")) +check("предупреждение: правила нашли больше", "больше" in (segmenter_warning(True, "model", ["a"], ["a", "b"]) or "")) +check("без предупреждения: совпало / нет модели", + segmenter_warning(True, "model", ["a"], ["a"]) is None and segmenter_warning(False, "rules", [], ["x"]) is None) + # ---------------------------------------------------------------- html print("== html_to_text ==") html = ("
меню форума
"