Initial commit
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
runtime/
|
||||||
|
|
||||||
|
*.json
|
||||||
|
|
||||||
|
*.mkv
|
||||||
|
*.mp4
|
||||||
|
*.avi
|
||||||
|
|
||||||
|
*.wav
|
||||||
|
*.ogg
|
||||||
|
*.mp3
|
||||||
|
*.m4a
|
||||||
257
README.md
Normal file
257
README.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# 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` (текст сегмента). Затем должен идти ключ
|
||||||
|
`custom`, где указываются значения, специфичные для движка (они будут
|
||||||
|
использованы только на следующем шаге, а затем будут удалены).
|
||||||
|
```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` и происходит переход на следующий шаг.
|
||||||
|
-
|
||||||
33
agent.py
Normal file
33
agent.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import httpx2
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from utils import AgentMessage
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
"""Perform operations with timeline events using OpenAI-compatible API"""
|
||||||
|
def __init__(self, *, model: str, base_url: str | None, api_key: str, **kwargs) -> None:
|
||||||
|
self._client = OpenAI(
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
http_client=httpx2.Client(verify=False),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
|
||||||
|
|
||||||
|
def completion(self, messages: list[AgentMessage], **kwargs) -> str:
|
||||||
|
"""Generate a completion for specified messages."""
|
||||||
|
messages_raw = []
|
||||||
|
for m in messages:
|
||||||
|
messages_raw.append(
|
||||||
|
{
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = self._client.chat.completions.create(
|
||||||
|
model=self._model,
|
||||||
|
messages=messages_raw,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
return response.choices[0].message.content # type: ignore
|
||||||
358
main.py
Normal file
358
main.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"""Application entry point"""
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from transcriber import Transcriber
|
||||||
|
from agent import Agent
|
||||||
|
from utils import TimelineEvent, AnalysisWindow, AgentMessage, TimelineProcessingResult
|
||||||
|
|
||||||
|
def check_cuda() -> None:
|
||||||
|
"""Checks if CUDA is available."""
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError(
|
||||||
|
"CUDA is unavaiable! Refusing to start, it's pointless."
|
||||||
|
)
|
||||||
|
|
||||||
|
def setup_arguments() -> argparse.Namespace:
|
||||||
|
"""Parses CLI arguments and returns namespace. It may terminate the
|
||||||
|
application on invalid arguments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- argparse namespace
|
||||||
|
"""
|
||||||
|
voice_models = Transcriber.get_models_list()
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="sumka",
|
||||||
|
description="Summarizes large video/audio files into convenient format",
|
||||||
|
)
|
||||||
|
parser.add_argument("filename", type=str)
|
||||||
|
parser.add_argument(
|
||||||
|
"--voice-model",
|
||||||
|
choices=voice_models,
|
||||||
|
default="turbo" if "turbo" in voice_models else voice_models[-1]
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--voice-language",
|
||||||
|
choices=["ru", "en"],
|
||||||
|
default="ru"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--window-payload-size",
|
||||||
|
type=int,
|
||||||
|
default=1024
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--window-context-size",
|
||||||
|
type=int,
|
||||||
|
default=128
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ai-model",
|
||||||
|
type=str,
|
||||||
|
default="GigaChat-3-Pro"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ai-base-url",
|
||||||
|
type=str,
|
||||||
|
default="https://api.giga.chat/v1"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ai-api-key",
|
||||||
|
type=str,
|
||||||
|
default="gdsfgds"
|
||||||
|
)
|
||||||
|
parser.add_argument("-v", action='store_true')
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
#
|
||||||
|
# GENERIC
|
||||||
|
#
|
||||||
|
def make_windows(events: list[TimelineEvent], context_symbols: int, payload_symbols: int) -> list[AnalysisWindow]:
|
||||||
|
result: list[AnalysisWindow] = []
|
||||||
|
window_start = 0
|
||||||
|
while window_start < len(events):
|
||||||
|
window = AnalysisWindow([], [], [])
|
||||||
|
result.append(window)
|
||||||
|
# build the window itself
|
||||||
|
window_end = window_start + 1
|
||||||
|
total_size = 0
|
||||||
|
for event in events[window_start:]:
|
||||||
|
window.modifiable.append(event)
|
||||||
|
total_size += len(event.payload)
|
||||||
|
if total_size >= payload_symbols:
|
||||||
|
break
|
||||||
|
window_end += 1
|
||||||
|
# build readonly events before the window
|
||||||
|
total_size = 0
|
||||||
|
for event in reversed(events[:window_start]):
|
||||||
|
window.before_readonly.insert(0, event)
|
||||||
|
total_size += len(event.payload)
|
||||||
|
if total_size >= context_symbols:
|
||||||
|
break
|
||||||
|
# build readonly event after the window
|
||||||
|
total_size = 0
|
||||||
|
for event in events[window_end:]:
|
||||||
|
window.after_readonly.append(event)
|
||||||
|
total_size += len(event.payload)
|
||||||
|
if total_size >= context_symbols:
|
||||||
|
break
|
||||||
|
# prepare for the next window
|
||||||
|
window_start = window_end
|
||||||
|
return [r for r in result if len(r.modifiable)]
|
||||||
|
|
||||||
|
def execute_timeline_request(res: TimelineProcessingResult, request: dict):
|
||||||
|
req = request["req"]
|
||||||
|
if req == "modify":
|
||||||
|
id = request["id"]
|
||||||
|
payload = request["payload"]
|
||||||
|
valid = [e for e in res.events if e.id == id]
|
||||||
|
if not len(valid):
|
||||||
|
raise RuntimeError(f"AI tries to modify nonexistent event with ID `{id}`")
|
||||||
|
valid[0].payload = payload
|
||||||
|
logging.info(f"Updated `{id}`'s payload to `{payload}`")
|
||||||
|
else:
|
||||||
|
print(json.dumps(request, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
def build_document_structure(timeline: TimelineProcessingResult, args: argparse.Namespace):
|
||||||
|
results_path = "document_structure.json"
|
||||||
|
result = []
|
||||||
|
# create the agent
|
||||||
|
agent = Agent(
|
||||||
|
model=args.ai_model,
|
||||||
|
base_url=args.ai_base_url,
|
||||||
|
api_key=args.ai_api_key
|
||||||
|
)
|
||||||
|
# create the system prompt
|
||||||
|
with open("prompts/build_structure.md", "r") as f:
|
||||||
|
system_prompt = AgentMessage(
|
||||||
|
f.read(),
|
||||||
|
"system"
|
||||||
|
)
|
||||||
|
windows = make_windows(timeline.events, args.window_context_size * 2, args.window_payload_size * 2)
|
||||||
|
# context
|
||||||
|
context = {}
|
||||||
|
# process each window
|
||||||
|
for window_id, window in enumerate(windows):
|
||||||
|
# prepare request body
|
||||||
|
req = {
|
||||||
|
"before_readonly": [e.get_ai_dict() for e in window.before_readonly],
|
||||||
|
"content": [e.get_ai_dict() for e in window.modifiable],
|
||||||
|
"after_readonly": [e.get_ai_dict() for e in window.after_readonly],
|
||||||
|
"document_context": context
|
||||||
|
}
|
||||||
|
msg = AgentMessage(
|
||||||
|
content=json.dumps(req, indent=2, ensure_ascii=False),
|
||||||
|
role="user"
|
||||||
|
)
|
||||||
|
# process the window
|
||||||
|
success = False
|
||||||
|
attempt = 1
|
||||||
|
while not success:
|
||||||
|
logging.info(f"Processing a window #{window_id + 1} (attempt #{attempt})...")
|
||||||
|
# try to parse as JSON
|
||||||
|
try:
|
||||||
|
# call the AI
|
||||||
|
response = json.loads(
|
||||||
|
agent.completion(messages=[system_prompt, msg]))
|
||||||
|
# process each request separately
|
||||||
|
for block in response["blocks"]:
|
||||||
|
result.append(block)
|
||||||
|
context = response["new_document_context"]
|
||||||
|
success = True
|
||||||
|
except:
|
||||||
|
attempt += 1
|
||||||
|
logging.error("Failed, retrying")
|
||||||
|
logging.debug(traceback.format_exc())
|
||||||
|
with open(results_path, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
result,
|
||||||
|
f,
|
||||||
|
indent=4,
|
||||||
|
ensure_ascii=False
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
#
|
||||||
|
# AUDIO
|
||||||
|
#
|
||||||
|
def transcribe_audio(audio_path: str,
|
||||||
|
args: argparse.Namespace) -> list[TimelineEvent]:
|
||||||
|
"""Transcribes audio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- audio_path - path to the audio file
|
||||||
|
- result_path - path to the resulting JSON file
|
||||||
|
- args - arguments as returned by argsparse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- timeline events produced by ASR
|
||||||
|
"""
|
||||||
|
# check if file exists and just load it if it does
|
||||||
|
result_path = "asr_events.json"
|
||||||
|
if os.path.isfile(result_path):
|
||||||
|
try:
|
||||||
|
logging.info(
|
||||||
|
f"Trying to load transcription data from {result_path}"
|
||||||
|
)
|
||||||
|
with open(result_path, "rb") as f:
|
||||||
|
j = [TimelineEvent(**e) for e in json.load(f)]
|
||||||
|
logging.info(f"Loaded transcription data from {result_path}")
|
||||||
|
return j
|
||||||
|
except:
|
||||||
|
logging.debug(
|
||||||
|
f"Could not load transcription data from {result_path}"
|
||||||
|
)
|
||||||
|
# actually transcribe
|
||||||
|
logging.info(f"Transcribing {audio_path}...")
|
||||||
|
logging.debug(f"Creating transcriber (using model `{args.voice_model}`)")
|
||||||
|
t = Transcriber(args.voice_model)
|
||||||
|
logging.debug(f"Creating the transcription...")
|
||||||
|
events = t.transcribe(
|
||||||
|
audio_path,
|
||||||
|
language=args.voice_language
|
||||||
|
)
|
||||||
|
logging.debug(f"Saving to {result_path}")
|
||||||
|
with open(result_path, "w") as f:
|
||||||
|
f.write(json.dumps([asdict(e) for e in events], indent=4, ensure_ascii=False))
|
||||||
|
logging.info(f"Done transcribing, timeline events produced: {len(events)}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
def prepare_audio_windows(events: list[TimelineEvent],
|
||||||
|
args: argparse.Namespace) -> list[AnalysisWindow]:
|
||||||
|
"""Prepare list of windows which should be processed by LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- events - return value of `transcribe_audio`
|
||||||
|
- args - arguments as returned by argsparse
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- list of windows for LLM
|
||||||
|
"""
|
||||||
|
return make_windows(
|
||||||
|
events,
|
||||||
|
args.window_context_size,
|
||||||
|
args.window_payload_size
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_audio_windows(windows: list[AnalysisWindow], args: argparse.Namespace) -> TimelineProcessingResult:
|
||||||
|
# check if already processed
|
||||||
|
results_path = "asr_proc_events.json"
|
||||||
|
if os.path.isfile(results_path):
|
||||||
|
try:
|
||||||
|
logging.info(f"Loading processing result from {results_path}")
|
||||||
|
with open(results_path, "rb") as f:
|
||||||
|
j = json.load(f)
|
||||||
|
result = TimelineProcessingResult(
|
||||||
|
events=[TimelineEvent(**e) for e in j["events"]],
|
||||||
|
desired_events=[TimelineEvent(**e) for e in j["desired_events"]]
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except:
|
||||||
|
logging.error(traceback.print_exc())
|
||||||
|
# create the agent
|
||||||
|
agent = Agent(
|
||||||
|
model=args.ai_model,
|
||||||
|
base_url=args.ai_base_url,
|
||||||
|
api_key=args.ai_api_key
|
||||||
|
)
|
||||||
|
# create the system prompt
|
||||||
|
with open("prompts/audio_window.md", "r") as f:
|
||||||
|
system_prompt = AgentMessage(
|
||||||
|
f.read(),
|
||||||
|
"system"
|
||||||
|
)
|
||||||
|
# prepare the processing result
|
||||||
|
all_events = [w.modifiable for w in windows]
|
||||||
|
processing_result = TimelineProcessingResult(
|
||||||
|
events=[item for sublist in all_events for item in sublist],
|
||||||
|
desired_events=[]
|
||||||
|
)
|
||||||
|
# context for AI to remember previous iterations
|
||||||
|
previous_self_context = {
|
||||||
|
"_comment": "Use this object as you data storage for next iterations"
|
||||||
|
}
|
||||||
|
# process each window
|
||||||
|
for window_id, window in enumerate(windows):
|
||||||
|
# prepare request body
|
||||||
|
req = {
|
||||||
|
"before_readonly": [e.get_ai_dict() for e in window.before_readonly],
|
||||||
|
"modifiable": [e.get_ai_dict() for e in window.modifiable],
|
||||||
|
"after_readonly": [e.get_ai_dict() for e in window.after_readonly],
|
||||||
|
"ai_custom_context": previous_self_context
|
||||||
|
}
|
||||||
|
msg = AgentMessage(
|
||||||
|
content=json.dumps(req, indent=2, ensure_ascii=False),
|
||||||
|
role="user"
|
||||||
|
)
|
||||||
|
# process the window
|
||||||
|
success = False
|
||||||
|
attempt = 1
|
||||||
|
while not success:
|
||||||
|
logging.info(f"Processing a window #{window_id + 1} (attempt #{attempt})...")
|
||||||
|
# try to parse as JSON
|
||||||
|
try:
|
||||||
|
# call the AI
|
||||||
|
response = json.loads(
|
||||||
|
agent.completion(messages=[system_prompt, msg]))
|
||||||
|
# process each request separately
|
||||||
|
requests = response["requests"]
|
||||||
|
for r in requests:
|
||||||
|
execute_timeline_request(processing_result, r)
|
||||||
|
previous_self_context = response["new_context"]
|
||||||
|
success = True
|
||||||
|
except:
|
||||||
|
attempt += 1
|
||||||
|
logging.error("Failed, retrying")
|
||||||
|
logging.debug(traceback.format_exc())
|
||||||
|
with open(results_path, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"events": [asdict(e) for e in processing_result.events],
|
||||||
|
"desired_events": [asdict(e) for e in processing_result.desired_events],
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
indent=4,
|
||||||
|
ensure_ascii=False
|
||||||
|
)
|
||||||
|
return processing_result
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Application entry point"""
|
||||||
|
check_cuda()
|
||||||
|
args = setup_arguments()
|
||||||
|
# setup the logger
|
||||||
|
logging.basicConfig(level=logging.DEBUG if args.v else logging.INFO)
|
||||||
|
# transcribe
|
||||||
|
audio_events = transcribe_audio(
|
||||||
|
args.filename,
|
||||||
|
args
|
||||||
|
)
|
||||||
|
# prepare audio windows
|
||||||
|
audio_windows = prepare_audio_windows(
|
||||||
|
audio_events,
|
||||||
|
args
|
||||||
|
)
|
||||||
|
# process audio window
|
||||||
|
processing_result = process_audio_windows(
|
||||||
|
audio_windows,
|
||||||
|
args
|
||||||
|
)
|
||||||
|
# build the document
|
||||||
|
build_document_structure(processing_result, args)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
364
prompts/audio_window.md
Normal file
364
prompts/audio_window.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
Ты обрабатываешь временную шкалу записи лекции и подготавливаешь её к дальнейшему созданию документа.
|
||||||
|
|
||||||
|
На вход ты получаешь JSON:
|
||||||
|
|
||||||
|
{
|
||||||
|
"before_readonly": [...],
|
||||||
|
"modifiable": [...],
|
||||||
|
"after_readonly": [...],
|
||||||
|
"ai_custom_context": {...}
|
||||||
|
}
|
||||||
|
|
||||||
|
События расположены в хронологическом порядке.
|
||||||
|
|
||||||
|
ТВОЯ ЗАДАЧА
|
||||||
|
|
||||||
|
1. Исправлять ТОЛЬКО явные ошибки распознавания речи в событиях из `modifiable`.
|
||||||
|
2. Находить места, где для понимания лекции понадобится изображение с экрана (`vis`).
|
||||||
|
3. Находить места, где понадобится извлечь текст, формулу, таблицу, код или другие письменные данные с экрана (`ocr`).
|
||||||
|
4. Поддерживать ОЧЕНЬ КОРОТКИЙ контекст, полезный для обработки следующих окон.
|
||||||
|
|
||||||
|
Ты НЕ создаёшь конспект.
|
||||||
|
Ты НЕ переписываешь речь литературно.
|
||||||
|
Ты НЕ улучшаешь стиль преподавателя.
|
||||||
|
Ты НЕ обязан что-либо изменить в каждом событии.
|
||||||
|
Если изменение не нужно — ничего не делай.
|
||||||
|
|
||||||
|
=== СОБЫТИЯ ===
|
||||||
|
|
||||||
|
Обычное ASR-событие:
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "asr_123",
|
||||||
|
"timestamp": 100.0,
|
||||||
|
"duration": 4.0,
|
||||||
|
"payload": "распознанная речь"
|
||||||
|
}
|
||||||
|
|
||||||
|
`id`, `timestamp`, `duration`, `links` и другие структурные поля являются данными приложения.
|
||||||
|
|
||||||
|
Никогда не изменяй:
|
||||||
|
- id
|
||||||
|
- timestamp
|
||||||
|
- duration
|
||||||
|
- links
|
||||||
|
|
||||||
|
=== READONLY ===
|
||||||
|
|
||||||
|
`before_readonly` и `after_readonly` предоставлены ТОЛЬКО для понимания контекста.
|
||||||
|
|
||||||
|
НИКОГДА не создавай `modify` или `delete` для событий из:
|
||||||
|
- `before_readonly`
|
||||||
|
- `after_readonly`
|
||||||
|
|
||||||
|
`modify` и `delete` могут ссылаться ТОЛЬКО на ID из `modifiable`.
|
||||||
|
|
||||||
|
=== MODIFY ===
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "modify",
|
||||||
|
"id": "asr_123",
|
||||||
|
"payload": "исправленный текст"
|
||||||
|
}
|
||||||
|
|
||||||
|
Создавай `modify` ТОЛЬКО при содержательном изменении текста.
|
||||||
|
|
||||||
|
ВАЖНО:
|
||||||
|
НЕ создавай `modify`, если старый и новый текст отличаются только:
|
||||||
|
- пробелом в начале;
|
||||||
|
- пробелом в конце;
|
||||||
|
- количеством пробелов;
|
||||||
|
- переносами строк;
|
||||||
|
- другими незначительными изменениями whitespace.
|
||||||
|
|
||||||
|
Например:
|
||||||
|
|
||||||
|
БЫЛО:
|
||||||
|
" для изучения сложных систем."
|
||||||
|
|
||||||
|
СТАЛО:
|
||||||
|
"для изучения сложных систем."
|
||||||
|
|
||||||
|
Это НЕ является причиной для `modify`.
|
||||||
|
|
||||||
|
Не создавай такой запрос.
|
||||||
|
|
||||||
|
Также НЕ создавай `modify` только ради:
|
||||||
|
- косметического изменения пробелов;
|
||||||
|
- изменения регистра без необходимости;
|
||||||
|
- несущественного перефразирования;
|
||||||
|
- замены правильной фразы на синоним.
|
||||||
|
|
||||||
|
Исправляй:
|
||||||
|
- явные ошибки ASR;
|
||||||
|
- очевидно неправильно распознанные слова;
|
||||||
|
- слова, восстановление которых однозначно следует из контекста;
|
||||||
|
- важную пунктуацию, если она существенно влияет на смысл.
|
||||||
|
|
||||||
|
Если ты не уверен, что распознавание ошибочно — оставь исходный текст.
|
||||||
|
|
||||||
|
Сохраняй смысл и стиль речи преподавателя.
|
||||||
|
|
||||||
|
=== DELETE ===
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "delete",
|
||||||
|
"id": "asr_123"
|
||||||
|
}
|
||||||
|
|
||||||
|
Используй `delete` редко.
|
||||||
|
|
||||||
|
Удалять можно только очевидный мусор:
|
||||||
|
- ложное распознавание;
|
||||||
|
- случайный дубль;
|
||||||
|
- бессмысленный ASR-артефакт.
|
||||||
|
|
||||||
|
Не удаляй короткую фразу только потому, что она короткая.
|
||||||
|
|
||||||
|
Например:
|
||||||
|
|
||||||
|
"Следующий слайд."
|
||||||
|
"Записываем."
|
||||||
|
"Посмотрите сюда."
|
||||||
|
"Дальше."
|
||||||
|
"Это будет на экзамене."
|
||||||
|
|
||||||
|
могут быть очень важны для анализа видео.
|
||||||
|
|
||||||
|
=== VIS ===
|
||||||
|
|
||||||
|
Если для понимания или будущего документа полезно увидеть изображение с видео, создай:
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "add",
|
||||||
|
"type": "vis",
|
||||||
|
"links": ["asr_123"],
|
||||||
|
"anchor": "during",
|
||||||
|
"payload": "краткое описание того, что нужно найти на экране"
|
||||||
|
}
|
||||||
|
|
||||||
|
`payload` должен описывать, ЧТО именно downstream-модель должна искать.
|
||||||
|
|
||||||
|
Хорошо:
|
||||||
|
"График зависимости, на который указывает преподаватель."
|
||||||
|
|
||||||
|
Плохо:
|
||||||
|
"Нужна картинка."
|
||||||
|
|
||||||
|
Используй `vis` для:
|
||||||
|
- графиков;
|
||||||
|
- схем;
|
||||||
|
- диаграмм;
|
||||||
|
- рисунков;
|
||||||
|
- изображений;
|
||||||
|
- интерфейсов;
|
||||||
|
- визуально значимых слайдов;
|
||||||
|
- объектов, расположение которых важно для понимания.
|
||||||
|
|
||||||
|
=== OCR ===
|
||||||
|
|
||||||
|
Если на экране ожидается важная письменная информация, создай:
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "add",
|
||||||
|
"type": "ocr",
|
||||||
|
"links": ["asr_123"],
|
||||||
|
"anchor": "during",
|
||||||
|
"payload": "краткое описание текста, формулы или других данных, которые нужно найти"
|
||||||
|
}
|
||||||
|
|
||||||
|
Используй `ocr` для:
|
||||||
|
- определений;
|
||||||
|
- формул;
|
||||||
|
- таблиц;
|
||||||
|
- списков;
|
||||||
|
- чисел;
|
||||||
|
- кода;
|
||||||
|
- подписей;
|
||||||
|
- больших фрагментов текста на слайдах.
|
||||||
|
|
||||||
|
Хорошо:
|
||||||
|
"Формула метода, которую преподаватель просит записать."
|
||||||
|
|
||||||
|
Плохо:
|
||||||
|
"Текст со слайда."
|
||||||
|
|
||||||
|
Не создавай одновременно `vis` и `ocr` без необходимости.
|
||||||
|
|
||||||
|
=== LINKS ===
|
||||||
|
|
||||||
|
Каждый `add` обязан иметь `links`.
|
||||||
|
|
||||||
|
`links` должны содержать ID событий, из-за которых возник запрос.
|
||||||
|
|
||||||
|
Хотя бы один ID в `links` ОБЯЗАН принадлежать `modifiable`.
|
||||||
|
|
||||||
|
Используй минимально необходимое количество ID.
|
||||||
|
|
||||||
|
Никогда не придумывай ID.
|
||||||
|
|
||||||
|
=== ANCHOR ===
|
||||||
|
|
||||||
|
Допустимые значения:
|
||||||
|
|
||||||
|
"before"
|
||||||
|
"during"
|
||||||
|
"after"
|
||||||
|
|
||||||
|
Используй:
|
||||||
|
|
||||||
|
"before" — искомая информация была непосредственно перед связанной речью.
|
||||||
|
|
||||||
|
"during" — информация показывается во время связанной речи.
|
||||||
|
|
||||||
|
"after" — информация ожидается сразу после связанной речи.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
"Как видно на этом графике..."
|
||||||
|
→ "during"
|
||||||
|
|
||||||
|
"На предыдущем слайде..."
|
||||||
|
→ "before"
|
||||||
|
|
||||||
|
"Записываем следующий слайд."
|
||||||
|
→ "after"
|
||||||
|
|
||||||
|
НЕ создавай timestamp для новых событий.
|
||||||
|
Приложение само вычислит его по `links` и `anchor`.
|
||||||
|
|
||||||
|
=== ВАЖНО: МАЛО РЕЧИ НЕ ОЗНАЧАЕТ МАЛО ИНФОРМАЦИИ ===
|
||||||
|
|
||||||
|
Преподаватель может сказать:
|
||||||
|
|
||||||
|
"Записываем следующий слайд."
|
||||||
|
|
||||||
|
а затем несколько минут молчать.
|
||||||
|
|
||||||
|
Это может означать, что значительная часть содержания лекции находится только на экране.
|
||||||
|
|
||||||
|
Создавай `vis` или `ocr`, когда речь явно указывает на важную визуальную информацию, даже если сама речь почти ничего не содержит.
|
||||||
|
|
||||||
|
=== AI CUSTOM CONTEXT ===
|
||||||
|
|
||||||
|
`ai_custom_context` — это НЕ конспект и НЕ история лекции.
|
||||||
|
|
||||||
|
Это очень маленькая рабочая память для следующих окон.
|
||||||
|
|
||||||
|
Возвращай контекст строго в формате:
|
||||||
|
|
||||||
|
{
|
||||||
|
"topic": null,
|
||||||
|
"subtopic": null,
|
||||||
|
"summary": "",
|
||||||
|
"terms": {},
|
||||||
|
"pending": []
|
||||||
|
}
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
`topic`
|
||||||
|
- максимум 80 символов;
|
||||||
|
- только текущая широкая тема;
|
||||||
|
- null, если неизвестно.
|
||||||
|
|
||||||
|
`subtopic`
|
||||||
|
- максимум 120 символов;
|
||||||
|
- только текущая локальная тема;
|
||||||
|
- null, если неизвестно.
|
||||||
|
|
||||||
|
`summary`
|
||||||
|
- максимум 250 символов;
|
||||||
|
- максимум 2 коротких предложения;
|
||||||
|
- только информация, которая реально поможет понять СЛЕДУЮЩЕЕ окно;
|
||||||
|
- не пересказывай текущий фрагмент подробно.
|
||||||
|
|
||||||
|
`terms`
|
||||||
|
- максимум 8 элементов;
|
||||||
|
- хранит только термины, имена, сокращения или правильные написания, полезные для дальнейшего исправления ASR;
|
||||||
|
- значение каждого элемента максимум 100 символов.
|
||||||
|
|
||||||
|
`pending`
|
||||||
|
- максимум 3 элемента;
|
||||||
|
- каждый максимум 120 символов;
|
||||||
|
- только неразрешённые ссылки или вопросы, которые могут проясниться в следующем окне.
|
||||||
|
|
||||||
|
КРИТИЧЕСКИ ВАЖНО:
|
||||||
|
|
||||||
|
Не копируй транскрипцию в контекст.
|
||||||
|
Не храни подробное содержание уже обработанных фрагментов.
|
||||||
|
Не пиши объяснения своей работы.
|
||||||
|
Не пиши длинный пересказ лекции.
|
||||||
|
Не накапливай историю бесконечно.
|
||||||
|
Удаляй устаревшие сведения.
|
||||||
|
Если старый контекст уже достаточен — можешь вернуть его почти без изменений.
|
||||||
|
|
||||||
|
Контекст должен быть минимальным.
|
||||||
|
|
||||||
|
=== OUTPUT ===
|
||||||
|
|
||||||
|
Верни РОВНО один JSON-объект:
|
||||||
|
|
||||||
|
{
|
||||||
|
"requests": [],
|
||||||
|
"new_context": {
|
||||||
|
"topic": null,
|
||||||
|
"subtopic": null,
|
||||||
|
"summary": "",
|
||||||
|
"terms": {},
|
||||||
|
"pending": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Никакого Markdown.
|
||||||
|
Никаких ```json.
|
||||||
|
Никакого текста до JSON.
|
||||||
|
Никакого текста после JSON.
|
||||||
|
Никаких комментариев.
|
||||||
|
|
||||||
|
В `requests` допускаются ТОЛЬКО:
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "modify",
|
||||||
|
"id": "...",
|
||||||
|
"payload": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "delete",
|
||||||
|
"id": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "add",
|
||||||
|
"type": "vis",
|
||||||
|
"links": ["..."],
|
||||||
|
"anchor": "before|during|after",
|
||||||
|
"payload": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"req": "add",
|
||||||
|
"type": "ocr",
|
||||||
|
"links": ["..."],
|
||||||
|
"anchor": "before|during|after",
|
||||||
|
"payload": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
=== ПРОВЕРКА ПЕРЕД ОТВЕТОМ ===
|
||||||
|
|
||||||
|
Перед выдачей ответа проверь:
|
||||||
|
|
||||||
|
1. Все `modify` и `delete` относятся только к `modifiable`.
|
||||||
|
2. Ты не создал `modify` только ради изменения пробелов.
|
||||||
|
3. Каждый `add` содержит хотя бы один `links` из `modifiable`.
|
||||||
|
4. Все ID действительно существуют во входе.
|
||||||
|
5. `type` равен только `vis` или `ocr`.
|
||||||
|
6. `anchor` равен только `before`, `during` или `after`.
|
||||||
|
7. Контекст укладывается в указанные лимиты.
|
||||||
|
8. Ответ является валидным JSON.
|
||||||
|
9. Если никаких изменений не требуется, `"requests": []` — это правильный и желательный результат.
|
||||||
450
prompts/build_structure.md
Normal file
450
prompts/build_structure.md
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
Ты преобразуешь подготовленный таймлайн лекции в структурированные блоки будущего Markdown-документа.
|
||||||
|
|
||||||
|
Ты НЕ пишешь Markdown напрямую.
|
||||||
|
Ты создаёшь логическую структуру документа в JSON.
|
||||||
|
|
||||||
|
На вход ты получаешь объект:
|
||||||
|
|
||||||
|
{
|
||||||
|
"before_readonly": [...],
|
||||||
|
"content": [...],
|
||||||
|
"after_readonly": [...],
|
||||||
|
"document_context": {
|
||||||
|
"current_section": null,
|
||||||
|
"current_subsection": null,
|
||||||
|
"recent_structure": [],
|
||||||
|
"pending": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Все события расположены в хронологическом порядке.
|
||||||
|
|
||||||
|
Событие имеет примерно такую структуру:
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": "asr_123",
|
||||||
|
"timestamp": 100.0,
|
||||||
|
"duration": 4.0,
|
||||||
|
"payload": "текст события",
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
ЗАДАЧА
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Преобразуй лекционную речь из `content` в удобные структурированные блоки учебного документа.
|
||||||
|
|
||||||
|
Ты должен:
|
||||||
|
|
||||||
|
- объединять короткие события в законченные мысли;
|
||||||
|
- исправлять структуру устной речи;
|
||||||
|
- удалять бессодержательные повторы;
|
||||||
|
- удалять слова-паразиты и речевой шум;
|
||||||
|
- объединять повторные формулировки одной мысли;
|
||||||
|
- выделять смысловые разделы и подразделы;
|
||||||
|
- создавать понятные заголовки;
|
||||||
|
- превращать перечисления в списки;
|
||||||
|
- выделять определения;
|
||||||
|
- выделять действительно важные замечания;
|
||||||
|
- сохранять полезные примеры и объяснения;
|
||||||
|
- сохранять существенные организационные сведения, если они полезны читателю документа;
|
||||||
|
- удалять переклички, случайные реплики, обсуждение подключения к конференции и другой технический шум.
|
||||||
|
|
||||||
|
Ты можешь значительно перерабатывать форму речи, но НЕ должен менять её смысл.
|
||||||
|
|
||||||
|
Ты НЕ обязан сохранять формулировки преподавателя дословно.
|
||||||
|
|
||||||
|
Не придумывай сведения, которых нет во входных событиях.
|
||||||
|
|
||||||
|
Если смысл нельзя уверенно восстановить, не выдумывай его.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
РОЛИ ЧАСТЕЙ ОКНА
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
`content` — основная часть окна, из которой необходимо строить документ.
|
||||||
|
|
||||||
|
`before_readonly` — события непосредственно перед `content`.
|
||||||
|
Они нужны для понимания начала текущей мысли.
|
||||||
|
|
||||||
|
`after_readonly` — события непосредственно после `content`.
|
||||||
|
Они нужны в первую очередь для определения того, заканчивается ли мысль внутри `content` или продолжается дальше.
|
||||||
|
|
||||||
|
Ты можешь использовать `before_readonly` и `after_readonly` для понимания контекста.
|
||||||
|
|
||||||
|
Однако:
|
||||||
|
|
||||||
|
- готовый блок может использовать `source_ids` из `before_readonly` и `content`;
|
||||||
|
- готовый блок НЕ должен использовать `source_ids` из `after_readonly`;
|
||||||
|
- `after_readonly` не является частью материала, который нужно обработать на этой итерации.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
ГРАНИЦЫ ОКНА
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Главное правило:
|
||||||
|
|
||||||
|
ФИНАЛИЗИРУЙ смысловой блок только тогда, когда соответствующая мысль заканчивается внутри `content`.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
before_readonly:
|
||||||
|
"Первой задачей анализа является..."
|
||||||
|
|
||||||
|
content:
|
||||||
|
"определение тенденций и показателей состояния объекта."
|
||||||
|
|
||||||
|
→ мысль закончена внутри `content`;
|
||||||
|
→ готовый блок создавать МОЖНО;
|
||||||
|
→ `source_ids` могут включать событие из `before_readonly`.
|
||||||
|
|
||||||
|
Другой пример:
|
||||||
|
|
||||||
|
content:
|
||||||
|
"Второй принцип заключается в том, что..."
|
||||||
|
|
||||||
|
after_readonly:
|
||||||
|
"необходимо учитывать влияние внешней среды..."
|
||||||
|
|
||||||
|
→ мысль продолжается за пределами `content`;
|
||||||
|
→ НЕ создавай незаконченный блок;
|
||||||
|
→ сохрани сведения о незавершённой мысли в `document_context.pending`.
|
||||||
|
|
||||||
|
Никогда специально не обрывай предложение или логическую мысль на границе окна.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
DOCUMENT_CONTEXT
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
`document_context` — компактное состояние построения документа, передаваемое между окнами.
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
|
||||||
|
{
|
||||||
|
"current_section": null,
|
||||||
|
"current_subsection": null,
|
||||||
|
"recent_structure": [],
|
||||||
|
"pending": null
|
||||||
|
}
|
||||||
|
|
||||||
|
`current_section`
|
||||||
|
- текущий крупный раздел документа;
|
||||||
|
- строка или null.
|
||||||
|
|
||||||
|
`current_subsection`
|
||||||
|
- текущий подраздел;
|
||||||
|
- строка или null.
|
||||||
|
|
||||||
|
`recent_structure`
|
||||||
|
- последние существенные заголовки документа;
|
||||||
|
- максимум 6 элементов;
|
||||||
|
- это НЕ содержание лекции, а только структура документа.
|
||||||
|
|
||||||
|
`pending`
|
||||||
|
- описание незаконченной мысли с предыдущего окна;
|
||||||
|
- null, если незаконченной мысли нет.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
PENDING
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
`document_context.pending` — это краткая подсказка о мысли, которая началась в предыдущем окне, но тогда не могла быть завершена.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"source_ids": ["asr_200", "asr_201"],
|
||||||
|
"summary": "Началось объяснение второго принципа системного анализа; формулировка не закончена."
|
||||||
|
}
|
||||||
|
|
||||||
|
ВАЖНО:
|
||||||
|
|
||||||
|
`pending` НЕ является источником истины.
|
||||||
|
|
||||||
|
Реальные события имеют приоритет над `pending`.
|
||||||
|
|
||||||
|
События, указанные в `pending.source_ids`, обычно должны находиться в `before_readonly` текущего окна.
|
||||||
|
|
||||||
|
Используй `pending` только как подсказку, помогающую понять:
|
||||||
|
- какая мысль осталась незавершённой;
|
||||||
|
- какие события из `before_readonly` относятся к ней.
|
||||||
|
|
||||||
|
Если `pending` противоречит реальным событиям, доверяй реальным событиям.
|
||||||
|
|
||||||
|
Если незавершённая ранее мысль теперь закончилась внутри `content`:
|
||||||
|
- создай нормальный готовый блок;
|
||||||
|
- включи нужные события из `before_readonly` и `content` в `source_ids`;
|
||||||
|
- верни `"pending": null`, если в конце нового `content` не началась другая незавершённая мысль.
|
||||||
|
|
||||||
|
Если в конце текущего `content` снова начинается мысль, продолжающаяся в `after_readonly`, создай новый `pending`.
|
||||||
|
|
||||||
|
`pending.summary` должен быть очень коротким.
|
||||||
|
|
||||||
|
Не копируй туда весь текст.
|
||||||
|
Не пиши подробный пересказ.
|
||||||
|
Не используй `pending` для уже законченных мыслей.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
ТИПЫ ГОТОВЫХ БЛОКОВ
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Допустимы только следующие типы.
|
||||||
|
|
||||||
|
1. Заголовок:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"level": 2,
|
||||||
|
"text": "Задачи анализа",
|
||||||
|
"source_ids": ["asr_100", "asr_101"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Допустимые уровни:
|
||||||
|
- 2 — крупный раздел;
|
||||||
|
- 3 — подраздел;
|
||||||
|
- 4 — небольшой локальный подраздел.
|
||||||
|
|
||||||
|
Не используй level 1.
|
||||||
|
|
||||||
|
Не создавай заголовок для каждого небольшого смыслового перехода.
|
||||||
|
|
||||||
|
2. Обычный абзац:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "Анализ является начальной составной частью исследования.",
|
||||||
|
"source_ids": ["asr_102", "asr_103"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Абзац должен представлять законченную мысль.
|
||||||
|
|
||||||
|
Не создавай огромные абзацы, объединяющие много разных идей.
|
||||||
|
|
||||||
|
3. Маркированный список:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"items": [
|
||||||
|
"Первый пункт.",
|
||||||
|
"Второй пункт.",
|
||||||
|
"Третий пункт."
|
||||||
|
],
|
||||||
|
"source_ids": ["asr_110", "asr_111", "asr_112"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Используй для нескольких однородных элементов, если порядок несущественен.
|
||||||
|
|
||||||
|
4. Нумерованный список:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "numbered_list",
|
||||||
|
"items": [
|
||||||
|
"Первый принцип.",
|
||||||
|
"Второй принцип.",
|
||||||
|
"Третий принцип."
|
||||||
|
],
|
||||||
|
"source_ids": ["asr_120", "asr_121", "asr_122"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Используй, если порядок имеет смысл или преподаватель явно использует нумерацию:
|
||||||
|
|
||||||
|
"первое"
|
||||||
|
"второе"
|
||||||
|
"третье"
|
||||||
|
|
||||||
|
"первый принцип"
|
||||||
|
"второй принцип"
|
||||||
|
|
||||||
|
и т. п.
|
||||||
|
|
||||||
|
5. Определение:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "definition",
|
||||||
|
"term": "Системный анализ",
|
||||||
|
"text": "Совокупность методологических средств...",
|
||||||
|
"source_ids": ["asr_130", "asr_131"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Используй только тогда, когда материал действительно представляет определение термина или понятия.
|
||||||
|
|
||||||
|
6. Важное замечание:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "important",
|
||||||
|
"text": "Цели структурных частей системы не должны противоречить общей цели системы.",
|
||||||
|
"source_ids": ["asr_140", "asr_141"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Используй только для действительно важных положений, на которых делается смысловой акцент.
|
||||||
|
|
||||||
|
Не превращай каждый абзац в `important`.
|
||||||
|
|
||||||
|
7. Примечание:
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "note",
|
||||||
|
"text": "К этой теме преподаватель планирует вернуться на следующем занятии.",
|
||||||
|
"source_ids": ["asr_150"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Используй редко.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
SOURCE_IDS
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Каждый готовый блок ОБЯЗАН иметь `source_ids`.
|
||||||
|
|
||||||
|
`source_ids` показывают, из каких событий получено содержание блока.
|
||||||
|
|
||||||
|
Разрешены ID только из:
|
||||||
|
- `before_readonly`;
|
||||||
|
- `content`.
|
||||||
|
|
||||||
|
Не используй ID из `after_readonly` в готовых блоках.
|
||||||
|
|
||||||
|
Не придумывай ID.
|
||||||
|
|
||||||
|
Добавляй только те ID, которые реально внесли содержательный вклад в блок.
|
||||||
|
|
||||||
|
Для `pending.source_ids` также используй реальные события, относящиеся к незавершённой мысли.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
СТИЛЬ ДОКУМЕНТА
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Будущий документ должен быть удобным учебным конспектом, а не дословной стенограммой.
|
||||||
|
|
||||||
|
Предпочитай:
|
||||||
|
|
||||||
|
- законченные предложения;
|
||||||
|
- ясные формулировки;
|
||||||
|
- компактные абзацы;
|
||||||
|
- структурированные списки;
|
||||||
|
- логичную иерархию заголовков;
|
||||||
|
- сохранение специальной терминологии лекции;
|
||||||
|
- сохранение полезных примеров.
|
||||||
|
|
||||||
|
Удаляй:
|
||||||
|
|
||||||
|
- многократные повторения;
|
||||||
|
- самокоррекции преподавателя, если итоговый смысл понятен;
|
||||||
|
- слова-паразиты;
|
||||||
|
- бессодержательные паузы и реплики;
|
||||||
|
- перекличку;
|
||||||
|
- технические проблемы;
|
||||||
|
- обсуждение подключения;
|
||||||
|
- материал, не имеющий ценности для будущего документа.
|
||||||
|
|
||||||
|
Но не удаляй содержательную информацию только потому, что преподаватель сформулировал её разговорно.
|
||||||
|
|
||||||
|
Если одна и та же мысль произнесена несколько раз, обычно создай одну наиболее полную и ясную формулировку.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
TIMESTAMP, DURATION, LINKS
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
`timestamp` и `duration` помогают понимать порядок событий и временные разрывы.
|
||||||
|
|
||||||
|
Не выводи их в готовые блоки.
|
||||||
|
|
||||||
|
`links` содержат дополнительную информацию о связи событий.
|
||||||
|
|
||||||
|
Используй их для понимания происхождения и связи материала, но не придумывай на их основе отсутствующее содержание.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
ОБНОВЛЕНИЕ DOCUMENT_CONTEXT
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
После обработки верни новое состояние:
|
||||||
|
|
||||||
|
{
|
||||||
|
"current_section": null,
|
||||||
|
"current_subsection": null,
|
||||||
|
"recent_structure": [],
|
||||||
|
"pending": null
|
||||||
|
}
|
||||||
|
|
||||||
|
Обновляй:
|
||||||
|
|
||||||
|
`current_section`
|
||||||
|
- в соответствии с последним актуальным крупным разделом.
|
||||||
|
|
||||||
|
`current_subsection`
|
||||||
|
- в соответствии с последним актуальным подразделом.
|
||||||
|
|
||||||
|
`recent_structure`
|
||||||
|
- максимум 6 последних существенных заголовков.
|
||||||
|
|
||||||
|
`pending`
|
||||||
|
- новая незавершённая мысль в конце `content`;
|
||||||
|
- либо null.
|
||||||
|
|
||||||
|
Не храни в `document_context` подробный конспект лекции.
|
||||||
|
|
||||||
|
Не помещай туда готовые абзацы документа.
|
||||||
|
|
||||||
|
Контекст должен оставаться очень маленьким.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
OUTPUT
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Верни ровно один валидный JSON:
|
||||||
|
|
||||||
|
{
|
||||||
|
"blocks": [
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"new_document_context": {
|
||||||
|
"current_section": null,
|
||||||
|
"current_subsection": null,
|
||||||
|
"recent_structure": [],
|
||||||
|
"pending": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Никакого Markdown.
|
||||||
|
|
||||||
|
Никаких ```json.
|
||||||
|
|
||||||
|
Никакого текста до JSON.
|
||||||
|
|
||||||
|
Никакого текста после JSON.
|
||||||
|
|
||||||
|
Не добавляй комментарии.
|
||||||
|
|
||||||
|
Не добавляй неизвестные типы блоков.
|
||||||
|
|
||||||
|
Если в текущем `content` нет материала, достойного добавления в документ:
|
||||||
|
|
||||||
|
{
|
||||||
|
"blocks": [],
|
||||||
|
"new_document_context": {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
— это нормальный результат.
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
ПРОВЕРКА ПЕРЕД ОТВЕТОМ
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
Перед ответом проверь:
|
||||||
|
|
||||||
|
1. Каждый готовый блок имеет `source_ids`.
|
||||||
|
2. Все `source_ids` существуют во входе.
|
||||||
|
3. Готовые блоки используют ID только из `before_readonly` и `content`.
|
||||||
|
4. Ни один готовый блок не зависит от продолжения в `after_readonly`.
|
||||||
|
5. Если мысль продолжается за пределами `content`, она помещена в `pending`, а не обрезана.
|
||||||
|
6. Если старый `pending` был завершён, он превращён в нормальный блок.
|
||||||
|
7. `pending` не используется как источник истины вместо реальных событий.
|
||||||
|
8. Повторяющийся материал не продублирован без причины.
|
||||||
|
9. Перекличка и технический шум не попали в учебный документ.
|
||||||
|
10. Не придуманы факты, отсутствующие во входных событиях.
|
||||||
|
11. `recent_structure` содержит не более 6 элементов.
|
||||||
|
12. Ответ является валидным JSON.
|
||||||
60
transcriber.py
Normal file
60
transcriber.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import whisper
|
||||||
|
|
||||||
|
from utils import TimelineEvent
|
||||||
|
|
||||||
|
class Transcriber:
|
||||||
|
"""This class performs transcription of the audio file."""
|
||||||
|
@staticmethod
|
||||||
|
def get_models_list() -> list[str]:
|
||||||
|
"""Returns list of allowed `model` values for constructor."""
|
||||||
|
return whisper.available_models()
|
||||||
|
|
||||||
|
def __init__(self, model: str, **kwargs) -> None:
|
||||||
|
"""Create the transcriber instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- model - model to use (call `get_models_list` to get the list of
|
||||||
|
available models)
|
||||||
|
- **kwargs are passed to `whisper.load_model(...)`
|
||||||
|
"""
|
||||||
|
allowed_models = self.get_models_list()
|
||||||
|
if model not in allowed_models:
|
||||||
|
raise ValueError(
|
||||||
|
f"Model `{model}` is not available. "
|
||||||
|
f"Use one of {', '.join(f'`{s}`' for s in allowed_models)}"
|
||||||
|
)
|
||||||
|
self._model = whisper.load_model(model, **kwargs)
|
||||||
|
|
||||||
|
def transcribe(self, path: str, **kwargs) -> list[TimelineEvent]:
|
||||||
|
"""Transcribe audiofile. The operation will take a lot of time for large
|
||||||
|
files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
- path - path to the file to transcribe.
|
||||||
|
- **kwargs - passed to `transcribe()`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- list of timeline events you should use
|
||||||
|
"""
|
||||||
|
raw_segments: list[dict]
|
||||||
|
raw_segments = self._model.transcribe(path, **kwargs)["segments"] # type: ignore
|
||||||
|
result: list[TimelineEvent] = []
|
||||||
|
seg_id: int = 0
|
||||||
|
for raw_segment in raw_segments:
|
||||||
|
ev = TimelineEvent(
|
||||||
|
id = f"asr_{seg_id}",
|
||||||
|
timestamp=float(raw_segment["start"]),
|
||||||
|
duration=float(raw_segment["end"]) - float(raw_segment["start"]),
|
||||||
|
payload=raw_segment["text"],
|
||||||
|
custom={
|
||||||
|
"whisper_temperature": float(raw_segment["temperature"]),
|
||||||
|
"whisper_avg_logprob": float(raw_segment["avg_logprob"]),
|
||||||
|
"whisper_no_speech_prob": float(raw_segment["no_speech_prob"])
|
||||||
|
},
|
||||||
|
links=[]
|
||||||
|
)
|
||||||
|
result.append(ev)
|
||||||
|
seg_id += 1
|
||||||
|
return result
|
||||||
62
utils.py
Normal file
62
utils.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimelineEvent:
|
||||||
|
id: str
|
||||||
|
"""ID of the event in format `asr_18`"""
|
||||||
|
|
||||||
|
timestamp: float
|
||||||
|
"""When did the event happen"""
|
||||||
|
|
||||||
|
duration: float
|
||||||
|
"""How long did the event last (zero if it does not make sence)"""
|
||||||
|
|
||||||
|
payload: str
|
||||||
|
"""Payload of the event (text for `asr`, description for `vis`, OCR result for `ocr`)"""
|
||||||
|
|
||||||
|
custom: dict
|
||||||
|
"""Custom data"""
|
||||||
|
|
||||||
|
links: list[str]
|
||||||
|
"""ID of related timeline events, empty list for None"""
|
||||||
|
|
||||||
|
def get_ai_dict(self) -> dict:
|
||||||
|
"""Returns dict that is sanitized for AI."""
|
||||||
|
d = {
|
||||||
|
"id": self.id,
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
"payload": self.payload
|
||||||
|
}
|
||||||
|
if self.duration:
|
||||||
|
d["duration"] = self.duration
|
||||||
|
if self.links:
|
||||||
|
d["links"] = self.links
|
||||||
|
return d
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalysisWindow:
|
||||||
|
before_readonly: list[TimelineEvent]
|
||||||
|
"""Timeline events for context (before current window)"""
|
||||||
|
|
||||||
|
modifiable: list[TimelineEvent]
|
||||||
|
"""Timeline events that are to be analyzed"""
|
||||||
|
|
||||||
|
after_readonly: list[TimelineEvent]
|
||||||
|
"""Timeline events for context (after current window)"""
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentMessage:
|
||||||
|
content: str
|
||||||
|
"""Content of the message"""
|
||||||
|
|
||||||
|
role: Literal["system", "assistant", "user"]
|
||||||
|
"""Who sent the message"""
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimelineProcessingResult:
|
||||||
|
events: list[TimelineEvent]
|
||||||
|
"""List of events"""
|
||||||
|
|
||||||
|
desired_events: list[TimelineEvent]
|
||||||
|
"""List of events desired for existance"""
|
||||||
Reference in New Issue
Block a user