Added docker support

This commit is contained in:
2026-09-23 03:26:09 +03:00
parent 22cc5d0034
commit 4b0a19713a
13 changed files with 186 additions and 95 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
.venv
__pycache__/
*.pyc
runtime/
debug/
images/
input.*
audio.*
video.*
output.*
*.json

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1
ARG GPU=nvidia
FROM nvidia/cuda:13.0.2-cudnn-runtime-ubuntu24.04 AS base-nvidia
FROM rocm/dev-ubuntu-24.04:7.2.1-complete AS base-amd
FROM base-${GPU} AS app
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
XDG_CACHE_HOME=/tmp/sumka-cache
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg fonts-dejavu-core \
python3 python3-pip python3-venv \
&& curl -fsSL -o /tmp/chrome.deb \
https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
&& apt-get install -y /tmp/chrome.deb \
&& rm -rf /var/lib/apt/lists/* /tmp/chrome.deb
WORKDIR /app
COPY requirements.txt requirements-nvidia.txt requirements-amd.txt ./
ARG GPU
RUN python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
&& /opt/venv/bin/pip install --no-cache-dir -r requirements-${GPU}.txt
ENV PATH=/opt/venv/bin:$PATH
COPY . ./
WORKDIR /work
ENTRYPOINT ["python3", "/app/main.py"]

View File

@@ -15,7 +15,36 @@
## Установка (Docker) ## Установка (Docker)
TODO Образы собираются из одного Dockerfile:
```bash
docker build --build-arg GPU=nvidia -t sumka:nvidia .
docker build --build-arg GPU=amd -t sumka:amd .
```
Для NVIDIA нужен настроенный NVIDIA Container Toolkit. Для AMD на хосте должен
быть установлен совместимый ROCm. Рабочая директория монтируется в `/work`:
```bash
WORK_DIR=$(mktemp -d)
cp input.mkv "$WORK_DIR/"
# nvidia
docker run --rm --gpus all -e AI_API_KEY \
--mount type=volume,src=sumka-whisper-cache,dst=/root/.cache/whisper \
--mount type=bind,src="$WORK_DIR",dst=/work \
--tmpfs /tmp:rw,size=8g sumka:nvidia
# amd
docker run --rm --device=/dev/kfd --device=/dev/dri --group-add video \
--security-opt seccomp=unconfined -e AI_API_KEY \
--mount type=volume,src=sumka-whisper-cache,dst=/root/.cache/whisper \
--mount type=bind,src="$WORK_DIR",dst=/work \
--tmpfs /tmp:rw,size=8g sumka:amd
```
Все входные, промежуточные и итоговые файлы находятся в `WORK_DIR`. Для дампов
запросов заранее создайте `"$WORK_DIR/debug"`; промпты остаются внутри образа.
## Установка (Python) ## Установка (Python)
@@ -28,8 +57,8 @@ cd 2026-linux-sumka
python3 -m venv .venv python3 -m venv .venv
# Активировать виртуальное окружение # Активировать виртуальное окружение
. .venv/bin/activate . .venv/bin/activate
# Установить зависимости # Установить зависимости под свою видеокарту
pip install -r requirements.txt pip install -r requirements-nvidia.txt # либо requirements-amd.txt
# Также должны быть установлены ffmpeg и Chromium/Google Chrome # Также должны быть установлены ffmpeg и Chromium/Google Chrome

View File

@@ -1,13 +1,12 @@
from typing import Any from typing import Any
import traceback import traceback
import shutil
import os
import json import json
from pydantic import BaseModel from pydantic import BaseModel
from agent import Agent, AgentMessage from agent import Agent, AgentMessage
from windowizer import Windowizer, Window from windowizer import Windowizer, Window
from asr_filter import AsrFilterResult, AsrFilterSegment from asr_filter import AsrFilterResult, AsrFilterSegment
from paths import debug_path, prompt_path
from utils import Timeline, Event from utils import Timeline, Event
class _PreEvent(BaseModel): class _PreEvent(BaseModel):
@@ -41,21 +40,19 @@ class AsrEventizer:
role="user" role="user"
) )
] ]
debug_dir = None debug_dir = debug_path("AsrEventizer", AsrEventizer.DEBUG_ID)
if os.path.isdir("debug"):
debug_dir = f"debug/AsrEventizer/{AsrEventizer.DEBUG_ID}"
AsrEventizer.DEBUG_ID += 1
os.makedirs(debug_dir, exist_ok=True)
if debug_dir: if debug_dir:
with open(f"{debug_dir}/request.txt", "w") as f: AsrEventizer.DEBUG_ID += 1
f.write(messages[1].content) (debug_dir / "request.txt").write_text(messages[1].content, encoding="utf-8")
retries_left = 5 retries_left = 5
while retries_left > 0: while retries_left > 0:
retries_left -= 1 retries_left -= 1
response = self._agent.completion(messages=messages, response_format=_EventizeResult) response = self._agent.completion(messages=messages, response_format=_EventizeResult)
if debug_dir: if debug_dir:
with open(f"{debug_dir}/{retries_left}-retries-left.txt", "w") as f: (debug_dir / f"{retries_left}-retries-left.txt").write_text(
f.write(response) response,
encoding="utf-8",
)
# validate data # validate data
try: try:
response = json.loads(response) response = json.loads(response)
@@ -85,10 +82,9 @@ class AsrEventizer:
""" """
self._agent = agent self._agent = agent
self._windowizer = windowizer self._windowizer = windowizer
with open("prompts/asr_eventizer.md", "r") as f:
self._system_prompt = AgentMessage( self._system_prompt = AgentMessage(
content=f.read(), content=prompt_path("asr_eventizer.md").read_text(encoding="utf-8"),
role="system" role="system",
) )
def eventize(self, asr_filter_result: AsrFilterResult) -> Timeline: def eventize(self, asr_filter_result: AsrFilterResult) -> Timeline:

24
paths.py Normal file
View File

@@ -0,0 +1,24 @@
"""Paths for bundled resources and per-run artifacts."""
from pathlib import Path
SOURCE_DIR = Path(__file__).resolve().parent
def prompt_path(name: str) -> Path:
"""Return a prompt bundled with the application source code."""
path = SOURCE_DIR / "prompts" / name
if not path.is_file():
raise FileNotFoundError(f"Prompt not found: {path}")
return path
def debug_path(component: str, index: int) -> Path | None:
"""Create a debug directory in the current working directory when enabled."""
root = Path.cwd() / "debug"
if not root.is_dir():
return None
path = root / component / str(index)
path.mkdir(parents=True, exist_ok=True)
return path

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import html import html
import os
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -277,6 +278,8 @@ class PdfBuilder:
f"--print-to-pdf={destination}", f"--print-to-pdf={destination}",
html_path.as_uri(), html_path.as_uri(),
] ]
if os.geteuid() == 0:
command.insert(1, "--no-sandbox")
try: try:
result = subprocess.run( result = subprocess.run(
command, command,

View File

@@ -13,6 +13,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
from agent import Agent, AgentMessage from agent import Agent, AgentMessage
from paths import debug_path, prompt_path
from utils import Event, Timeline from utils import Event, Timeline
from video_references import UnresolvedReference, UnresolvedReferences from video_references import UnresolvedReference, UnresolvedReferences
@@ -94,10 +95,14 @@ class ReferenceResolver:
self._image_dir = Path(image_dir) self._image_dir = Path(image_dir)
self._frame_interval = frame_interval self._frame_interval = frame_interval
self._max_frames = max_frames self._max_frames = max_frames
with open("prompts/reference_resolver.md", "r", encoding="utf-8") as f: self._system_prompt = AgentMessage(
self._system_prompt = AgentMessage(content=f.read(), role="system") content=prompt_path("reference_resolver.md").read_text(encoding="utf-8"),
with open("prompts/reference_resolver_ocr.md", "r", encoding="utf-8") as f: role="system",
self._ocr_prompt = AgentMessage(content=f.read(), role="system") )
self._ocr_prompt = AgentMessage(
content=prompt_path("reference_resolver_ocr.md").read_text(encoding="utf-8"),
role="system",
)
def _sampling_timestamps(self, start: float, end: float) -> list[float]: def _sampling_timestamps(self, start: float, end: float) -> list[float]:
start = max(0.0, start) start = max(0.0, start)
@@ -217,13 +222,10 @@ class ReferenceResolver:
), ),
] ]
debug_dir = None debug_dir = debug_path("ReferenceResolver", ReferenceResolver.DEBUG_ID)
if os.path.isdir("debug"): if debug_dir:
debug_dir = f"debug/ReferenceResolver/{ReferenceResolver.DEBUG_ID}"
ReferenceResolver.DEBUG_ID += 1 ReferenceResolver.DEBUG_ID += 1
os.makedirs(debug_dir, exist_ok=True) (debug_dir / "request.txt").write_text(messages[1].content, encoding="utf-8")
with open(f"{debug_dir}/request.txt", "w", encoding="utf-8") as f:
f.write(messages[1].content)
retries_left = self.MAX_RETRIES retries_left = self.MAX_RETRIES
while retries_left > 0: while retries_left > 0:
@@ -235,12 +237,10 @@ class ReferenceResolver:
response_format=_FrameResult, response_format=_FrameResult,
) )
if debug_dir: if debug_dir:
with open( (debug_dir / f"{retries_left}-retries-left.txt").write_text(
f"{debug_dir}/{retries_left}-retries-left.txt", response,
"w",
encoding="utf-8", encoding="utf-8",
) as f: )
f.write(response)
try: try:
result = _FrameResult.model_validate_json(response) result = _FrameResult.model_validate_json(response)
@@ -312,14 +312,11 @@ class ReferenceResolver:
), ),
] ]
debug_dir = None debug_dir = debug_path("ReferenceResolverOCR", ReferenceResolver.OCR_DEBUG_ID)
if os.path.isdir("debug"): if debug_dir:
debug_dir = f"debug/ReferenceResolverOCR/{ReferenceResolver.OCR_DEBUG_ID}"
ReferenceResolver.OCR_DEBUG_ID += 1 ReferenceResolver.OCR_DEBUG_ID += 1
os.makedirs(debug_dir, exist_ok=True) (debug_dir / "request.txt").write_text(messages[1].content, encoding="utf-8")
with open(f"{debug_dir}/request.txt", "w", encoding="utf-8") as f: shutil.copyfile(crop_path, debug_dir / "crop.jpg")
f.write(messages[1].content)
shutil.copyfile(crop_path, f"{debug_dir}/crop.jpg")
retries_left = self.MAX_RETRIES retries_left = self.MAX_RETRIES
while retries_left > 0: while retries_left > 0:
@@ -331,12 +328,10 @@ class ReferenceResolver:
response_format=_OCRResult, response_format=_OCRResult,
) )
if debug_dir: if debug_dir:
with open( (debug_dir / f"{retries_left}-retries-left.txt").write_text(
f"{debug_dir}/{retries_left}-retries-left.txt", response,
"w",
encoding="utf-8", encoding="utf-8",
) as f: )
f.write(response)
try: try:
result = _OCRResult.model_validate_json(response) result = _OCRResult.model_validate_json(response)

3
requirements-amd.txt Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.txt
--extra-index-url https://download.pytorch.org/whl/rocm7.2
torch==2.14.0+rocm7.2

3
requirements-nvidia.txt Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.txt
--extra-index-url https://download.pytorch.org/whl/cu130
torch==2.14.0+cu130

View File

@@ -1,6 +1,5 @@
httpx2 httpx2==2.13.0
latex2mathml latex2mathml==3.81.1
openai openai==3.14.0
openai-whisper openai-whisper==20250625
pydantic>=2 pydantic==2.13.5
torch

View File

@@ -1,11 +1,11 @@
import json import json
import os
import traceback import traceback
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from agent import Agent, AgentMessage from agent import Agent, AgentMessage
from paths import debug_path, prompt_path
from utils import Event, Timeline from utils import Event, Timeline
from windowizer import Window, Windowizer from windowizer import Window, Windowizer
@@ -146,8 +146,10 @@ class StructureBuilder:
""" """
self._agent = agent self._agent = agent
self._windowizer = windowizer self._windowizer = windowizer
with open("prompts/structure_builder.md", "r", encoding="utf-8") as f: self._system_prompt = AgentMessage(
self._system_prompt = AgentMessage(content=f.read(), role="system") content=prompt_path("structure_builder.md").read_text(encoding="utf-8"),
role="system",
)
@staticmethod @staticmethod
def _validate_result(result: _BuildResult, window: Window[Event]) -> None: def _validate_result(result: _BuildResult, window: Window[Event]) -> None:
@@ -192,25 +194,20 @@ class StructureBuilder:
), ),
] ]
debug_dir = None debug_dir = debug_path("StructureBuilder", StructureBuilder.DEBUG_ID)
if os.path.isdir("debug"): if debug_dir:
debug_dir = f"debug/StructureBuilder/{StructureBuilder.DEBUG_ID}"
StructureBuilder.DEBUG_ID += 1 StructureBuilder.DEBUG_ID += 1
os.makedirs(debug_dir, exist_ok=True) (debug_dir / "request.txt").write_text(messages[1].content, encoding="utf-8")
with open(f"{debug_dir}/request.txt", "w", encoding="utf-8") as f:
f.write(messages[1].content)
retries_left = self.MAX_RETRIES retries_left = self.MAX_RETRIES
while retries_left > 0: while retries_left > 0:
retries_left -= 1 retries_left -= 1
response = self._agent.completion(messages=messages, response_format=_BuildResult) response = self._agent.completion(messages=messages, response_format=_BuildResult)
if debug_dir: if debug_dir:
with open( (debug_dir / f"{retries_left}-retries-left.txt").write_text(
f"{debug_dir}/{retries_left}-retries-left.txt", response,
"w",
encoding="utf-8", encoding="utf-8",
) as f: )
f.write(response)
try: try:
result = _BuildResult.model_validate_json(response) result = _BuildResult.model_validate_json(response)

View File

@@ -1,10 +1,10 @@
import json import json
import os
import traceback import traceback
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from agent import Agent, AgentMessage from agent import Agent, AgentMessage
from paths import debug_path, prompt_path
from structure_builder import ImageElement, Structure, StructureElement from structure_builder import ImageElement, Structure, StructureElement
from windowizer import Window, Windowizer from windowizer import Window, Windowizer
@@ -39,8 +39,10 @@ class StructureRefiner:
def __init__(self, agent: Agent, windowizer: Windowizer[StructureElement]) -> None: def __init__(self, agent: Agent, windowizer: Windowizer[StructureElement]) -> None:
self._agent = agent self._agent = agent
self._windowizer = windowizer self._windowizer = windowizer
with open("prompts/structure_refiner.md", "r", encoding="utf-8") as f: self._system_prompt = AgentMessage(
self._system_prompt = AgentMessage(content=f.read(), role="system") content=prompt_path("structure_refiner.md").read_text(encoding="utf-8"),
role="system",
)
@staticmethod @staticmethod
def _validate_result( def _validate_result(
@@ -69,13 +71,10 @@ class StructureRefiner:
), ),
] ]
debug_dir = None debug_dir = debug_path("StructureRefiner", StructureRefiner.DEBUG_ID)
if os.path.isdir("debug"): if debug_dir:
debug_dir = f"debug/StructureRefiner/{StructureRefiner.DEBUG_ID}"
StructureRefiner.DEBUG_ID += 1 StructureRefiner.DEBUG_ID += 1
os.makedirs(debug_dir, exist_ok=True) (debug_dir / "request.txt").write_text(messages[1].content, encoding="utf-8")
with open(f"{debug_dir}/request.txt", "w", encoding="utf-8") as f:
f.write(messages[1].content)
retries_left = self.MAX_RETRIES retries_left = self.MAX_RETRIES
while retries_left > 0: while retries_left > 0:
@@ -85,12 +84,10 @@ class StructureRefiner:
response_format=_RefineResult, response_format=_RefineResult,
) )
if debug_dir: if debug_dir:
with open( (debug_dir / f"{retries_left}-retries-left.txt").write_text(
f"{debug_dir}/{retries_left}-retries-left.txt", response,
"w",
encoding="utf-8", encoding="utf-8",
) as f: )
f.write(response)
try: try:
result = _RefineResult.model_validate_json(response) result = _RefineResult.model_validate_json(response)

View File

@@ -1,11 +1,11 @@
import json import json
import os
import traceback import traceback
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from agent import Agent, AgentMessage from agent import Agent, AgentMessage
from paths import debug_path, prompt_path
from utils import Timeline from utils import Timeline
from windowizer import Window, Windowizer from windowizer import Window, Windowizer
@@ -71,8 +71,10 @@ class VideoReferenceBuilder:
) -> None: ) -> None:
self._agent = agent self._agent = agent
self._windowizer = windowizer self._windowizer = windowizer
with open("prompts/video_references.md", "r", encoding="utf-8") as f: self._system_prompt = AgentMessage(
self._system_prompt = AgentMessage(content=f.read(), role="system") content=prompt_path("video_references.md").read_text(encoding="utf-8"),
role="system",
)
@staticmethod @staticmethod
def _validate_result( def _validate_result(
@@ -113,13 +115,10 @@ class VideoReferenceBuilder:
), ),
] ]
debug_dir = None debug_dir = debug_path("VideoReferenceBuilder", self.DEBUG_ID)
if os.path.isdir("debug"): if debug_dir:
debug_dir = f"debug/VideoReferenceBuilder/{self.DEBUG_ID}"
VideoReferenceBuilder.DEBUG_ID += 1 VideoReferenceBuilder.DEBUG_ID += 1
os.makedirs(debug_dir, exist_ok=True) (debug_dir / "request.txt").write_text(messages[1].content, encoding="utf-8")
with open(f"{debug_dir}/request.txt", "w", encoding="utf-8") as f:
f.write(messages[1].content)
retries_left = self.MAX_RETRIES retries_left = self.MAX_RETRIES
while retries_left > 0: while retries_left > 0:
@@ -129,12 +128,10 @@ class VideoReferenceBuilder:
response_format=_WindowResult, response_format=_WindowResult,
) )
if debug_dir: if debug_dir:
with open( (debug_dir / f"{retries_left}-retries-left.txt").write_text(
f"{debug_dir}/{retries_left}-retries-left.txt", response,
"w",
encoding="utf-8", encoding="utf-8",
) as f: )
f.write(response)
try: try:
result = _WindowResult.model_validate_json(response) result = _WindowResult.model_validate_json(response)