diff --git a/.gitignore b/.gitignore index d3f5a12..c323cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ - +.venv diff --git a/ege-checker-roadmap.pdf b/ege-checker-roadmap.pdf new file mode 100644 index 0000000..2e50d31 Binary files /dev/null and b/ege-checker-roadmap.pdf differ diff --git a/ege-checker/SKILL.md b/ege-checker/SKILL.md new file mode 100644 index 0000000..eeee0f8 --- /dev/null +++ b/ege-checker/SKILL.md @@ -0,0 +1,222 @@ +--- +name: ege-checker +description: > + Проверка заданий ЕГЭ с выставлением баллов по официальным критериям ФИПИ. + Используй этот скилл когда пользователь: + - Загружает фото/скан рукописного сочинения ЕГЭ по русскому языку + - Просит проверить сочинение ЕГЭ, выставить баллы, оценить работу + - Загружает аудиозапись или текст ответов по разделу "Аудирование" ЕГЭ по английскому + - Упоминает "ЕГЭ", "сочинение", "критерии К1-К12", "аудирование английский" + - Просит разобрать ошибки в сочинении по критериям ФИПИ + Скилл умеет: OCR рукописного текста через vision (multimodal), оценку по критериям, подробные комментарии. Работает с любой multimodal LLM (Qwen, GPT-4o и др.). +--- + +# ЕГЭ-Checker + +## Обзор возможностей + +| Модуль | Статус | Вход | Выход | +|--------|--------|------|-------| +| Сочинение (русский) | ✅ MVP | Фото рукописи или текст | Баллы К1–К12 + комментарии | +| Аудирование (английский) | ✅ MVP | Аудиофайл + ключи | Баллы по заданиям | + +--- + +## Модуль 1: Сочинение ЕГЭ по русскому языку + +### Что получает агент от проверяющего +Проверяющий присылает в одном сообщении: +1. **Сканы бланков** — фото рукописного сочинения (один или несколько листов) +2. **Исходный текст задания** — публицистический/художественный текст, по которому написано сочинение +3. **Тему/формулировку задания** — если есть отдельно (иногда тема вытекает из текста) + +Без исходного текста невозможно проверить К1, К2, К3 и К12. Если текст не прислан — попроси его перед началом проверки. + +### Шаг 1 — Получить текст сочинения + +**Если загружено фото/скан:** +1. Внимательно рассмотри все изображения — бланки могут быть на нескольких листах +2. Распознай рукописный текст максимально точно, сохраняя абзацное деление +3. Выведи распознанный текст и попроси подтвердить: *"Вот что распознано. Если есть неточности — поправьте перед проверкой."* +4. Дождись подтверждения или правок + +**Если текст вставлен напрямую:** +Переходи к Шагу 2 без распознавания. + +### Шаг 2 — Загрузить критерии + +Прочитай файл `references/russian-essay-criteria.md` — там полные критерии К1–К12 с баллами и примерами. + +### Шаг 3 — Оценить по каждому критерию + +Пройдись по всем критериям К1–К12 **последовательно**. Для каждого: +- Процитируй конкретный фрагмент из сочинения (если уместно) +- Объясни своё решение +- Выставь балл + +**Важные правила:** +- Если К1 = 0 (нет формулировки проблемы) → К2, К3, К4 автоматически = 0 +- Если работа ≤ 70 слов → все баллы = 0, только К10 может быть ненулевым +- Считай слова перед проверкой (служебные части речи считаются) + +### Шаг 4 — Итоговый вывод + +Используй этот формат вывода: + +``` +## Результаты проверки сочинения ЕГЭ + +### Распознанный текст +[текст если было фото, иначе пропусти] + +### Количество слов: XX + +--- + +### Оценка по критериям + +| Критерий | Название | Балл | Макс | +|----------|----------|------|------| +| К1 | Формулировка проблем исходного текста | X | 1 | +| К2 | Комментарий к проблеме | X | 6 | +| К3 | Отражение позиции автора | X | 1 | +| К4 | Отношение к позиции автора | X | 1 | +| К5 | Смысловая цельность, связность | X | 2 | +| К6 | Точность и выразительность речи | X | 2 | +| К7 | Орфография | X | 3 | +| К8 | Пунктуация | X | 3 | +| К9 | Языковые нормы | X | 2 | +| К10 | Речевые нормы | X | 2 | +| К11 | Этические нормы | X | 1 | +| К12 | Фактическая точность | X | 1 | +| **ИТОГО** | | **XX** | **25** | + +--- + +### Подробные комментарии + +**К1 — [балл/1]** +[объяснение] + +**К2 — [балл/6]** +[объяснение с цитатами из текста] + +... и так далее по каждому критерию ... + +--- + +### Главные рекомендации +1. [самое важное для улучшения] +2. ... +``` + +--- + +## Модуль 2: Аудирование ЕГЭ по английскому + +### Что получает агент от проверяющего +Проверяющий присылает в одном сообщении: +1. **Аудиозапись** — файл с устными ответами ученика (MP3, WAV, M4A и т.д.) +2. **Правильные ответы (ключи)** — текстом или фото бланка с ключами + +### Шаг 1 — Сохранить аудиофайл во временную директорию + +Сохрани полученный аудиофайл во временный путь `/tmp/ege_audio_.`. + +```python +import os, time + +ext = os.path.splitext(original_filename)[1] or ".mp3" +tmp_path = f"/tmp/ege_audio_{int(time.time())}{ext}" +with open(tmp_path, "wb") as f: + f.write(audio_bytes) +``` + +### Шаг 2 — Запустить recognition.py + +**Через subprocess (рекомендуется):** +```bash +python3 ~/zeroclaw-bot/recognition.py {tmp_path} --output json +``` + +**Через прямой импорт:** +```python +import sys +sys.path.insert(0, os.path.expanduser("~/zeroclaw-bot")) +from recognition import process_audio + +transcript, answers = process_audio(tmp_path, model_size="medium", verbose=False) +recognition_json = { + "transcript": transcript.text, + "answers": answers.to_dict(), + "unrecognized": answers.unrecognized, +} +``` + +Результат — JSON: +- `transcript` — полный текст того, что сказал ученик +- `answers.task1` — {"A": "3", "B": "1", ...} +- `answers.task2_9` — {"2": "1", "3": "2", ...} +- `answers.task10_18` — {"10": "2", "11": "1", ...} +- `answers.unrecognized` — задания, которые не удалось извлечь (засчитать как 0) + +### Шаг 3 — Получить и распознать ключи +Если ключи пришли текстом — использовать напрямую. +Если пришло фото бланка с ключами — распознать через vision. + +### Шаг 4 — Передать в LLM для сверки и выставления баллов + +Сформируй промпт для LLM на основе результатов recognition.py. +Подставь реальные значения вместо плейсхолдеров: + +``` +Ты эксперт-проверяющий ЕГЭ по английскому (аудирование). + +Транскрипт ответов ученика (распознан через Whisper): +[TRANSCRIPT] + +Извлечённые ответы ученика: +- Задание 1 (A-F): [TASK1] +- Задания 2-9: [TASK2_9] +- Задания 10-18: [TASK10_18] +- Не распознаны (засчитать как 0): [UNRECOGNIZED] + +Правильные ответы (ключи): +[KEYS] + +Сверь ответы с ключами. 1 балл за совпадение, 0 — за несовпадение или отсутствие. +Верни строго JSON без markdown: +{"results":{"task1":{"A":true},"task2_9":{"2":true},"task10_18":{"10":false}}, + "scores":{"task1":0,"task2_9":0,"task10_18":0,"total":0}, + "errors":["Задание 1B: ученик ответил 5, верный ответ 1"]} +``` + +Читай `references/english-listening-criteria.md` для максимальных баллов каждого блока. + +### Шаг 5 — Удалить временный файл + +Обязательно удали аудиофайл после получения ответа от LLM: + +```python +import os +if os.path.exists(tmp_path): + os.remove(tmp_path) +``` + +### Шаг 6 — Вывести результат пользователю + +Три блока (Задание 1 / Задания 2-9 / Задания 10-18): +колонки — задание, ответ ученика, ключ, результат (✓/✗). +Итог: баллы по каждому блоку + общий итог из 23. + +Если были нераспознанные задания — явно указать: +"Задания X, Y не были распознаны в аудио и засчитаны как неверные." +--- + +## Общие принципы проверки + +- **Актуальность**: Критерии соответствуют демоверсии ФИПИ 2024–2025 +- **Строгость**: Придерживайся формулировок критериев, не занижай и не завышай +- **Пограничные случаи**: При сомнении между баллами — объясни оба варианта и выбери более обоснованный +- **Тон**: Конструктивный, поддерживающий. Это учебный инструмент, не карательный +- **Совместимость**: Скилл написан без привязки к конкретной модели. Работает с любым multimodal агентом, поддерживающим vision и аудио (Qwen3, GPT-4o, и др.) diff --git a/ege-checker/references/english-listening-criteria.md b/ege-checker/references/english-listening-criteria.md new file mode 100644 index 0000000..33d29b3 --- /dev/null +++ b/ege-checker/references/english-listening-criteria.md @@ -0,0 +1,30 @@ +# Критерии оценивания раздела "Аудирование" ЕГЭ по английскому языку +## Статус: заглушка — будет заполнено в следующей итерации + +### Структура раздела (ЕГЭ 2024–2025) + +**Задание 1** (B1) — установление соответствия: 6 высказываний → 7 утверждений +- 1 балл за каждое верное соответствие +- Максимум: 6 баллов + +**Задания 2–9** (B2) — верно/неверно/не сказано (True/False/Not Stated) +- 1 балл за каждый верный ответ +- Максимум: 8 баллов + +**Задания 10–18** — краткий ответ/выбор из нескольких вариантов +- 1 балл за каждый верный ответ +- Максимум: 9 баллов + +**Итого по разделу "Аудирование": 20 первичных баллов** + +--- + +## STT интеграция (планируется) + +Для обработки аудиозаписей потребуется: +- OpenAI Whisper API (рекомендуется для русской и английской речи) +- Или Deepgram (альтернатива) +- Эндпоинт: `https://api.openai.com/v1/audio/transcriptions` +- Модель: `whisper-1` + +Временное решение для MVP: попросить пользователя ввести ответы текстом. diff --git a/ege-checker/references/russian-essay-criteria.md b/ege-checker/references/russian-essay-criteria.md new file mode 100644 index 0000000..d3317b5 --- /dev/null +++ b/ege-checker/references/russian-essay-criteria.md @@ -0,0 +1,206 @@ +# Критерии оценивания сочинения ЕГЭ по русскому языку +## Источник: ФИПИ, демоверсия 2024–2025 + +--- + +## Предварительные проверки + +### Подсчёт слов +- Считаются все слова, включая служебные (предлоги, союзы, частицы) +- Если слов **меньше 70** → все критерии = 0 (кроме К10) +- Если слов **70–149** → рекомендуемый объём не выдержан, но работа проверяется +- Рекомендуемый объём: **150–300 слов** + +### Правило нулевого К1 +Если К1 = 0 (проблема не сформулирована или сформулирована неверно): +→ К2 = 0, К3 = 0, К4 = 0 **автоматически** + +--- + +## К1 — Формулировка проблем исходного текста (0–1 балл) + +**1 балл**: Экзаменуемый (в той или иной форме) верно сформулировал одну из проблем исходного текста. Фактических ошибок, связанных с пониманием и формулировкой проблемы, нет. + +**0 баллов**: Экзаменуемый не смог верно сформулировать ни одну из проблем исходного текста. + +**Подсказки для проверки:** +- Проблема должна быть сформулирована как вопрос или тезис +- Она должна соответствовать содержанию исходного текста +- Допустимы разные формулировки одной и той же проблемы + +--- + +## К2 — Комментарий к сформулированной проблеме исходного текста (0–6 баллов) + +Структура: **2 примера-иллюстрации** из текста + **смысловая связь** между ними. + +| Баллы | Условия | +|-------|---------| +| 6 | Оба примера с пояснениями + связь с анализом | +| 5 | Оба примера с пояснениями + связь без анализа | +| 4 | Оба примера с пояснениями, связь не указана | +| 3 | Один пример с пояснением + связь | +| 2 | Оба примера без пояснений + связь | +| 1 | Один пример без пояснения | +| 0 | Примеры отсутствуют или текст пересказан | + +**Важно:** +- Пример-иллюстрация = конкретный фрагмент/факт из текста с объяснением его роли +- Смысловая связь: сравнение, противопоставление, причина-следствие, вывод и т.д. +- Пересказ без анализа = 0 баллов +- Фактические ошибки в комментарии снижают балл (−1 за каждую, но не более −2) + +--- + +## К3 — Отражение позиции автора исходного текста (0–1 балл) + +**1 балл**: Позиция автора верно сформулирована. Фактических ошибок нет. + +**0 баллов**: Позиция автора не сформулирована или сформулирована неверно. + +**Подсказки:** +- Позиция = ответ автора на поставленную проблему +- Должна быть явно выражена, не просто угадана +- Можно цитировать или пересказывать + +--- + +## К4 — Отношение к позиции автора по проблеме исходного текста (0–1 балл) + +**1 балл**: Экзаменуемый выразил своё отношение к позиции автора и обосновал его. + +**0 баллов**: Экзаменуемый не выразил своё отношение или оно не обосновано. + +**Подсказки:** +- Формальное согласие/несогласие без обоснования = 0 +- Обоснование: личный опыт, примеры из жизни/литературы, логические доводы +- Фраза "я согласен с автором" без аргумента = 0 + +--- + +## К5 — Смысловая цельность, речевая связность и последовательность изложения (0–2 балла) + +**2 балла**: Работа характеризуется смысловой цельностью, речевой связностью и последовательностью изложения. Логических ошибок нет. Абзацное членение присутствует. + +**1 балл**: 1–2 логические ошибки ИЛИ нарушено абзацное членение. + +**0 баллов**: 3+ логические ошибки ИЛИ отсутствует абзацное членение. + +**Типичные логические ошибки:** +- Нарушение последовательности мысли +- Отсутствие связи между абзацами +- Повтор одной мысли разными словами +- Противоречия в тексте + +--- + +## К6 — Точность и выразительность речи (0–2 балла) + +**2 балла**: Работа характеризуется точностью выражения мысли, разнообразием грамматического строя речи. + +**1 балл**: Работа характеризуется точностью выражения мысли, но однообразием грамматического строя речи ИЛИ выразительностью, но использованием неточных слов. + +**0 баллов**: Точность и выразительность речи существенно снижены. + +**Примечание:** К6 не может быть выше К10. Если К10 = 0, то К6 = 0. + +--- + +## К7 — Соблюдение орфографических норм (0–3 балла) + +| Баллы | Количество ошибок | +|-------|-------------------| +| 3 | 0 ошибок | +| 2 | 1 ошибка | +| 1 | 2–3 ошибки | +| 0 | 4+ ошибки | + +**Что считается ошибкой:** +- Неверное написание слова +- Слитное/раздельное/дефисное написание +- Прописные/строчные буквы + +**Что НЕ считается ошибкой:** +- Описки (если исправлены) +- Перенос слова + +--- + +## К8 — Соблюдение пунктуационных норм (0–3 балла) + +| Баллы | Количество ошибок | +|-------|-------------------| +| 3 | 0 ошибок | +| 2 | 1–2 ошибки | +| 1 | 3–4 ошибки | +| 0 | 5+ ошибок | + +**Типичные ошибки:** +- Пропуск запятой при однородных членах +- Ошибки в сложных предложениях +- Неверное оформление прямой речи + +--- + +## К9 — Соблюдение языковых норм (грамматика) (0–2 балла) + +| Баллы | Количество ошибок | +|-------|-------------------| +| 2 | 0 ошибок | +| 1 | 1–2 ошибки | +| 0 | 3+ ошибки | + +**Типичные грамматические ошибки:** +- Ошибки в согласовании (управлении) +- Неправильное образование форм слова +- Нарушение синтаксических норм + +--- + +## К10 — Соблюдение речевых норм (0–2 балла) + +| Баллы | Количество ошибок | +|-------|-------------------| +| 2 | 0–1 ошибка | +| 1 | 2–3 ошибки | +| 0 | 4+ ошибки | + +**Типичные речевые ошибки:** +- Тавтология (повтор одного слова/однокоренных слов) +- Плеоназм (масло масляное) +- Неуместное употребление слова +- Употребление слова в несвойственном значении + +--- + +## К11 — Соблюдение этических норм (0–1 балл) + +**1 балл**: Этические ошибки отсутствуют. + +**0 баллов**: Допущена 1+ этическая ошибка. + +**Этические ошибки:** +- Грубость, оскорбления в адрес кого-либо +- Национальная/религиозная нетерпимость +- Речевая агрессия + +--- + +## К12 — Соблюдение фактологической точности (0–1 балл) + +**1 балл**: Фактические ошибки отсутствуют. + +**0 баллов**: Допущена 1+ фактическая ошибка. + +**Фактические ошибки:** +- Неверные имена, даты, названия произведений +- Искажение содержания упомянутых текстов +- Неверные факты о реальных событиях/людях + +--- + +## Таблица максимальных баллов + +| К1 | К2 | К3 | К4 | К5 | К6 | К7 | К8 | К9 | К10 | К11 | К12 | Итого | +|----|----|----|----|----|----|----|----|----|-----|-----|-----|-------| +| 1 | 6 | 1 | 1 | 2 | 2 | 3 | 3 | 2 | 2 | 1 | 1 | **25** | diff --git a/ege.skill b/ege.skill deleted file mode 100644 index 2d6c976..0000000 Binary files a/ege.skill and /dev/null differ diff --git a/local-testers/english-listening-checker.html b/local-testers/english-listening-checker.html new file mode 100644 index 0000000..d886a8c --- /dev/null +++ b/local-testers/english-listening-checker.html @@ -0,0 +1,563 @@ + + + + + +ЕГЭ Английский — Аудирование + + + +
+ +

