428 lines
24 KiB
Markdown
428 lines
24 KiB
Markdown
# 2026-linux-sumka
|
||
|
||
Сервис для построения полноценного файла коспекта (главным образом Markdown) из
|
||
записи в одном из видов:
|
||
1. **Только звук.** Создаст файл лекции, основываясь только на звуке.
|
||
2. **Видео со звуком.** Создаст файл лекции на основе звука, и дополнит
|
||
информацию, используя кадры из видео.
|
||
|
||
Для запросов к ИИ используется OpenAI-compatible API, поэтому использовать
|
||
можно разные модели. Разработка велась в связке с [GigaChat](https://giga.chat)
|
||
и с локальной `ollama` через посредство `OpenWebUI`. В репозитории хранятся
|
||
промтпы для абстрактной языковой модели, и, вероятно, их лучше поменять в проде.
|
||
*К тому же, промпты написаны на русском языке, а иностранные модели лучше
|
||
работают со служебными промптами на английском языке.*
|
||
|
||
## Установка (Docker)
|
||
|
||
TODO
|
||
|
||
## Установка (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.txt
|
||
|
||
# Запуск вот так (см. далее)
|
||
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`, используя который
|
||
будет построена структура итогового документа.
|
||
- TODO, пока что это не реализовано
|
||
- Итоговый файл `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
|
||
}
|
||
]
|
||
}
|
||
```
|
||
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/` (относительно директории
|
||
промежуточных данных)
|