Сегментация: список дорожек под «Перевод:» на реальной вёрстке, достройка блока, segmenter_warning
docker / build (push) Successful in 2m1s
docker / build (push) Successful in 2m1s
- html_extract: строки дорожек под пустым «Перевод:» берутся и через пустые строки (в HTML после <br> стоит \n исходника — прежняя правка на реальной странице rutracker t=6919056 отдавала 1 дорожку из 4); шаблон начала строки требует полные слова типа озвучки. - release: complete_block достраивает список, из которого модель выбрала не все строки; segmenter_warning (+ лог) — модель и правила (без кодек-строк) разошлись; поле segmenter_warning в ответе. - docs: retrain-segmenter.md — как дообучить сегментатор, поле в api-reference. - tests: 275 ok. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e185c73714
commit
b3ceefda4f
5 files changed
+188
-7
No files matched your search
@@ -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
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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: …` в логе сервиса — модель
|
||||
и правила разошлись. На глоссариях типов озвучки правила дают ложные
|
||||
срабатывания, поэтому предупреждение — повод посмотреть страницу, а не
|
||||
всегда ошибка.
|
||||
+10
-4
@@ -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 после <br> стоит ещё и \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
|
||||
|
||||
+58
-3
@@ -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"]:
|
||||
|
||||
@@ -115,6 +115,16 @@ check("список строк под «Перевод:» (rt 6919056)", segs ==
|
||||
"Профессиональный (многоголосый закадровый) | HDRezka Studio",
|
||||
"Профессиональный (многоголосый закадровый) | TVShows"], f"got {segs}")
|
||||
|
||||
_rt_html = ('<div class="post_body"><span class="post-b">Перевод</span>: <br>\n'
|
||||
'Профессиональный (дублированный) <span class="post-b">| Cinema Tone Production</span><br>\n'
|
||||
'Профессиональный (псевдодублированный) <span class="post-b">| HDRezka Studio</span><br>\n'
|
||||
'Профессиональный (многоголосый закадровый) <span class="post-b">| HDRezka Studio</span><br>\n'
|
||||
'Профессиональный (многоголосый закадровый) <span class="post-b">| TVShows</span><br>\n<br>\n'
|
||||
'<span class="post-b">Оригинальная аудиодорожка</span>: Английская</div>')
|
||||
check("rutracker: <br>+\\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 = ("<html><body><div>меню форума</div>"
|
||||
|
||||
Reference in new issue
Block a user