Initial commit

This commit is contained in:
2026-09-16 08:32:06 +03:00
commit 89f0b171d2
8 changed files with 1598 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
__pycache__/
.venv/
runtime/
*.json
*.mkv
*.mp4
*.avi
*.wav
*.ogg
*.mp3
*.m4a

257
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"""