Рабочий пайплайн: YouTube -> транскрипция -> выжимка -> Postgres

- новый api/: /pipeline/process (yt-dlp, субтитры или Vosk, LLM/экстрактивная
  суммаризация), /videos, /llm; старый код перенесён в api_legacy/
- web/: Flet UI (flet 0.28.3 + flet-web, порт 8550)
- Dockerfile.api: ffmpeg слоем из mwader/static-ffmpeg, pip с кэш-маунтом
- requirements/pyproject: убраны moviepy, imageio-ffmpeg, битый asyncio
- README с инструкцией запуска и загрузкой Vosk-модели

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-14 11:12:46 +05:00
parent 564c9c2d2f
commit 2697e01714
51 changed files with 2313 additions and 284 deletions

406
web/views/main_view.py Normal file
View File

@@ -0,0 +1,406 @@
"""Main page of the LLM-infa Flet web app."""
from __future__ import annotations
import threading
import flet as ft
from web.api_client import ApiClient, ApiError
import web.designer as d
class MainView:
def __init__(self, page: ft.Page, client: ApiClient):
self.page = page
self.client = client
self.models: list[dict] = []
self.selected_model: str = ""
# ── LLM config fields ──────────────────────────────────────────────
self.base_url = d.text_field("Base URL", hint="https://api.openai.com/v1", expand=True)
self.api_key = d.text_field("API Key", hint="sk-…", password=True, expand=True)
# Searchable model combobox
self.model_input = d.text_field(
"Модель",
hint="Сначала нажмите «Загрузить модели»",
on_change=self._on_model_search,
expand=True,
)
self._model_list_col = ft.Column(spacing=2, scroll=ft.ScrollMode.AUTO)
self._model_list_wrap = ft.Container(
content=self._model_list_col,
height=260,
bgcolor=d.SURFACE_3,
border=ft.border.all(1, d.BORDER),
border_radius=8,
padding=4,
visible=False,
)
self.llm_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.llm_status_chip = ft.Container(
content=ft.Text("LLM не подключена", size=12, color=d.MUTED),
padding=ft.padding.symmetric(horizontal=10, vertical=4),
border=ft.border.all(1, d.BORDER),
border_radius=99,
)
self.btn_load = d.primary_button("Загрузить модели", self._load_models)
self.btn_test = d.secondary_button("Проверить связь", self._test_conn)
# ── Pipeline fields ────────────────────────────────────────────────
self.yt_url = d.text_field(
"Ссылка на YouTube",
hint="https://youtu.be/…",
expand=True,
)
self.vosk_path = d.text_field(
"Vosk-модель (fallback, когда нет субтитров)",
value="models/vosk-model-small-ru-0.22",
expand=True,
)
self.n_sentences = ft.TextField(
label="Предложений в выжимке",
value="15",
input_filter=ft.NumbersOnlyInputFilter(),
bgcolor=d.SURFACE_3,
color=d.TEXT,
border_color=d.BORDER,
focused_border_color=d.ACCENT,
label_style=ft.TextStyle(color=d.MUTED, size=12),
text_size=14,
width=220,
)
self.pipeline_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.btn_run = d.primary_button("▶ Запустить", self._run_pipeline)
# Result block (initially hidden)
self._result_col = ft.Column(spacing=6, visible=False)
# Library
self._library_col = ft.Column(spacing=8)
# ─────────────────────── build ───────────────────────────────────────
def build(self) -> ft.Control:
return ft.Column(
controls=[
self._build_topbar(),
ft.Container(
content=ft.Column(
[
self._build_llm_card(),
self._build_pipeline_card(),
self._build_library_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
),
],
expand=True,
spacing=0,
)
def _build_topbar(self) -> ft.Container:
return ft.Container(
content=ft.Row(
[
ft.Row(
[
ft.Container(
width=14,
height=14,
border_radius=4,
gradient=ft.LinearGradient(
begin=ft.alignment.top_left,
end=ft.alignment.bottom_right,
colors=[d.ACCENT, "#5b8def"],
),
),
ft.Text("LLM-infa", color=d.TEXT, size=17, weight=ft.FontWeight.W_600),
],
spacing=10,
),
ft.Container(expand=True),
self.llm_status_chip,
],
),
padding=ft.padding.symmetric(horizontal=24, vertical=14),
bgcolor=ft.Colors.with_opacity(0.6, d.SURFACE),
border=ft.border.only(bottom=ft.BorderSide(1, d.BORDER)),
)
def _build_llm_card(self) -> ft.Container:
return d.card(
d.section_title("Подключение к LLM"),
d.hint(
"Укажите любой OpenAI-совместимый провайдер. "
"Нейронка внутри Docker не разворачивается — "
"указываем URL внешнего сервиса."
),
ft.Row([self.base_url, self.api_key], spacing=12),
ft.Row([self.btn_load, self.btn_test, self.llm_msg], spacing=10),
ft.Divider(color=d.BORDER, height=1),
self.model_input,
self._model_list_wrap,
)
def _build_pipeline_card(self) -> ft.Container:
return d.card(
d.section_title("Обработать видео"),
d.hint(
"Pipeline: yt-dlp → субтитры (или Vosk fallback) "
"→ выжимка через выбранную LLM → запись в БД."
),
self.yt_url,
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
ft.Row([self.btn_run, self.pipeline_msg], spacing=10),
self._result_col,
)
def _build_library_card(self) -> ft.Container:
return d.card(
ft.Row(
[
d.section_title("Библиотека"),
ft.Container(expand=True),
d.secondary_button("Обновить", lambda _: self._refresh_library()),
]
),
d.hint("Все обработанные видео и пути к файлам."),
self._library_col,
)
# ─────────────────── model combobox ──────────────────────────────────
def _on_model_search(self, e):
q = (self.model_input.value or "").strip().lower()
filtered = [m for m in self.models if q in m["id"].lower()] if q else self.models
self._render_model_list(filtered)
def _render_model_list(self, items: list[dict]):
self._model_list_col.controls = [
ft.Container(
content=ft.Row(
[
ft.Text(m["id"], color=d.TEXT, size=13, expand=True, no_wrap=True),
ft.Text(m.get("owned_by", ""), color=d.MUTED, size=11),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
ink=True,
bgcolor=d.SURFACE_2,
padding=ft.padding.symmetric(horizontal=10, vertical=8),
border_radius=6,
on_click=lambda e, mid=m["id"]: self._select_model(mid),
)
for m in items
]
if not items:
self._model_list_col.controls = [
ft.Text("Ничего не найдено", color=d.MUTED, size=13)
]
self._model_list_wrap.visible = bool(self.models)
self.page.update()
def _select_model(self, mid: str):
self.selected_model = mid
self.model_input.value = mid
self._model_list_wrap.visible = False
self.page.update()
# ─────────────────── LLM actions ─────────────────────────────────────
def _load_models(self, _):
base_url = self.base_url.value.strip()
if not base_url:
self._set_llm_msg("Укажите Base URL", error=True)
return
self._set_llm_msg("Загружаю…")
self.btn_load.disabled = True
self.page.update()
def _task():
try:
self.models = self.client.llm_models(base_url, self.api_key.value.strip())
self._render_model_list(self.models)
self._set_llm_msg(f"Загружено: {len(self.models)} моделей", ok=True)
self._set_status(ok=True)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
self._set_status(ok=False)
finally:
self.btn_load.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
def _test_conn(self, _):
if not self.selected_model:
self._set_llm_msg("Сначала выберите модель", error=True)
return
self._set_llm_msg("Проверяю…")
self.btn_test.disabled = True
self.page.update()
def _task():
try:
r = self.client.llm_test(
self.base_url.value.strip(),
self.api_key.value.strip(),
self.selected_model,
)
preview = (r.get("reply") or "")[:80]
self._set_llm_msg(f"OK · {preview}", ok=True)
self._set_status(ok=True)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
self._set_status(ok=False)
finally:
self.btn_test.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
# ─────────────────── pipeline ─────────────────────────────────────────
def _run_pipeline(self, _):
url = self.yt_url.value.strip()
if not url:
self._set_pipeline_msg("Укажите ссылку на видео", error=True)
return
payload: dict = {
"url": url,
"model_path": self.vosk_path.value.strip() or None,
"summary_max_sentences": int(self.n_sentences.value or 15),
}
if self.base_url.value.strip() and self.selected_model:
payload["llm"] = {
"base_url": self.base_url.value.strip(),
"api_key": self.api_key.value.strip(),
"model": self.selected_model,
}
self._set_pipeline_msg("Обработка видео… (может занять несколько минут)")
self._result_col.visible = False
self.btn_run.disabled = True
self.page.update()
def _task():
try:
r = self.client.pipeline_process(payload)
self._render_result(r)
self._set_pipeline_msg("Готово ✓", ok=True)
self._refresh_library()
except ApiError as e:
self._set_pipeline_msg(str(e), error=True)
finally:
self.btn_run.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
def _render_result(self, r: dict):
self._result_col.controls = [
ft.Divider(color=d.BORDER, height=1),
ft.Text(r.get("title") or r.get("uuid", ""), color=d.ACCENT_2, size=15, weight=ft.FontWeight.W_600),
*[
self._kv(k, v)
for k, v in [
("UUID", r.get("uuid", "")),
("Источник", r.get("source_url", "")),
("Метод", r.get("transcription_method", "")),
("Видео", r.get("video_path", "")),
("Полный текст", r.get("text_full_path", "")),
("Выжимка", r.get("text_summary_path", "")),
]
],
ft.Text("Превью выжимки:", color=d.MUTED, size=12),
ft.Container(
content=ft.Text(r.get("summary_preview", ""), color=d.TEXT, size=13, selectable=True, no_wrap=False),
padding=12,
bgcolor=d.SURFACE_3,
border=ft.border.all(1, d.BORDER),
border_radius=8,
),
]
self._result_col.visible = True
# ─────────────────── library ─────────────────────────────────────────
def _refresh_library(self):
try:
items = self.client.list_videos()
except ApiError as e:
self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)]
self.page.update()
return
if not items:
self._library_col.controls = [ft.Text("Пока пусто", color=d.MUTED, size=13)]
else:
self._library_col.controls = [self._lib_card(v) for v in items]
self.page.update()
def _lib_card(self, v: dict) -> ft.Container:
created = v.get("created_at", "")[:19].replace("T", " ")
return ft.Container(
content=ft.Column(
[
ft.Text(
v.get("title") or v.get("uuid", ""),
color=d.TEXT,
size=14,
weight=ft.FontWeight.W_600,
no_wrap=True,
),
ft.Text(v.get("source_url", ""), color=d.MUTED, size=11, selectable=True),
ft.Row(
[
ft.Text(f"uuid: {v.get('uuid', '')}", color=d.MUTED, size=11, selectable=True, expand=True),
ft.Text(created, color=d.MUTED, size=11),
]
),
self._kv("Метод", v.get("transcription_method", "")),
self._kv("Видео", v.get("video_path", "")),
self._kv("Выжимка", v.get("text_summary_path", "")),
],
spacing=3,
tight=True,
),
padding=12,
bgcolor=d.SURFACE_2,
border=ft.border.all(1, d.BORDER),
border_radius=10,
)
# ─────────────────── helpers ──────────────────────────────────────────
def _kv(self, key: str, value: str) -> ft.Row:
return ft.Row(
[
ft.Text(key, color=d.MUTED, size=12, width=120),
ft.Text(value, color=d.TEXT, size=12, selectable=True, expand=True, no_wrap=False),
],
)
def _set_llm_msg(self, text: str, *, ok: bool = False, error: bool = False):
self.llm_msg.value = text
self.llm_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
def _set_pipeline_msg(self, text: str, *, ok: bool = False, error: bool = False):
self.pipeline_msg.value = text
self.pipeline_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
def _set_status(self, *, ok: bool):
chip = self.llm_status_chip
chip.content.value = "LLM подключена" if ok else "Ошибка подключения"
chip.content.color = d.OK if ok else d.DANGER
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else "rgba(227,90,114,.4)")