Files
2026-linux-sumka/README.md
2026-09-24 18:30:05 +03:00

531 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=whisper-cache,dst=/tmp/sumka-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=whisper-cache,dst=/tmp/sumka-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
```
## Запуск
Контейнер рассчитан на один запрос. Для каждого задания создайте отдельную
директорию, положите в неё один поддерживаемый файл с именем `input.*` и
смонтируйте её в `/work`:
```bash
JOB_DIR=$(mktemp -d)
cp input.mkv "$JOB_DIR/input.mkv"
docker run --rm --name sumka-example --gpus all -e AI_API_KEY \
--mount type=bind,src="$JOB_DIR",dst=/work \
--mount type=volume,src=whisper-cache,dst=/tmp/sumka-cache/whisper \
--tmpfs /tmp:rw,size=8g sumka:nvidia
```
Не запускайте два контейнера с одной рабочей директорией. Повторный запуск с
той же директорией продолжит работу по уже созданным промежуточным файлам.
Кэш Whisper можно безопасно разделять между заданиями.
### Статус и логирование
Приложение дописывает в `JOB_DIR/report.txt` по одной JSON-записи на строку.
Записи появляются при старте и завершении этапа, а во время долгой операции —
каждые 30 секунд. Интервал меняется через `--report-interval`.
```json
{"timestamp":"2026-09-24T12:00:00Z","run_id":"...","status":"running","step":"voice_recognition","step_index":2,"steps_total":10}
{"timestamp":"2026-09-24T12:00:30Z","run_id":"...","status":"heartbeat","elapsed_seconds":30,"step":"voice_recognition","step_index":2,"steps_total":10}
{"timestamp":"2026-09-24T12:10:00Z","run_id":"...","status":"succeeded","elapsed_seconds":600,"outputs":["output.md","output.pdf"]}
```
`status` принимает значения `started`, `running`, `step_completed`,
`heartbeat`, `succeeded`, `failed` или `cancelled`. Файл не перезаписывается:
повторный запуск добавляет записи с новым `run_id`. Смотреть его вручную можно
через `tail -f "$JOB_DIR/report.txt"`.
Matrix-боту рекомендуется запускать `docker run --rm --name <job-id>` через
`subprocess.Popen`/`asyncio.create_subprocess_exec`, параллельно читать новые
строки `report.txt` и разбирать их через `json.loads`. Код возврата контейнера
остаётся окончательным признаком успеха: `0` — успех, ненулевой — ошибка или
отмена. Подробные диагностические логи идут в stdout/stderr контейнера; для
отмены задания можно выполнить `docker stop <job-id>`.
## Архитектура
Предполагается, что приложение будет запускаться сторонним приложением всякий
раз, когда требуется произвести конвертацию медиафайла в конспект (например,
Matrix-ботом, которому отправили видео).
**Сервис не хранит состояние вне рабочей директории.** Если сервис сгенерировал
промежуточный файл, а после этого его принудительно завершили, то при следующем
запуске он использует этот файл, чтобы не повторять дорогую операцию. **Поэтому
для каждого нового запроса нужна новая рабочая директория; старые директории
можно удалять, когда результат больше не нужен.**
Алгоритм работы сервиса следующий:
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: с типографикой, выделенными важными блоками,
подписями к изображениям и нумерацией страниц.