Docker-сервис, выбор основы и сравнение моделей, сборщик датасета
docker / build (push) Failing after 39s
docker / build (push) Failing after 39s
- Docker: Dockerfile (CPU), compose с FlareSolverr, настройка через DUB_* переменные, токен доступа к /predict, tools/pack_models.py, workflow Gitea Actions, docs/docker.md - Модель грузится офлайн (config.json рядом с весами); общий код вынесен в src/spans.py, сервису не нужны sklearn/matplotlib/pandas - Обучение: профили tiny2/base/large/modern, запуски в models/runs, реестр и честное сравнение на одной тестовой выборке (src/runs.py), пункт меню «Модели» - Датасет: язык и аудио-параметры дорожек, 18+ по строкам «Аудио», ручные метки по ссылкам, переиндексация сохранённого HTML, параллельная сборка, сбалансированная выборка - Тексты страниц (data/pages) больше не хранятся в git Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d155b8584b
commit
efc9a4f56a
411 files changed
+87495
-27363
No files matched your search
+269
@@ -0,0 +1,269 @@
|
||||
"""Интерфейс консоли для tools/collect.py: живая панель сбора, статистика, меню (rich)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console, Group
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.progress import (BarColumn, MofNCompleteColumn, Progress, ProgressColumn,
|
||||
SpinnerColumn, TaskProgressColumn, TextColumn, TimeElapsedColumn,
|
||||
TimeRemainingColumn)
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
console = Console()
|
||||
|
||||
SITE_STYLE = {"rutracker": "cyan", "nnm": "magenta", "kinozal": "yellow"}
|
||||
STATE_STYLE = {"ожидание": "dim", "готово": "green", "ошибка": "red", "остановлено": "yellow"}
|
||||
|
||||
|
||||
def fmt_time(sec: float | None) -> str:
|
||||
if sec is None or sec != sec:
|
||||
return "—"
|
||||
sec = int(sec)
|
||||
h, rest = divmod(sec, 3600)
|
||||
m, s = divmod(rest, 60)
|
||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def bar(frac: float, width: int = 16, color: str = "green") -> Text:
|
||||
frac = max(0.0, min(1.0, frac))
|
||||
full = int(round(frac * width))
|
||||
t = Text()
|
||||
t.append("█" * full, style=color)
|
||||
t.append("░" * (width - full), style="grey35")
|
||||
return t
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""Счётчики одного трекера в текущем запуске сбора."""
|
||||
|
||||
site: str
|
||||
limit: int = 0
|
||||
state: str = "ожидание"
|
||||
section: str = ""
|
||||
saved: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0 # удалённые раздачи («Нет раздачи с таким ID»)
|
||||
started: float = 0.0
|
||||
finished: float = 0.0
|
||||
last: str = ""
|
||||
errors: deque = field(default_factory=lambda: deque(maxlen=3))
|
||||
stamps: deque = field(default_factory=lambda: deque(maxlen=12))
|
||||
|
||||
def begin(self, limit: int) -> None:
|
||||
self.limit, self.saved, self.failed, self.skipped = limit, 0, 0, 0
|
||||
self.started, self.finished = time.time(), 0.0
|
||||
self.stamps.clear()
|
||||
self.errors.clear()
|
||||
|
||||
def ok(self, title: str) -> None:
|
||||
self.saved += 1
|
||||
self.last = title
|
||||
self.stamps.append(time.time())
|
||||
|
||||
def fail(self, msg: str) -> None:
|
||||
self.failed += 1
|
||||
self.errors.append(msg)
|
||||
|
||||
def done(self, state: str = "готово") -> None:
|
||||
self.finished = time.time()
|
||||
self.state = state
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
if not self.started:
|
||||
return 0.0
|
||||
return (self.finished or time.time()) - self.started
|
||||
|
||||
@property
|
||||
def speed(self) -> float:
|
||||
"""Страниц в минуту: по последним сохранениям, пока их мало — за всё время."""
|
||||
if len(self.stamps) >= 4 and not self.finished:
|
||||
span = self.stamps[-1] - self.stamps[0]
|
||||
if span > 0:
|
||||
return (len(self.stamps) - 1) / span * 60
|
||||
return self.saved / self.elapsed * 60 if self.elapsed > 0 and self.saved else 0.0
|
||||
|
||||
@property
|
||||
def eta(self) -> float | None:
|
||||
if self.finished or self.speed <= 0 or self.limit <= self.saved:
|
||||
return None
|
||||
return (self.limit - self.saved) / self.speed * 60
|
||||
|
||||
|
||||
STATS: dict[str, Stats] = {}
|
||||
|
||||
|
||||
def get_stats(site: str) -> Stats:
|
||||
return STATS.setdefault(site, Stats(site))
|
||||
|
||||
|
||||
def dashboard(sites: list[str]) -> Panel:
|
||||
"""Живая таблица по трекерам: состояние, прогресс, скорость, ETA, последняя раздача."""
|
||||
t = Table(box=box.SIMPLE_HEAD, expand=True, pad_edge=False)
|
||||
t.add_column("Трекер", style="bold", no_wrap=True)
|
||||
t.add_column("Состояние", no_wrap=True)
|
||||
t.add_column("Прогресс", no_wrap=True)
|
||||
t.add_column("Сохр.", justify="right")
|
||||
t.add_column("Ош.", justify="right")
|
||||
t.add_column("Удал.", justify="right")
|
||||
t.add_column("стр/мин", justify="right")
|
||||
t.add_column("Время", justify="right")
|
||||
t.add_column("ETA", justify="right")
|
||||
t.add_column("Раздел / последняя раздача", overflow="ellipsis", no_wrap=True, ratio=1)
|
||||
total_saved = total_failed = 0
|
||||
speed_sum = 0.0
|
||||
for s in sites:
|
||||
st = get_stats(s)
|
||||
total_saved += st.saved
|
||||
total_failed += st.failed
|
||||
speed_sum += st.speed if not st.finished else 0
|
||||
frac = st.saved / st.limit if st.limit else 0
|
||||
prog = Text.assemble(bar(frac, 14, SITE_STYLE.get(s, "green")),
|
||||
f" {st.saved}/{st.limit or '?'}")
|
||||
state = Text(st.state, style=STATE_STYLE.get(st.state, "white"))
|
||||
info = Text(st.section or "", style="dim")
|
||||
if st.last:
|
||||
info.append(" ▸ " + st.last[:70], style="white")
|
||||
t.add_row(Text(s, style=SITE_STYLE.get(s, "white")), state, prog, str(st.saved),
|
||||
Text(str(st.failed), style="red" if st.failed else "dim"),
|
||||
Text(str(st.skipped), style="yellow" if st.skipped else "dim"),
|
||||
f"{st.speed:4.1f}" if st.speed else "—", fmt_time(st.elapsed) if st.started else "—",
|
||||
fmt_time(st.eta), info)
|
||||
foot = Text.assemble(("Итого сохранено: ", "dim"), (str(total_saved), "bold green"),
|
||||
(" ошибок: ", "dim"), (str(total_failed), "red" if total_failed else "dim"),
|
||||
(" общая скорость: ", "dim"), (f"{speed_sum:.1f} стр/мин", "bold"),
|
||||
(" Ctrl+C — остановить после текущей раздачи", "dim italic"))
|
||||
errs = [Text(f" ! {s}: {e}", style="red") for s in sites for e in list(get_stats(s).errors)[-1:]]
|
||||
return Panel(Group(t, foot, *errs), title="[bold]Сбор страниц[/bold]", border_style="blue")
|
||||
|
||||
|
||||
def live(sites: list[str]) -> Live:
|
||||
return Live(dashboard(sites), console=console, refresh_per_second=4, transient=False,
|
||||
get_renderable=lambda: dashboard(sites))
|
||||
|
||||
|
||||
def distribution(title: str, counter: Counter, width: int = 18, top: int = 8) -> Table:
|
||||
t = Table(box=box.SIMPLE, title=title, title_justify="left", show_header=False, pad_edge=False)
|
||||
t.add_column(justify="left")
|
||||
t.add_column(justify="right")
|
||||
t.add_column()
|
||||
items = counter.most_common(top)
|
||||
mx = max((n for _, n in items), default=1)
|
||||
for k, n in items:
|
||||
t.add_row(str(k) or "—", str(n), bar(n / mx, width, "cyan"))
|
||||
return t
|
||||
|
||||
|
||||
def coverage_table(rows: list[dict]) -> Table:
|
||||
"""rows: {site, labeled, pages, postonly, raw, meta, missing}."""
|
||||
t = Table(box=box.ROUNDED, title="Покрытие датасета", title_justify="left")
|
||||
for col, just in (("Трекер", "left"), ("Размечено", "right"), ("Страниц", "right"),
|
||||
("Полных", "right"), ("Урезан.", "right"),
|
||||
("Raw", "right"), ("Meta", "right"), ("Без стр.", "right")):
|
||||
t.add_column(col, justify=just, no_wrap=True, min_width=len(col))
|
||||
for r in rows:
|
||||
full = r["pages"] - r["postonly"]
|
||||
t.add_row(Text(r["site"], style=SITE_STYLE.get(r["site"], "white")), str(r["labeled"]),
|
||||
str(r["pages"]), str(full), str(r["postonly"]), str(r["raw"]), str(r["meta"]),
|
||||
Text(str(r["missing"]), style="red" if r["missing"] else "green"))
|
||||
return t
|
||||
|
||||
|
||||
def _short_ref(ref: str) -> str:
|
||||
if ref.startswith("http"):
|
||||
import re
|
||||
m = re.search(r"c=(\d+)&v=(\d+)&d=(\d+)", ref)
|
||||
return f"c={m.group(1)} v={m.group(2)} d={m.group(3)}" if m else ref[-24:]
|
||||
return f"f={ref}"
|
||||
|
||||
|
||||
def sections_table(site: str, entries: list[dict], rows: int = 14) -> Panel:
|
||||
"""Итог поиска разделов: что взято, что пропущено, сколько тем на первой странице."""
|
||||
kept = [e for e in entries if e.get("keep")]
|
||||
skipped = [e for e in entries if not e.get("keep")]
|
||||
t = Table(box=box.SIMPLE_HEAD, expand=True, pad_edge=False)
|
||||
t.add_column("", width=2)
|
||||
t.add_column("Раздел / список", overflow="ellipsis", no_wrap=True, ratio=1)
|
||||
t.add_column("ID", style="dim", no_wrap=True)
|
||||
t.add_column("Тем", justify="right")
|
||||
for e in (kept[:rows] + skipped[:3]):
|
||||
ok = e.get("keep")
|
||||
mark = Text("✓", style="green") if e.get("status") == "ok" else (
|
||||
Text("?", style="yellow") if ok else Text("✗", style="red"))
|
||||
topics = "—" if e.get("topics") is None else str(e["topics"])
|
||||
t.add_row(mark, e["name"], _short_ref(e["ref"]), topics)
|
||||
hidden = len(kept) - min(len(kept), rows)
|
||||
foot = f"взято {len(kept)}" + (f", пропущено {len(skipped)} (мало тем)" if skipped else "")
|
||||
if hidden:
|
||||
foot += f" [dim]… и ещё {hidden} в списке[/]"
|
||||
unsure = sum(1 for e in kept if e.get("status") == "?")
|
||||
if unsure:
|
||||
foot += f" [yellow]? — не удалось проверить: {unsure}[/]"
|
||||
return Panel(Group(t, Text.from_markup(foot)), title=f"[bold]{site}[/] · найденные разделы",
|
||||
border_style=SITE_STYLE.get(site, "blue"))
|
||||
|
||||
|
||||
def banner(lines: list[str]) -> Panel:
|
||||
body = Text("\n".join(lines))
|
||||
return Panel(body, title="[bold]DUB-AI · датасет и модель[/bold]", border_style="green",
|
||||
padding=(0, 2))
|
||||
|
||||
|
||||
def menu_table(items: list[tuple[str, str]]) -> Table:
|
||||
t = Table(box=None, show_header=False, pad_edge=False)
|
||||
t.add_column(style="bold cyan", justify="right")
|
||||
t.add_column()
|
||||
for key, label in items:
|
||||
t.add_row(key, label)
|
||||
return t
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""Очистить терминал (если вывод не в консоль — ничего не делает)."""
|
||||
if console.is_terminal:
|
||||
console.clear()
|
||||
|
||||
|
||||
def pause(text: str = "Enter — вернуться в меню") -> None:
|
||||
try:
|
||||
console.input(f"\n[dim]{text}…[/dim]")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
pass
|
||||
|
||||
|
||||
def ask(prompt: str, default: str = "") -> str:
|
||||
return Prompt.ask(f"[bold]{prompt}[/bold]", default=default or None, console=console,
|
||||
show_default=bool(default)) or ""
|
||||
|
||||
|
||||
class _Rate(ProgressColumn):
|
||||
"""Скорость: элементов в секунду (по окну rich)."""
|
||||
|
||||
def render(self, task) -> Text:
|
||||
return Text(f"{task.speed:.1f}/с" if task.speed else "—/с", style="cyan")
|
||||
|
||||
|
||||
def progress() -> Progress:
|
||||
"""Полоса прогресса: что делаем, N/M, %, скорость, прошло, осталось."""
|
||||
return Progress(SpinnerColumn(), TextColumn("[bold]{task.description:<26}"), BarColumn(bar_width=26),
|
||||
MofNCompleteColumn(), TaskProgressColumn(), _Rate(),
|
||||
TextColumn("прошло"), TimeElapsedColumn(), TextColumn("осталось"),
|
||||
TimeRemainingColumn(), console=console, transient=False)
|
||||
|
||||
|
||||
def summary_table(title: str, columns: list[str], rows: list[list]) -> Table:
|
||||
t = Table(box=box.SIMPLE_HEAD, title=title, title_justify="left", pad_edge=False)
|
||||
for i, c in enumerate(columns):
|
||||
t.add_column(c, justify="left" if i == 0 else "right")
|
||||
for r in rows:
|
||||
t.add_row(*[str(x) for x in r])
|
||||
return t
|
||||
Reference in new issue
Block a user