ЕГЭ — Аудирование (Английский язык)

+

Загрузи аудиозапись ответов ученика → получи транскрипт → сверь с ключами → баллы

+ + +
+
Настройки API
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
1
+
+
Загрузи аудиозапись ответов ученика
+
+
🎤
+
Нажми или перетащи аудиофайл
+
MP3, WAV, M4A, OGG, WEBM — до 25 МБ
+ +
+ +
+
+
+ + +
+
+
2
+
+
Введи правильные ответы (ключи)
+

Заполни ключи для заданий, которые есть в варианте. Пустые задания игнорируются.

+ +
+ ЗаданиеКлюч (верный)— пусто — +
+ +
Задание 1 — Соответствие (A–F → цифра 1–7)
+
+ +
+ +
Задания 2–9 — True(1) / False(2) / Not Stated(3)
+
+ +
+ +
Задания 10–18 — Выбор из 3 вариантов (1/2/3)
+
+ +
+
+
+
+ + +
+ + +
+ + + + + +
+ +
+
Транскрипт ответов ученика
+
+
+
Распознанные ответы
+
+
+
+ +
+
Детальная проверка
+ +
+

Задание 1 — Установление соответствия

+ + + +
ВысказываниеОтвет ученикаКлючРезультат
+
+ +
+ +
+

