commit 89f0b171d293352ed998f1717268233d379b2700 Author: nikita Date: Wed Sep 16 08:32:06 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e730c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +.venv/ +runtime/ + +*.json + +*.mkv +*.mp4 +*.avi + +*.wav +*.ogg +*.mp3 +*.m4a \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c08eb5 --- /dev/null +++ b/README.md @@ -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` и происходит переход на следующий шаг. + - \ No newline at end of file diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..02e6d5f --- /dev/null +++ b/agent.py @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..25c758d --- /dev/null +++ b/main.py @@ -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() \ No newline at end of file diff --git a/prompts/audio_window.md b/prompts/audio_window.md new file mode 100644 index 0000000..ee72831 --- /dev/null +++ b/prompts/audio_window.md @@ -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": []` — это правильный и желательный результат. \ No newline at end of file diff --git a/prompts/build_structure.md b/prompts/build_structure.md new file mode 100644 index 0000000..8700676 --- /dev/null +++ b/prompts/build_structure.md @@ -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. \ No newline at end of file diff --git a/transcriber.py b/transcriber.py new file mode 100644 index 0000000..873649e --- /dev/null +++ b/transcriber.py @@ -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 \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..0890c0d --- /dev/null +++ b/utils.py @@ -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""" \ No newline at end of file