diff --git a/asr_eventizer.py b/asr_eventizer.py index e794399..3a03c31 100644 --- a/asr_eventizer.py +++ b/asr_eventizer.py @@ -123,5 +123,6 @@ class AsrEventizer: text=preevent.text, payload=None )) + output_id += 1 # final timeline return Timeline(events=events) \ No newline at end of file diff --git a/main.py b/main.py index dc010c4..5e64163 100644 --- a/main.py +++ b/main.py @@ -14,10 +14,11 @@ import torch from asr import Asr, AsrRawResult from asr_filter import AsrFilter, AsrFilterResult from asr_eventizer import AsrEventizer +from structure_builder import StructureBuilder from windowizer import Windowizer from agent import Agent -from utils import ffmpeg_split_video, ffmpeg_to_mp3 +from utils import Timeline, ffmpeg_split_video, ffmpeg_to_mp3 ARGS: argparse.Namespace @@ -170,6 +171,54 @@ def on_asr_events(current_step: Step, input_data: dict | None) -> tuple[Step | N result = eventizer.eventize(AsrFilterResult(**input_data)) return (Step.VIDEO_REFERENCES, result.model_dump(mode="json")) +def on_video_references(current_step: Step, input_data: dict | None) -> tuple[Step | None, dict | None]: + # do not filter if output file exists + if os.path.isfile(WORKFLOW_DATA[current_step][0]): + logging.info("Skipping video references") + with open(WORKFLOW_DATA[current_step][0], "rb") as f: + return (Step.REFERENCE_RESOLVER, json.load(f)) + if not os.path.isfile("video.mp4"): + logging.info("No video, skipping") + return (Step.REFERENCE_RESOLVER, None) + # NOT IMPLEMENTED + logging.warning("Video references are not implemented yet") + return (Step.REFERENCE_RESOLVER, None) + +def on_reference_resolver(current_step: Step, input_data: dict | None) -> tuple[Step | None, dict | None]: + # do not filter if output file exists + if os.path.isfile(WORKFLOW_DATA[current_step][0]): + logging.info("Skipping reference resolver") + with open(WORKFLOW_DATA[current_step][0], "rb") as f: + return (Step.STRUCTURE_BUILDER, json.load(f)) + if not os.path.isfile("video.mp4"): + logging.info("No video, skipping") + return (Step.STRUCTURE_BUILDER, input_data) + # NOT IMPLEMENTED + logging.warning("Reference resolver is not implemented yet") + return (Step.STRUCTURE_BUILDER, input_data) + +def on_structure_builder(current_step: Step, input_data: dict | None) -> tuple[Step | None, dict | None]: + # don't if done + if os.path.isfile(WORKFLOW_DATA[current_step][0]): + logging.info("Skipping structure builder") + with open(WORKFLOW_DATA[current_step][0], "rb") as f: + return (Step.MARKDOWN_BUILDER, json.load(f)) + if input_data is None: + logging.error("Can't build document structure without input_data") + return (None, None) + logging.info("Creating the agent") + agent = Agent( + model=ARGS.ai_model, + base_url=ARGS.ai_base_url, + api_key=ARGS.ai_api_key + ) + logging.info("Creating the windowizer") + windowizer = Windowizer() + logging.info("Building document structure...") + builder = StructureBuilder(agent, windowizer) + result = builder.build(Timeline(**input_data)) + return (Step.MARKDOWN_BUILDER, result.model_dump(mode="json")) + # # Main # @@ -178,9 +227,9 @@ WORKFLOW_DATA: dict[Step, tuple[str, Callable[[Step, dict | None], tuple[Step | Step.VOICE_RECOGNITION: ("asr_raw.json", on_voice_recognition), Step.ASR_FILTER: ("asr.json", on_asr_filter), Step.ASR_EVENTS: ("audio_events.json", on_asr_events), - Step.VIDEO_REFERENCES: ("unresolved.json", None), - Step.REFERENCE_RESOLVER: ("events.json", None), - Step.STRUCTURE_BUILDER: ("structure.json", None), + Step.VIDEO_REFERENCES: ("unresolved.json", on_video_references), + Step.REFERENCE_RESOLVER: ("events.json", on_reference_resolver), + Step.STRUCTURE_BUILDER: ("structure.json", on_structure_builder), Step.MARKDOWN_BUILDER: ("output.md", None) } """Information about workflow. @@ -224,4 +273,4 @@ if __name__ == "__main__": except SystemExit: raise except: - traceback.print_exc() \ No newline at end of file + traceback.print_exc() diff --git a/prompts/structure_builder.md b/prompts/structure_builder.md new file mode 100644 index 0000000..4a05517 --- /dev/null +++ b/prompts/structure_builder.md @@ -0,0 +1,588 @@ +Ты преобразуешь временную шкалу лекции в структурированные элементы будущего Markdown-документа. + +Ты НЕ пишешь Markdown напрямую. +Ты создаёшь только JSON-структуру документа. + +Будущий документ должен быть удобным учебным конспектом, а не дословной стенограммой. + +================================================== +ФОРМАТ ВХОДА +================================================== + +На вход поступает JSON: + +{ + "past": [...], + "present": [...], + "future": [...], + "context": {...} +} + +Все события расположены в хронологическом порядке. + +Событие может иметь один из трёх типов. + +1. Голосовое событие: + +{ + "id": 10, + "type": "voice", + "timestamp": 120.0, + "duration": 8.0, + "text": "Осмысленная речь преподавателя.", + "payload": null +} + +`text` содержит речь преподавателя. + +2. Визуальное событие: + +{ + "id": 11, + "type": "vis", + "timestamp": 128.0, + "duration": 0.0, + "text": "Описание содержимого выбранного кадра.", + "payload": "images/123.jpg" +} + +`text` описывает содержание изображения. +`payload` содержит путь к изображению. + +3. OCR-событие: + +{ + "id": 12, + "type": "ocr", + "timestamp": 132.0, + "duration": 0.0, + "text": "Описание того, какой текст был найден на экране.", + "payload": "Текст, распознанный непосредственно с изображения." +} + +Для `ocr` наиболее важным содержанием является `payload`. + +================================================== +ОСНОВНАЯ ЗАДАЧА +================================================== + +Преобразуй события из `present` в элементы учебного документа. + +Ты должен: + +- объединять связанные события в законченные мысли; +- устранять особенности устной речи; +- удалять бессодержательные повторы; +- удалять слова-паразиты и организационный шум; +- объединять повторные формулировки одной мысли; +- создавать логические разделы и подразделы; +- превращать перечисления в списки; +- выделять определения; +- выделять действительно важные утверждения; +- включать полезную информацию из OCR; +- добавлять изображения, когда они действительно помогают понять материал; +- сохранять важные примеры и пояснения преподавателя. + +Ты можешь существенно перерабатывать ФОРМУ речи. + +Ты НЕ должен изменять её СМЫСЛ. + +Ты НЕ должен придумывать факты, которых нет во входных событиях. + +Ты НЕ обязан использовать каждое событие. + +================================================== +PAST +================================================== + +`past` содержит события непосредственно перед текущим окном. + +Они нужны только для понимания контекста. + +Не создавай элементы документа повторно на основе уже обработанного `past`. + +Использовать содержание из `past` в новом элементе разрешается только если: + +- во входном `context.pending` указано, что эта мысль осталась незавершённой; +- и события из текущего `present` действительно завершают её. + +Во всех остальных случаях `past` является read-only контекстом. + +================================================== +PRESENT +================================================== + +`present` — основная часть текущего окна. + +Именно события из `present` необходимо преобразовать в элементы документа. + +Одно событие может: + +- породить один элемент; +- использоваться совместно с соседними событиями; +- не попасть в документ, если оно не несёт полезной информации. + +Не пытайся создать отдельный элемент для каждого события. + +================================================== +FUTURE +================================================== + +`future` предоставлен ТОЛЬКО для понимания того, продолжается ли мысль дальше. + +КРИТИЧЕСКОЕ ПРАВИЛО: + +НЕ создавай элементы документа на основе содержимого `future`. + +НЕ используй `future` как источник фактов для текущих элементов. + +НЕ создавай `image` для события из `future`. + +Ты можешь только прочитать `future`, чтобы понять, закончена ли мысль внутри `present`. + +Если мысль начинается в `present`, но явно продолжается в `future`, НЕ создавай незаконченный элемент. + +Вместо этого сохрани её в `context.pending`. + +================================================== +СТИЛЬ БУДУЩЕГО ДОКУМЕНТА +================================================== + +Документ должен быть похож на качественный конспект лекции. + +Предпочитай: + +- ясные законченные предложения; +- компактные абзацы; +- логичную иерархию; +- списки вместо длинных перечислений; +- сохранение терминологии преподавателя; +- сохранение содержательных примеров. + +Удаляй: + +- слова-паразиты; +- бессодержательные повторы; +- повторное чтение одного и того же текста; +- технические проблемы конференции; +- перекличку; +- обсуждение присутствия студентов; +- случайные реплики; +- фразы, не несущие ценности для конспекта. + +Не удаляй существенную информацию только потому, что она сформулирована разговорно. + +Если одна мысль была произнесена несколько раз, обычно оставь одну наиболее полную и ясную формулировку. + +================================================== +ДОПУСТИМЫЕ ЭЛЕМЕНТЫ +================================================== + +Разрешены ТОЛЬКО следующие типы. + +-------------------------------------------------- +1. heading +-------------------------------------------------- + +{ + "type": "heading", + "level": 2, + "text": "Основы системного анализа" +} + +Допустимые уровни: + +2 — крупный раздел; +3 — подраздел; +4 — небольшой локальный подраздел. + +Не используй level 1. + +Не создавай заголовок для каждого небольшого абзаца. + +Заголовок должен отражать реально обсуждаемую тему, а не придуманную тобой новую классификацию. + +-------------------------------------------------- +2. paragraph +-------------------------------------------------- + +{ + "type": "paragraph", + "text": "Системный анализ используется для исследования сложных систем в условиях неопределённости." +} + +Один `paragraph` должен содержать одну законченную мысль или небольшой связный набор мыслей. + +Не создавай огромные параграфы. + +Если материал естественно представляет перечисление — используй список. + +-------------------------------------------------- +3. unordered +-------------------------------------------------- + +{ + "type": "unordered", + "items": [ + "Повышение качества.", + "Снижение себестоимости.", + "Повышение надёжности." + ] +} + +Используй, когда несколько пунктов однородны, но их порядок несущественен. + +-------------------------------------------------- +4. ordered +-------------------------------------------------- + +{ + "type": "ordered", + "items": [ + "Определить цель исследования.", + "Построить модель объекта.", + "Рассмотреть альтернативные решения." + ] +} + +Используй, когда: + +- порядок элементов важен; +- преподаватель явно использует нумерацию; +- перечисляются этапы; +- перечисляются принципы; +- перечисляются последовательные действия. + +-------------------------------------------------- +5. definition +-------------------------------------------------- + +{ + "type": "definition", + "term": "Системный анализ", + "text": "Совокупность методологических средств..." +} + +Используй только для настоящих определений терминов или понятий. + +Не превращай обычное объяснение в `definition`. + +-------------------------------------------------- +6. important +-------------------------------------------------- + +{ + "type": "important", + "text": "Цели частей системы не должны противоречить общей цели системы." +} + +Используй только для действительно важных утверждений: + +- преподаватель явно делает акцент; +- правило принципиально важно; +- материал явно требуется запомнить; +- говорится о важном требовании или ограничении. + +Не злоупотребляй `important`. + +-------------------------------------------------- +7. image +-------------------------------------------------- + +{ + "type": "image", + "event_id": 54 +} + +Используй ТОЛЬКО для события с `type = "vis"`. + +`event_id` должен быть реальным ID соответствующего `vis`-события. + +Добавляй изображение только если оно действительно полезно для понимания материала. + +Не добавляй изображение только потому, что `vis`-событие существует. + +Если изображение: +- является схемой; +- графиком; +- диаграммой; +- рисунком; +- визуально важным слайдом; +- объектом, который трудно полноценно выразить текстом; + +то обычно его следует включить. + +Не описывай путь к изображению самостоятельно. +Renderer получит его позже по `event_id`. + +================================================== +VOICE +================================================== + +Для `voice` основным источником информации является поле `text`. + +Речь разрешается перерабатывать в нормальный письменный текст. + +Например: + +"Ну вот смотрите мы с вами получается сейчас рассмотрели три задачи анализа" + +может стать: + +"Были рассмотрены три основные задачи анализа." + +Но нельзя добавлять новые сведения или менять смысл. + +================================================== +OCR +================================================== + +Для `ocr` используй прежде всего `payload`. + +OCR-текст может быть: + +- определением; +- формулой; +- списком; +- заголовком; +- таблицей; +- обычным текстом. + +Преобразуй его в подходящие элементы документа. + +Если преподаватель читает вслух тот же текст, который содержится в OCR, НЕ дублируй информацию. + +Объедини оба источника в одну нормальную формулировку. + +Если OCR и речь немного различаются, не пытайся самостоятельно угадывать, какой вариант фактически правильный, если это нельзя определить из контекста. + +================================================== +VIS +================================================== + +`vis` содержит уже найденное и проверенное изображение. + +Поле `text` помогает понять, что находится на изображении. + +Не копируй описание изображения как отдельный paragraph без необходимости. + +Обычно `vis` используется для принятия решения: + +нужно ли добавить + +{ + "type": "image", + "event_id": ... +} + +в структуру документа. + +Размещай `image` рядом с текстом, к которому изображение относится. + +================================================== +СОВМЕСТНОЕ ИСПОЛЬЗОВАНИЕ ИСТОЧНИКОВ +================================================== + +События разных типов могут описывать одну и ту же часть лекции. + +Например: + +voice: +"Посмотрите на график зависимости температуры..." + +vis: +"График зависимости температуры от времени." + +ocr: +"Температура, °C / Время, с" + +Не создавай три независимых повторяющихся фрагмента. + +Вместо этого может получиться: + +- paragraph с объяснением; +- image с соответствующим `event_id`. + +Используй разные источники совместно для построения одной логической части документа. + +================================================== +ГРАНИЦЫ ОКНА +================================================== + +Элемент можно создавать только если его смысл завершён внутри `present`. + +Пример: + +present: +"Первый принцип системного анализа заключается в..." + +future: +"...необходимости чётко определить цель." + +НЕ создавай: + +{ + "type": "paragraph", + "text": "Первый принцип системного анализа заключается в..." +} + +Сохрани незавершённую мысль в `context.pending`. + +На следующей итерации она попадёт в `past`, а продолжение — в `present`. + +Тогда можно создать законченный элемент. + +================================================== +CONTEXT +================================================== + +`context` — очень маленькая рабочая память между окнами. + +Используй строго такую структуру: + +{ + "current_section": null, + "current_subsection": null, + "recent_headings": [], + "pending": null +} + +`current_section`: +- текущий заголовок уровня 2; +- строка или null; +- максимум 120 символов. + +`current_subsection`: +- текущий заголовок уровня 3 или 4; +- строка или null; +- максимум 120 символов. + +`recent_headings`: +- последние заголовки документа; +- максимум 6 строк; +- используется только чтобы не создавать повторяющиеся или нелогичные заголовки. + +`pending`: +- незаконченная мысль на границе окна; +- null, если такой мысли нет. + +Формат `pending`: + +{ + "event_ids": [40, 41], + "summary": "Началось объяснение второго принципа системного анализа, продолжение находится в следующем окне." +} + +`pending.event_ids`: +- должны относиться к событиям из текущего `present`; +- эти события не должны быть полностью оформлены в готовые элементы. + +`pending.summary`: +- максимум 180 символов; +- краткая подсказка для следующей итерации; +- не является частью итогового документа. + +На следующей итерации `pending` используется только как подсказка. + +Фактические события имеют больший приоритет. + +Не превращай `context` в конспект. +Не сохраняй там подробное содержание лекции. +Не копируй туда длинные фрагменты текста. + +================================================== +ЗАГОЛОВКИ МЕЖДУ ОКНАМИ +================================================== + +Перед созданием нового заголовка учитывай: + +- `current_section`; +- `current_subsection`; +- `recent_headings`; +- содержимое `past`. + +Не повторяй существующий заголовок только потому, что новая итерация началась посередине того же раздела. + +Если текущая тема продолжается — продолжай создавать содержательные элементы без нового heading. + +================================================== +ПОРЯДОК ЭЛЕМЕНТОВ +================================================== + +Все элементы должны идти в том порядке, в котором читатель должен увидеть их в документе. + +Обычно: + +heading +paragraph +image +paragraph +list + +или другая логичная последовательность. + +Хронология лекции является основой порядка, но внутри одной небольшой смысловой части разрешается разместить изображение непосредственно рядом с относящимся к нему текстом. + +================================================== +OUTPUT +================================================== + +Верни РОВНО один валидный JSON: + +{ + "elements": [ + { + "type": "heading", + "level": 2, + "text": "..." + }, + { + "type": "paragraph", + "text": "..." + } + ], + "context": { + "current_section": null, + "current_subsection": null, + "recent_headings": [], + "pending": null + } +} + +Никакого Markdown. +Никаких ```json. +Никакого текста перед JSON. +Никакого текста после JSON. +Никаких комментариев. +Никаких неизвестных типов элементов. + +Если в текущем `present` нет материала, достойного добавления в итоговый документ: + +{ + "elements": [], + "context": { + ... + } +} + +— это корректный результат. + +================================================== +ПРОВЕРКА ПЕРЕД ОТВЕТОМ +================================================== + +Перед ответом проверь: + +1. Все элементы используют только разрешённые типы. +2. Каждый `image.event_id` существует во входе. +3. Каждый `image.event_id` относится к событию типа `vis`. +4. Ни один image не создан для события из `future`. +5. Содержимое `future` не было использовано как материал готового элемента. +6. Содержательная мысль не оборвана на границе окна. +7. Незаконченная мысль сохранена в `context.pending`. +8. Старый `pending`, если он завершился, обработан с учётом реальных событий. +9. Одинаковая информация из voice/OCR/vis не продублирована без причины. +10. Заголовки не создаются заново только из-за начала нового окна. +11. Ты не добавил фактов, отсутствующих во входных событиях. +12. `context` остаётся коротким. +13. Ответ является валидным JSON. \ No newline at end of file diff --git a/structure_builder.py b/structure_builder.py new file mode 100644 index 0000000..d87b15f --- /dev/null +++ b/structure_builder.py @@ -0,0 +1,236 @@ +import json +import os +import traceback +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from agent import Agent, AgentMessage +from utils import Event, Timeline +from windowizer import Window, Windowizer + + +class _StrictModel(BaseModel): + """Base model for schemas returned by the agent.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + +class HeadingElement(_StrictModel): + """A document section heading.""" + + type: Literal["heading"] + level: Literal[2, 3, 4] + text: str = Field(min_length=1) + + +class ParagraphElement(_StrictModel): + """A paragraph containing one complete thought.""" + + type: Literal["paragraph"] + text: str = Field(min_length=1) + + +class UnorderedListElement(_StrictModel): + """A list whose item order is not significant.""" + + type: Literal["unordered"] + items: list[str] = Field(min_length=1) + + @model_validator(mode="after") + def validate_items(self) -> "UnorderedListElement": + if any(not item.strip() for item in self.items): + raise ValueError("List items must not be empty") + return self + + +class OrderedListElement(_StrictModel): + """A list whose item order is significant.""" + + type: Literal["ordered"] + items: list[str] = Field(min_length=1) + + @model_validator(mode="after") + def validate_items(self) -> "OrderedListElement": + if any(not item.strip() for item in self.items): + raise ValueError("List items must not be empty") + return self + + +class DefinitionElement(_StrictModel): + """A definition of a term or concept.""" + + type: Literal["definition"] + term: str = Field(min_length=1) + text: str = Field(min_length=1) + + +class ImportantElement(_StrictModel): + """An important statement that should stand out in the document.""" + + type: Literal["important"] + text: str = Field(min_length=1) + + +class ImageElement(_StrictModel): + """A reference to an image stored in a visual timeline event.""" + + type: Literal["image"] + event_id: int + + +StructureElement = Annotated[ + HeadingElement + | ParagraphElement + | UnorderedListElement + | OrderedListElement + | DefinitionElement + | ImportantElement + | ImageElement, + Field(discriminator="type"), +] + + +class Structure(_StrictModel): + """Structure of the future Markdown document.""" + + elements: list[StructureElement] + + +class _Pending(_StrictModel): + """An unfinished thought carried over to the next window.""" + + event_ids: list[int] = Field(min_length=1) + summary: str = Field(min_length=1, max_length=180) + + @model_validator(mode="after") + def validate_event_ids(self) -> "_Pending": + if len(self.event_ids) != len(set(self.event_ids)): + raise ValueError("pending.event_ids must not contain duplicates") + return self + + +class _BuilderContext(_StrictModel): + """Small amount of document state preserved between agent calls.""" + + current_section: str | None = Field(default=None, max_length=120) + current_subsection: str | None = Field(default=None, max_length=120) + recent_headings: list[str] = Field(default_factory=list, max_length=6) + pending: _Pending | None = None + + @model_validator(mode="after") + def validate_recent_headings(self) -> "_BuilderContext": + if any(not heading.strip() or len(heading) > 120 for heading in self.recent_headings): + raise ValueError("recent_headings must contain non-empty strings up to 120 characters") + return self + + +class _BuildResult(_StrictModel): + """Result returned by the agent for a single window.""" + + elements: list[StructureElement] + context: _BuilderContext + + +class StructureBuilder: + """Build a document structure from a timeline of lecture events.""" + + DEBUG_ID = 0 + MAX_RETRIES = 5 + + def __init__(self, agent: Agent, windowizer: Windowizer[Event]) -> None: + """Create a structure builder. + + Args: + agent: Agent used to transform timeline windows. + windowizer: Windowizer used to split timeline events. + """ + self._agent = agent + self._windowizer = windowizer + with open("prompts/structure_builder.md", "r", encoding="utf-8") as f: + self._system_prompt = AgentMessage(content=f.read(), role="system") + + @staticmethod + def _validate_result(result: _BuildResult, window: Window[Event]) -> None: + """Validate constraints that depend on the current input window.""" + usable_visual_ids = { + event.id + for event in window.past + window.present + if event.type == "vis" + } + for element in result.elements: + if isinstance(element, ImageElement) and element.event_id not in usable_visual_ids: + raise ValueError( + "image.event_id must reference a vis event from past or present" + ) + + if result.context.pending is None: + return + + window_ids = { + event.id + for event in window.past + window.present + window.future + } + invalid_pending_ids = ( + set(result.context.pending.event_ids) - window_ids + ) + if invalid_pending_ids: + raise ValueError( + "pending.event_ids must reference events from the current window" + ) + + def _build_window(self, window: Window[Event]) -> _BuildResult: + """Build document elements from a single timeline window.""" + messages = [ + self._system_prompt, + AgentMessage( + content=json.dumps( + window.model_dump(mode="json"), + indent=2, + ensure_ascii=False, + ), + role="user", + ), + ] + + debug_dir = None + if os.path.isdir("debug"): + debug_dir = f"debug/StructureBuilder/{StructureBuilder.DEBUG_ID}" + StructureBuilder.DEBUG_ID += 1 + os.makedirs(debug_dir, exist_ok=True) + with open(f"{debug_dir}/request.txt", "w", encoding="utf-8") as f: + f.write(messages[1].content) + + retries_left = self.MAX_RETRIES + while retries_left > 0: + retries_left -= 1 + response = self._agent.completion(messages=messages) + if debug_dir: + with open( + f"{debug_dir}/{retries_left}-retries-left.txt", + "w", + encoding="utf-8", + ) as f: + f.write(response) + + try: + result = _BuildResult.model_validate_json(response) + self._validate_result(result, window) + return result + except (ValidationError, ValueError, TypeError): + traceback.print_exc() + + raise RuntimeError("Agent has failed to provide valid schema too many times") + + def build(self, timeline: Timeline) -> Structure: + """Build the ordered structure of a future Markdown document.""" + elements: list[StructureElement] = [] + context = _BuilderContext() + + for window in self._windowizer.windowize(timeline.events): + window.context = context.model_dump(mode="json") + intermediate = self._build_window(window) + elements.extend(intermediate.elements) + context = intermediate.context + + return Structure(elements=elements)