# 2026-linux-sumka Сервис для построения полноценного конспекта в Markdown и PDF из записи в одном из видов: 1. **Только звук.** Создаст файл лекции, основываясь только на звуке. 2. **Видео со звуком.** Создаст файл лекции на основе звука, и дополнит информацию, используя кадры из видео. Для запросов к ИИ используется OpenAI-compatible API, поэтому использовать можно разные модели. Разработка велась в связке с [GigaChat](https://giga.chat) и с локальной `ollama` через посредство `OpenWebUI`. В репозитории хранятся промтпы для абстрактной языковой модели, и, вероятно, их лучше поменять в проде. *К тому же, промпты написаны на русском языке, а иностранные модели лучше работают со служебными промптами на английском языке.* ## Установка (Docker) Образы собираются из одного 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 3.13.1`. ```bash # Клонировать репозиторий и перейти в него git clone https://git.tyukalov.su/nikita/2026-linux-sumka cd 2026-linux-sumka # Создать venv (либо ваш способ, если шарите) python3 -m venv .venv # Активировать виртуальное окружение . .venv/bin/activate # Установить зависимости под свою видеокарту pip install -r requirements-nvidia.txt # либо requirements-amd.txt # Также должны быть установлены ffmpeg и Chromium/Google Chrome # Запуск вот так (см. далее) python main.py ``` ## Запуск TODO ## Архитектура Предполагается, что приложение будет запускаться сторонним приложением всякий раз, когда требуется произвести конвертацию медиафайла в конспект (например, Telegram ботом, которому отправили видео). **Сервис не сохраняет никаких данных между перезапусками**. Всё, что сохраняется - это промежуточные результаты. Например, если сервис сгенерировал файл `asr_events.json`, а после этого его принудительно завершили, то при следующем запуске он будет использовать этот файл, чтобы не повторять дорогие операции. **Поэтому при автоматизации рекомендуется на каждый новый запрос создавать новую промежуточную директорию, а старые директории удалять, когда они становятся не нужны.** Алгоритм работы сервиса следующий: 1. **Проверить, является входной файл звуком или видео со звуком** - Звуковой файл - сразу начать работу - Видеофайл со звуком - разделить файл на звук и видео, используя `ffmpeg`, и перейти к обработке звука - Видеофайл без звука - не поддерживается - Ни одна из категорий - ошибка 2. **Выполнить распознание речи** - Используется `Whisper` - Базовые настройки распознания должны быть доступны для изменения через CLI, релевантные аргументы должны начинаться с `asr-` - В результате распознания речи в промежуточной директории должен появиться файл `asr_raw.json`. В корне обязательно должен быть ключ, где указано название движка распознания (поддерживается только `whisper`). Каждый сегмент обязан содержать ключи `start` (время начала сегмента), `end` (время конца сегмента), `text` (текст сегмента). Затем должен идти ключ `engine`, где указываются значения, специфичные для движка (они будут использованы только на следующем шаге, а затем будут удалены). ```json { "engine": "whisper", "segments": [ { "start": 0.0, "end": 3.0, "text": "текст речи с 0.0 по 3.0", "engine": { "temperature": 0.4, "something": -5.6 } }, { "start": 3.0, "end": 7.0, "text": "текст речи с 3.0 по 7.0", "engine": { "temperature": 0.1, "something": 542 } } ] } ``` 3. **Выполнить фильтрацию полученных сегментов** - Смысл этого шага - отсеять сегменты которые можно по имеющимся данным отнести к лишним. - Этот шаг формально существует, но на самом деле в текущей реализации на нем просто выбрасываются лишние данные из `asr_raw.json` и сегментам присваиваются идентификаторы. - В итоге должен получится файл `asr.json` в таком формате: ```json { "segments": [ { "id": 0, "start": 0.0, "end": 3.0, "text": "текст речи с 0.0 по 3.0" }, { "id": 1, "start": 3.0, "end": 7.0, "text": "текст речи с 3.0 по 7.0" } ] } ``` 4. **Построить список событий** - Дальнейшие шаги требуют представления звука в виде списка событий - Для построения списка событий используется ИИ - Расшифровка скармливается ИИ постепенно, запрос состоит из двух сообщений, которые модели предлагается продолжить. **Первое сообщение** - системный промпт, где для модели разъясняется, что от неё требуется, а также описывается формат данных. **Второе сообщение** - сам запрос к модели. Он имеет следующий формат: ```json { "past": [ { "id": 40, "start": 58.0, "end": 60.0, "text": "фаышв" } ], "present": [ { "id": 41, "start": 60.0, "end": 64.0, "text": "ащфзо" }, { "id": 42, "start": 64.0, "end": 70.0, "text": "зпщыфвол" }, ], "future": [ { "id": 43, "start": 70.0, "end": 73.0, "text": "полытаы" } ], "context": {} } ``` - `present` - это события, которые попадают в скользящее окно нефиксированного размера. Размер окна в текущем запросе определяется переменными `asr_mmax_size` и `asr_mmin_segments`. Окно должно содержать как можно больше сегментов, но сумма длин всех `text`, входящих в окно, не должна превышать `asr_mmax_size`; в то же время количество сегментов в окне не должно быть меньше, чем `asr_mmin_segments`, и это требование - самое приоритетное. - `past` и `future` - это события, которые идут до и после текущего окна. Их размеры определяются аналогично `present`, но используются переменные `asr_cmax_size` и `asr_cmin_segments`. - `context` - это контекст, формат которого модель определяет сама. Его значение сохраняется между запросами к модели. В системном промпте рекомендуется строго задать формат данных. - Задача модели после получения промтпа - объединить сегменты распознания речи в так называемые *события*, которые будут использованы в дальнейшнем. Модели на этом этапе разрешается удалять сегменты, логически объединять их содержимое, но строго запрещается изменять смысл, формулировки. Также модели разрешается исправлять пунктуацию или очевидные ошибки распознания. - Модель должна отвечать в следующем виде: ```json { "preevents": [ { "ids": [0, 1], "text": "объединенный текст для ID 0 и 1" }, { "ids": [2, 3, 4, 5], "text": "объединенный текст для ID 2, 3, 4 и 5" }, { "ids": [8], "text": "заметьте, модель выбросила сегменты 6 и 7, а этот сегмент сохранила без объединения с другими" } ], "context": { "for_future_call": "Сегменты 6, 7 выброшены, потому что..." } } ``` - `preevents` - список недособытий. Время намеренно не должно быть использовано, так как оно должно вычисляться программно на основе значения `ids` - `context` - данные, которые модель хочет передать следующей итерации; формат определяется моделью или промптом - **Важно, что модели следует явно запретить объединять сегменты, если объединение затрагивает `future`. В промпте следует явно указать, что в такой ситуации (когда нужно объединить что-то между `present` и `future`), модель должна сделать для себя пометку в контексте, и при следующей итерации объединить сегменты из `past` и `present`. Если не ограничить модель в этом, то начнется сущий кошмар.** - Нужно после каждой итерации проверять, что указанные нейросетью ID действительно существуют, и что они входят в `past` или `present`. В следующей итерации модели больше нельзя передавать данные, которые были объединены, либо которые хоть раз попадали в `past`. - По мере получения объектов `preevent` нужно составлять файл `audio_events.json`: ```json { "events": [ { "id": 0, "type": "voice", "timestamp": 0.0, "duration": 7.0, "text": "Здесь уже должны быть осмысленные законченные фразы", "payload": null }, { "id": 1, "type": "voice", "timestamp": 7.0, "duration": 3.0, "text": "Обратите внимание, нет связи между ID здесь и раньше", "payload": null } ] } ``` - `id` присваивается независимо от того, что наблюдалось ранее. Здесь `id` - это просто число, увеличивающееся с ростом времени. - `type` на этом шаге может быть только `voice`. В этот общий список могут добавляться события других типов на более поздних шагах. - `timestamp` - начало события - `duration` - длительность события - `text` - текст - `payload` - для `voice` может быть только `null` 5. **Построить список ссылок на видео** - Если видео нет, то шаг пропускается. Вместо него файл `audio_events.json` копируется в `events.json` и происходит переход на 7 шаг. - Используя оконный метод, описанный выше, модели отправляются сформированные события, а она формирует список ссылок на видео, которые нужно разрешить. Окна передаются по такому же алгоритму, как уже было описано выше для шага №4, но данные передаются вот такими объектами: ```json { "id": 4, "timestamp": 3.0, "duration": 5.0, "text": "Осмысленный текст, полученный на 4-ом шаге." } ``` То есть для каждого объекта, входящего в `past`, `present` или `future`, передаются только данные, описанные выше. Промпт должен налагать дополнительные ограничения. Во-первых, непосредственно обрабатывать можно только соыбтия из списка `present`, а события, перечисляемые в `past` и `future` используются только для контекста - работать с ними нельзя. Ключ `context` работать точно так же, как на 4-ом шаге - модель может использовать его по своему усмотрению, но формат рекомендуется жестко сформулировать в промпте. - При построении списка ссылок модель модель возвращать данные в следующем формате: ```json { "unresolved": [ { "ids": [0, 1, 2], "type": "vis", "text": "Для типа `vis`: описание того что должно быть на видео в этот момент" }, { "ids": [19, 20, 21, 22, 23], "type": "ocr", "text": "Для типа `ocr`: текст, который нужно распознать с экрана (здесь должно быть названо, например, какой термин)" }, { "ids": [0, 1, 2], "type": "vis", "text": "Для типа `vis`: описание того что должно быть на видео в этот момент" } ], "context": { "for_future_call": "Сегменты 6, 7 выброшены, потому что..." } } ``` - `unresolved` - список событий, которые нужно будет разрешить на следующем шаге. Каждый объект содержит в точности три ключа: `ids` (идентификаторы событий, на протяжении которых нужно искать то, что описано), `type` (тип того, что нужно найти, см. далее), `text` (описание того, что нужно найти). Тип может быть либо `vis` (нужно определить, соответствует ли кадр из видео описанию `text`, и, если соответствует, то вставить его в итоговый файл лекции), либо `ocr` (нужно распознать текст, наводка на который даётся в `text`). - `context` - контекст, который должен быть передан модели при следующем вызове. - После выполнения этого шага модель должна сформировать файл `unresolved.json` - Модель и адрес API можно задать через `--video-ref-ai-model`, `--video-ref-ai-base-url` и `--video-ref-ai-api-key`. По умолчанию используется `google/gemini-3.1-flash-lite`. 6. **Разрешить ссылки на видео** - После этого шага должен быть получен файл `events.json`, используя который будет построена структура итогового документа. - Для каждого объекта из `unresolved.json` программа получает кадры через `ffmpeg`. По умолчанию кадры берутся с интервалом в одну секунду, но не более 9 кадров на одну ссылку. - Кадры проверяются мультимодальной моделью от общего к частному: сначала начало, середина и конец интервала, затем середины оставшихся промежутков. После первого подходящего кадра поиск прекращается. - Для `ocr` кадр сначала выбирается в режиме `low`, затем `ffmpeg` локально вырезает нужную область и только этот фрагмент отправляется в `high` для точного распознавания. Если фрагмент не читается, поиск продолжается. - Для `vis` выбранный кадр сохраняется в директории `images/`. Для `ocr` в событии сохраняется распознанный текст. - Если подходящий кадр не найден, ссылка сохраняется в корневом списке `unresolved` файла `events.json` с причиной `not_found` или `no_frames`. Её `ids` ссылаются на голосовые события из итогового списка `events`. - Настройки модели задаются через `--resolver-ai-model`, `--resolver-ai-base-url` и `--resolver-ai-api-key`. Период и максимальное число кадров задаются через `--resolver-frame-interval` и `--resolver-max-frames`. По умолчанию используется `google/gemini-3.5-flash`. - Итоговый файл `events.json`, должен иметь следующую структуру. ID никак не связаны с предудущими шагами. ```json { "events": [ { "id": 0, "type": "voice", "timestamp": 0.0, "duration": 7.0, "text": "Здесь уже должны быть осмысленные законченные фразы", "payload": null }, { "id": 1, "type": "vis", "timestamp": 4.0, "duration": 0.0, "text": "Описание содержимого изображения, путь к которому хранится в `payload`", "payload": "runtime/23865928/images/4123.jpg" }, { "id": 2, "type": "ocr", "timestamp": 6.0, "duration": 0.0, "text": "Описание текста, который должен был быть распознан", "payload": "Текст, распознанный с кадра из видео, без перефразирований" }, { "id": 3, "type": "voice", "timestamp": 7.0, "duration": 3.0, "text": "Для типа `voice` ключ `payload` всегда `null`", "payload": null } ], "unresolved": [ { "ids": [0, 3], "type": "ocr", "text": "Распознать формулу со слайда", "reason": "not_found" } ] } ``` 7. **Построить структуру итогового документа** - После этого шага должен быть получен файл `structure.json` следующего вида: ```json { "elements": [ { "type": "heading", "level": 2, "text": "Заголовок второго уровня" }, { "type": "paragraph", "text": "Текст параграфа." }, { "type": "heading", "level": 3, "text": "Заголовок третьего уровня" }, { "type": "unordered", "items": [ "Элемент неупорядоченного списка", "Ещё один элемент неупорядоченного списка" ] }, { "type": "ordered", "items": [ "1-ый элемент упорядоченного списка", "2-ой элемент упорядоченного списка" ] }, { "type": "definition", "term": "Кошка", "text": "Домашнее животное, которое мяукает и царапается" }, { "type": "important", "text": "Важное замечание, которое нужно выделить на уровне структуры документа" }, { "type": "image", "event_id": 54 } ] } ``` - Для получения файла `structure.json` модели передаются соыбытия из `events.json`, используя метод окна (описан ранее). 8. **Выполнить финальную редактуру структуры** - Черновая структура из `structure.json` передаётся модели оконным методом. - Модель исправляет язык и иерархию, согласовывает терминологию и удаляет явные повторы, не добавляя новые сведения. - Результат сохраняется в `structure_refined.json` в том же формате. 9. **Собрать финальный Markdown файл** - Для сборки Markdown файла используются элементы, полученные на предыдщем шаге, и хранимые в файле `structure_refined.json` - Сборка выполняется локально, без использования ИИ. Ссылки на изображения разрешаются по `image.event_id` через `events.json`. - В итоге создаётся файл `output.md`, который может включать в себя ссылки на изображения из директории `images/` (относительно директории промежуточных данных) 10. **Собрать PDF из Markdown** - Сборка выполняется локально, без ИИ, и создаёт файл `output.pdf`. - LaTeX-формулы преобразуются в MathML, а изображения встраиваются из относительных путей Markdown. Пакет `latex2mathml` входит в `requirements.txt`; для печати нужен установленный Chromium или Google Chrome. - PDF оформляется для A4: с типографикой, выделенными важными блоками, подписями к изображениям и нумерацией страниц.