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 = ('