Задания 2–9 — True / False / Not Stated

+ + + +
ЗаданиеОтвет ученикаКлючРезультат
+
+ +
+ +
+

Задания 10–18 — Выбор ответа

+ + + +
ЗаданиеОтвет ученикаКлючРезультат
+
+
+ +
+
+
Итоговый балл
+
+
из 23 первичных баллов
+
+
+
Ошибки
+
+
+
+ +
+ +
+ + + + diff --git a/ege_tester.html b/local-testers/russian-writing-checker.html similarity index 100% rename from ege_tester.html rename to local-testers/russian-writing-checker.html diff --git a/recognition.py b/recognition.py new file mode 100644 index 0000000..646ca40 --- /dev/null +++ b/recognition.py @@ -0,0 +1,503 @@ +""" +recognition.py — модуль распознавания аудиофайла с ответами ученика ЕГЭ (аудирование, английский язык). + +Зависимости: + pip install faster-whisper + +Использование: + from recognition import transcribe, extract_answers + + # Полный pipeline: аудио -> транскрипт -> структурированные ответы + result = transcribe("student_answers.mp3") + answers = extract_answers(result.text) + print(answers) +""" + +from __future__ import annotations + +import re +import logging +from dataclasses import dataclass, field +from pathlib import Path + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Константы +# --------------------------------------------------------------------------- + +# Модели faster-whisper по убыванию скорости / возрастанию качества: +# tiny, base, small, medium, large-v2, large-v3 +DEFAULT_MODEL = "medium" + +# Подсказка для Whisper — описывает формат ответов ученика. +# Критически важна для правильного распознавания "one/two/three" как цифр +# и "A equals 3" как ответа на задание 1. +WHISPER_PROMPT = ( + "Student answers to EGE English listening exam. " + "Task one matching: speaker A answer three, speaker B answer one, " + "speaker C answer five, speaker D answer seven, speaker E answer two, speaker F answer four. " + "Tasks two through nine True False Not Stated: " + "task two true, task three false, task four not stated. " + "Tasks ten through eighteen multiple choice one two or three: " + "task ten two, task eleven one, task twelve three." +) + + +# --------------------------------------------------------------------------- +# Структуры данных +# --------------------------------------------------------------------------- + +@dataclass +class TranscriptResult: + """Результат транскрипции аудиофайла.""" + text: str # Полный текст транскрипта + language: str # Определённый язык ("en") + duration_seconds: float # Длительность аудио в секундах + segments: list[dict] = field(default_factory=list) # Детальные сегменты с таймкодами + model_used: str = DEFAULT_MODEL + + +@dataclass +class StudentAnswers: + """Структурированные ответы ученика, извлечённые из транскрипта.""" + + # Задание 1: соответствие A–F → цифра 1–7 + task1: dict[str, str] = field(default_factory=dict) # {"A": "3", "B": "1", ...} + + # Задания 2–9: True(1) / False(2) / Not Stated(3) + task2_9: dict[int, str] = field(default_factory=dict) # {2: "1", 3: "2", ...} + + # Задания 10–18: выбор из вариантов 1/2/3 + task10_18: dict[int, str] = field(default_factory=dict) # {10: "2", 11: "1", ...} + + # Задания, которые не удалось распознать + unrecognized: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "task1": self.task1, + "task2_9": {str(k): v for k, v in self.task2_9.items()}, + "task10_18": {str(k): v for k, v in self.task10_18.items()}, + "unrecognized": self.unrecognized, + } + + def summary(self) -> str: + """Читаемое представление для вывода агенту / в лог.""" + lines = ["=== Распознанные ответы ученика ==="] + + if self.task1: + lines.append("\nЗадание 1 (соответствие):") + for letter in "ABCDEF": + ans = self.task1.get(letter, "?") + lines.append(f" {letter} → {ans}") + + if self.task2_9: + lines.append("\nЗадания 2–9 (True/False/Not Stated):") + labels = {"1": "True", "2": "False", "3": "Not Stated"} + for task_num in range(2, 10): + ans = self.task2_9.get(task_num, "?") + label = labels.get(ans, ans) + lines.append(f" Задание {task_num}: {ans} ({label})") + + if self.task10_18: + lines.append("\nЗадания 10–18 (выбор):") + for task_num in range(10, 19): + ans = self.task10_18.get(task_num, "?") + lines.append(f" Задание {task_num}: {ans}") + + if self.unrecognized: + lines.append(f"\nНе распознано: {', '.join(self.unrecognized)}") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Транскрипция +# --------------------------------------------------------------------------- + +def transcribe( + audio_path: str | Path, + model_size: str = DEFAULT_MODEL, + device: str = "auto", + compute_type: str = "auto", + language: str = "en", + beam_size: int = 5, +) -> TranscriptResult: + """ + Транскрибирует аудиофайл с ответами ученика. + + Args: + audio_path: Путь к аудиофайлу (MP3, WAV, M4A, OGG, WEBM, FLAC). + model_size: Размер модели Whisper: tiny/base/small/medium/large-v2/large-v3. + medium — хороший баланс скорость/качество для ЕГЭ. + large-v3 — максимальное качество, медленнее. + device: "auto" | "cpu" | "cuda". "auto" выберет GPU если доступен. + compute_type: "auto" | "int8" | "float16" | "float32". + "auto" подберёт оптимальный тип для устройства. + language: Язык аудио. "en" для ответов на английском. + beam_size: Ширина луча beam search. 5 — стандарт, выше = точнее но медленнее. + + Returns: + TranscriptResult с текстом, языком, длительностью и сегментами. + + Raises: + FileNotFoundError: Если аудиофайл не найден. + RuntimeError: Если faster-whisper не установлен. + """ + try: + from faster_whisper import WhisperModel + except ImportError: + raise RuntimeError( + "faster-whisper не установлен. Установите: pip install faster-whisper" + ) + + audio_path = Path(audio_path) + if not audio_path.exists(): + raise FileNotFoundError(f"Аудиофайл не найден: {audio_path}") + + # Автовыбор устройства и типа вычислений + resolved_device, resolved_compute = _resolve_device(device, compute_type) + + logger.info( + "Загрузка модели %s на %s (%s)...", + model_size, resolved_device, resolved_compute + ) + + model = WhisperModel( + model_size, + device=resolved_device, + compute_type=resolved_compute, + ) + + logger.info("Транскрибирую: %s", audio_path.name) + + segments_gen, info = model.transcribe( + str(audio_path), + language=language, + beam_size=beam_size, + initial_prompt=WHISPER_PROMPT, + word_timestamps=False, + vad_filter=True, # Фильтрация тишины — полезно для записей с паузами + vad_parameters={ + "min_silence_duration_ms": 500, # Паузы >0.5с считаются тишиной + "speech_pad_ms": 200, + }, + ) + + # Материализуем генератор сегментов + segments = [] + full_text_parts = [] + + for seg in segments_gen: + segments.append({ + "start": round(seg.start, 2), + "end": round(seg.end, 2), + "text": seg.text.strip(), + }) + full_text_parts.append(seg.text.strip()) + + full_text = " ".join(full_text_parts) + + logger.info( + "Транскрипция завершена. Длительность: %.1f сек, слов ~%d", + info.duration, len(full_text.split()) + ) + + return TranscriptResult( + text=full_text, + language=info.language, + duration_seconds=round(info.duration, 1), + segments=segments, + model_used=model_size, + ) + + +def _resolve_device(device: str, compute_type: str) -> tuple[str, str]: + """Определяет оптимальное устройство и тип вычислений.""" + if device != "auto" and compute_type != "auto": + return device, compute_type + + # Проверяем наличие CUDA + try: + from torch import cuda + has_cuda = cuda.is_available() + except ImportError: + has_cuda = False + + if device == "auto": + device = "cuda" if has_cuda else "cpu" + + if compute_type == "auto": + if device == "cuda": + compute_type = "float16" # GPU: float16 быстрее и точнее чем int8 + else: + compute_type = "int8" # CPU: int8 значительно быстрее float32 + + return device, compute_type + + +# --------------------------------------------------------------------------- +# Извлечение ответов из транскрипта +# --------------------------------------------------------------------------- + +def extract_answers(transcript_text: str) -> StudentAnswers: + """ + Извлекает структурированные ответы из текста транскрипта. + + Обрабатывает разные форматы речи ученика: + - "A three", "speaker A answer three", "A equals 3", "A — три" + - "task two true", "number two true", "two — true", "2 true" + - "task ten two", "ten — 2", "question ten answer two" + + Args: + transcript_text: Текст транскрипта от Whisper. + + Returns: + StudentAnswers со структурированными ответами. + """ + answers = StudentAnswers() + text = transcript_text.lower().strip() + + _extract_task1(text, answers) + _extract_task2_9(text, answers) + _extract_task10_18(text, answers) + + logger.debug("Извлечено ответов: task1=%d, task2_9=%d, task10_18=%d, нераспознано=%d", + len(answers.task1), len(answers.task2_9), + len(answers.task10_18), len(answers.unrecognized)) + + return answers + + +def _extract_task1(text: str, answers: StudentAnswers) -> None: + """ + Задание 1: соответствие A–F → цифра 1–7. + Примеры: "A three", "speaker A answer 3", "A equals three", "A — 3" + """ + # Числа словами → цифры + word_to_digit = { + "one": "1", "two": "2", "three": "3", "four": "4", + "five": "5", "six": "6", "seven": "7", + # На случай русских ответов + "один": "1", "два": "2", "три": "3", "четыре": "4", + "пять": "5", "шесть": "6", "семь": "7", + } + + for letter in "abcdef": + # Паттерн: буква, затем необязательный разделитель, затем цифра/слово + pattern = ( + rf"(?:speaker\s+)?{letter}" + rf"(?:\s+(?:answer|equals|is|—|-|:))?" + rf"\s+" + rf"({_digit_or_word_pattern(1, 7)})" + ) + match = re.search(pattern, text) + if match: + raw = match.group(1).strip() + digit = word_to_digit.get(raw, raw) if not raw.isdigit() else raw + if digit in [str(i) for i in range(1, 8)]: + answers.task1[letter.upper()] = digit + else: + answers.unrecognized.append(f"1{letter.upper()}") + else: + answers.unrecognized.append(f"1{letter.upper()}") + + +def _extract_task2_9(text: str, answers: StudentAnswers) -> None: + """ + Задания 2–9: True(1) / False(2) / Not Stated(3). + Примеры: "task two true", "number 3 false", "four not stated", "5 — 2" + """ + tfs_map = { + "true": "1", "1": "1", + "false": "2", "2": "2", + "not stated": "3", "not_stated": "3", "3": "3", + } + + num_words = { + "two": 2, "three": 3, "four": 4, "five": 5, + "six": 6, "seven": 7, "eight": 8, "nine": 9, + "2": 2, "3": 3, "4": 4, "5": 5, + "6": 6, "7": 7, "8": 8, "9": 9, + } + + for word, num in num_words.items(): + pattern = ( + rf"(?:task|number|question|задание)?\s*{re.escape(word)}" + rf"(?:\s+(?:is|answer|—|-|:))?" + rf"\s+" + rf"(true|false|not\s+stated|not_stated|[123])" + ) + match = re.search(pattern, text) + if match: + raw = match.group(1).strip().replace(" ", "_") + digit = tfs_map.get(raw) or tfs_map.get(raw.replace("_", " ")) + if digit: + answers.task2_9[num] = digit + else: + answers.unrecognized.append(str(num)) + else: + answers.unrecognized.append(str(num)) + + +def _extract_task10_18(text: str, answers: StudentAnswers) -> None: + """ + Задания 10–18: выбор из вариантов 1/2/3. + Примеры: "task ten two", "eleven — 1", "question 12 answer three" + """ + word_to_digit_choice = { + "one": "1", "two": "2", "three": "3", + "1": "1", "2": "2", "3": "3", + } + + num_words = { + "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, + "fourteen": 14, "fifteen": 15, "sixteen": 16, + "seventeen": 17, "eighteen": 18, + "10": 10, "11": 11, "12": 12, "13": 13, + "14": 14, "15": 15, "16": 16, "17": 17, "18": 18, + } + + for word, num in num_words.items(): + pattern = ( + rf"(?:task|number|question|задание)?\s*{re.escape(word)}" + rf"(?:\s+(?:is|answer|—|-|:))?" + rf"\s+" + rf"({_digit_or_word_pattern(1, 3)})" + ) + match = re.search(pattern, text) + if match: + raw = match.group(1).strip() + digit = word_to_digit_choice.get(raw) + if digit: + answers.task10_18[num] = digit + else: + answers.unrecognized.append(str(num)) + else: + answers.unrecognized.append(str(num)) + + +def _digit_or_word_pattern(min_val: int, max_val: int) -> str: + """Строит regex-паттерн для диапазона цифр и их словесных форм.""" + digits = [str(i) for i in range(min_val, max_val + 1)] + words = { + 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", + 6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten", + 11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen", + 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen", + } + options = digits + [words[i] for i in range(min_val, max_val + 1) if i in words] + return "(" + "|".join(sorted(options, key=len, reverse=True)) + ")" + + +# --------------------------------------------------------------------------- +# Удобный pipeline +# --------------------------------------------------------------------------- + +def process_audio( + audio_path: str | Path, + model_size: str = DEFAULT_MODEL, + device: str = "auto", + verbose: bool = False, +) -> tuple[TranscriptResult, StudentAnswers]: + """ + Полный pipeline: аудиофайл → транскрипт → структурированные ответы. + + Args: + audio_path: Путь к аудиофайлу. + model_size: Размер модели Whisper (medium по умолчанию). + device: "auto" | "cpu" | "cuda". + verbose: Если True — выводить промежуточные результаты в stdout. + + Returns: + Кортеж (TranscriptResult, StudentAnswers). + + Example: + result, answers = process_audio("student.mp3") + print(answers.summary()) + # Передать answers.to_dict() агенту для сверки с ключами + """ + if verbose: + print(f"[1/2] Транскрибирую {Path(audio_path).name} (модель: {model_size})...") + + transcript = transcribe(audio_path, model_size=model_size, device=device) + + if verbose: + print(f" Длительность: {transcript.duration_seconds} сек") + print(f" Транскрипт: {transcript.text[:120]}...") + print("[2/2] Извлекаю ответы...") + + answers = extract_answers(transcript.text) + + if verbose: + print(answers.summary()) + + return transcript, answers + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import argparse + import json + import sys + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + parser = argparse.ArgumentParser( + description="Распознавание аудиоответов ЕГЭ (аудирование, английский язык)" + ) + parser.add_argument("audio", help="Путь к аудиофайлу") + parser.add_argument( + "--model", default=DEFAULT_MODEL, + choices=["tiny", "base", "small", "medium", "large-v2", "large-v3"], + help=f"Размер модели Whisper (по умолчанию: {DEFAULT_MODEL})" + ) + parser.add_argument( + "--device", default="auto", + choices=["auto", "cpu", "cuda"], + help="Устройство для инференса (по умолчанию: auto)" + ) + parser.add_argument( + "--output", choices=["summary", "json", "transcript"], + default="summary", + help="Формат вывода: summary (читаемый), json (машинный), transcript (сырой текст)" + ) + parser.add_argument( + "--transcript-only", action="store_true", + help="Только транскрипция без извлечения ответов" + ) + + args = parser.parse_args() + + try: + if args.transcript_only or args.output == "transcript": + result = transcribe(args.audio, model_size=args.model, device=args.device) + print(result.text) + else: + result, answers = process_audio( + args.audio, + model_size=args.model, + device=args.device, + verbose=(args.output == "summary"), + ) + + if args.output == "json": + output = { + "transcript": result.text, + "language": result.language, + "duration_seconds": result.duration_seconds, + "model_used": result.model_used, + "answers": answers.to_dict(), + } + print(json.dumps(output, ensure_ascii=False, indent=2)) + elif args.output == "summary": + # verbose=True уже вывел всё в process_audio + pass + + except (FileNotFoundError, RuntimeError) as e: + print(f"Ошибка: {e}", file=sys.stderr) + sys.exit(1) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ab3ceb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +faster-whisper==1.2.1 +torch==2.11.0 diff --git a/tests/test-1/skan-1.jpg b/tests/russian/test1/skan-1.jpg similarity index 100% rename from tests/test-1/skan-1.jpg rename to tests/russian/test1/skan-1.jpg diff --git a/tests/test-1/skan-2.jpg b/tests/russian/test1/skan-2.jpg similarity index 100% rename from tests/test-1/skan-2.jpg rename to tests/russian/test1/skan-2.jpg