Compare commits
7 commits
55b4f4360a
...
f0ee3ecc28
| Author | SHA1 | Date | |
|---|---|---|---|
| f0ee3ecc28 | |||
| 95591c4933 | |||
| c78bcf4bf0 | |||
| 5db412e7f6 | |||
| 48a0c0088d | |||
| e8d07f3553 | |||
| 3558abd426 |
12 changed files with 1527 additions and 1 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1 @@
|
|||
|
||||
.venv
|
||||
|
|
|
|||
BIN
ege-checker-roadmap.pdf
Normal file
BIN
ege-checker-roadmap.pdf
Normal file
Binary file not shown.
222
ege-checker/SKILL.md
Normal file
222
ege-checker/SKILL.md
Normal file
|
|
@ -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_<timestamp>.<ext>`.
|
||||
|
||||
```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, и др.)
|
||||
30
ege-checker/references/english-listening-criteria.md
Normal file
30
ege-checker/references/english-listening-criteria.md
Normal file
|
|
@ -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: попросить пользователя ввести ответы текстом.
|
||||
206
ege-checker/references/russian-essay-criteria.md
Normal file
206
ege-checker/references/russian-essay-criteria.md
Normal file
|
|
@ -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** |
|
||||
BIN
ege.skill
BIN
ege.skill
Binary file not shown.
563
local-testers/english-listening-checker.html
Normal file
563
local-testers/english-listening-checker.html
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ЕГЭ Английский — Аудирование</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #f5f4f0;
|
||||
--surface: #ffffff;
|
||||
--border: #e0ddd6;
|
||||
--border2: #c8c5bc;
|
||||
--text: #1a1a18;
|
||||
--muted: #6b6a65;
|
||||
--accent: #2d5be3;
|
||||
--ok: #1a7a45;
|
||||
--ok-bg: #e8f5ee;
|
||||
--err: #b92c2c;
|
||||
--err-bg: #fdeaea;
|
||||
--warn: #8a5c00;
|
||||
--warn-bg: #fff8e6;
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.6; }
|
||||
.container { max-width: 860px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
||||
h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; }
|
||||
.subtitle { color: var(--muted); font-size: 13px; margin-bottom: 2rem; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; margin-bottom: 1rem; }
|
||||
.card-title { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
|
||||
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
input[type=text], input[type=password], textarea, select {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-size: 13px; font-family: inherit; background: var(--bg); color: var(--text);
|
||||
outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, textarea:focus { border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
|
||||
.field { margin-bottom: 12px; }
|
||||
button {
|
||||
padding: 9px 18px; border-radius: var(--radius-sm); border: 1px solid var(--border2);
|
||||
background: var(--surface); color: var(--text); font-size: 13px; font-family: inherit;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
button:hover { background: var(--bg); border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn-primary:hover { background: #1e4ac9; color: #fff; border-color: #1e4ac9; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border2); border-radius: var(--radius); padding: 2rem;
|
||||
text-align: center; cursor: pointer; color: var(--muted); font-size: 13px;
|
||||
position: relative; transition: all 0.15s;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.over { border-color: var(--accent); color: var(--accent); background: #f0f4ff; }
|
||||
.drop-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
|
||||
.drop-zone .icon { font-size: 28px; margin-bottom: 8px; }
|
||||
.file-info { margin-top: 10px; padding: 8px 12px; background: var(--ok-bg); border-radius: var(--radius-sm); font-size: 13px; color: var(--ok); }
|
||||
.step { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 1rem; }
|
||||
.step-num { width: 26px; height: 26px; min-width: 26px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; margin-top: 1px; }
|
||||
.step-num.done { background: var(--ok); }
|
||||
.step-num.pending { background: var(--border2); color: var(--muted); }
|
||||
.step-body { flex: 1; }
|
||||
.step-title { font-size: 14px; font-weight: 500; margin-bottom: 6px; }
|
||||
.answers-grid { display: grid; gap: 6px; }
|
||||
.answer-row { display: grid; grid-template-columns: 80px 1fr 1fr; gap: 8px; align-items: center; font-size: 13px; }
|
||||
.answer-row .task-label { color: var(--muted); font-weight: 500; }
|
||||
.answer-row input { padding: 5px 8px; }
|
||||
.section-header { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 0 4px; border-bottom: 1px solid var(--border); margin-bottom: 8px; display: grid; grid-template-columns: 80px 1fr 1fr; gap: 8px; }
|
||||
.log { background: #1a1a18; color: #e8e6e0; border-radius: var(--radius); padding: 1rem; font-family: 'Menlo', 'Monaco', monospace; font-size: 12px; line-height: 1.8; max-height: 200px; overflow-y: auto; white-space: pre-wrap; }
|
||||
.log .ok { color: #6ee7a0; }
|
||||
.log .err { color: #f87171; }
|
||||
.log .info { color: #93c5fd; }
|
||||
.result-card { border-radius: var(--radius); overflow: hidden; margin-bottom: 1rem; }
|
||||
.result-block { margin-bottom: 1.5rem; }
|
||||
.result-block h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
|
||||
table.check-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.check-table th { font-size: 11px; font-weight: 600; color: var(--muted); text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); }
|
||||
table.check-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); }
|
||||
table.check-table tr:last-child td { border-bottom: none; }
|
||||
.mark-ok { color: var(--ok); font-weight: 600; }
|
||||
.mark-err { color: var(--err); font-weight: 600; }
|
||||
.score-pill { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; }
|
||||
.score-pill.ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.score-pill.mid { background: var(--warn-bg); color: var(--warn); }
|
||||
.score-pill.low { background: var(--err-bg); color: var(--err); }
|
||||
.total-box { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; display: flex; align-items: center; justify-content: space-between; }
|
||||
.total-score { font-size: 36px; font-weight: 700; }
|
||||
.total-label { font-size: 13px; color: var(--muted); }
|
||||
.errors-list { font-size: 13px; }
|
||||
.error-item { padding: 6px 0; border-bottom: 1px solid var(--border); color: var(--err); }
|
||||
.error-item:last-child { border-bottom: none; }
|
||||
.transcript-box { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 1rem; font-size: 13px; line-height: 1.7; color: var(--text); max-height: 200px; overflow-y: auto; white-space: pre-wrap; }
|
||||
.badge { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 20px; }
|
||||
.badge-ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.badge-err { background: var(--err-bg); color: var(--err); }
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#result-section { display: none; }
|
||||
.divider { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<h1>ЕГЭ — Аудирование (Английский язык)</h1>
|
||||
<p class="subtitle">Загрузи аудиозапись ответов ученика → получи транскрипт → сверь с ключами → баллы</p>
|
||||
|
||||
<!-- API Settings -->
|
||||
<div class="card">
|
||||
<div class="card-title">Настройки API</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Whisper эндпоинт (STT)</label>
|
||||
<input type="text" id="whisper-url" value="https://api.openai.com/v1/audio/transcriptions" placeholder="https://...">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>LLM эндпоинт (проверка)</label>
|
||||
<input type="text" id="llm-url" value="https://llm.lambda.coredump.ru/v1" placeholder="https://...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>API Key (Whisper)</label>
|
||||
<input type="password" id="whisper-key" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>API Key (LLM)</label>
|
||||
<input type="password" id="llm-key" placeholder="ключ если нужен">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Модель LLM</label>
|
||||
<input type="text" id="llm-model" value="openai/Qwen3.5-122B-A10B" placeholder="имя модели">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Audio -->
|
||||
<div class="card">
|
||||
<div class="step">
|
||||
<div class="step-num" id="step1-num">1</div>
|
||||
<div class="step-body">
|
||||
<div class="step-title">Загрузи аудиозапись ответов ученика</div>
|
||||
<div class="drop-zone" id="audio-drop">
|
||||
<div class="icon">🎤</div>
|
||||
<div>Нажми или перетащи аудиофайл</div>
|
||||
<div style="font-size:11px;margin-top:4px;color:var(--muted)">MP3, WAV, M4A, OGG, WEBM — до 25 МБ</div>
|
||||
<input type="file" id="audio-file" accept="audio/*" onchange="handleAudio(this.files[0])">
|
||||
</div>
|
||||
<div id="audio-info" style="display:none" class="file-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Keys -->
|
||||
<div class="card">
|
||||
<div class="step">
|
||||
<div class="step-num pending" id="step2-num">2</div>
|
||||
<div class="step-body">
|
||||
<div class="step-title">Введи правильные ответы (ключи)</div>
|
||||
<p style="font-size:12px;color:var(--muted);margin-bottom:12px">Заполни ключи для заданий, которые есть в варианте. Пустые задания игнорируются.</p>
|
||||
|
||||
<div class="section-header">
|
||||
<span>Задание</span><span>Ключ (верный)</span><span>— пусто —</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;font-weight:600;color:var(--muted);margin:8px 0 4px">Задание 1 — Соответствие (A–F → цифра 1–7)</div>
|
||||
<div class="answers-grid" id="keys-b1">
|
||||
<!-- A-F rows generated by JS -->
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;font-weight:600;color:var(--muted);margin:14px 0 4px">Задания 2–9 — True(1) / False(2) / Not Stated(3)</div>
|
||||
<div class="answers-grid" id="keys-b2">
|
||||
<!-- 2-9 rows -->
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;font-weight:600;color:var(--muted);margin:14px 0 4px">Задания 10–18 — Выбор из 3 вариантов (1/2/3)</div>
|
||||
<div class="answers-grid" id="keys-c1">
|
||||
<!-- 10-18 rows -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run button -->
|
||||
<div style="display:flex;gap:10px;margin-bottom:1.5rem">
|
||||
<button class="btn-primary" id="run-btn" onclick="runCheck()" disabled>
|
||||
Распознать и проверить →
|
||||
</button>
|
||||
<button onclick="resetAll()">Сбросить</button>
|
||||
</div>
|
||||
|
||||
<!-- Log -->
|
||||
<div id="log-section" style="display:none;margin-bottom:1rem">
|
||||
<div class="log" id="log"></div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="result-section">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Транскрипт ответов ученика</div>
|
||||
<div class="transcript-box" id="transcript-box"></div>
|
||||
<div style="margin-top:8px">
|
||||
<div class="card-title" style="margin-bottom:4px">Распознанные ответы</div>
|
||||
<div class="transcript-box" id="parsed-answers-box" style="font-family:monospace"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="result-detail">
|
||||
<div class="card-title">Детальная проверка</div>
|
||||
|
||||
<div class="result-block">
|
||||
<h3>Задание 1 — Установление соответствия <span id="score-b1-pill" class="score-pill"></span></h3>
|
||||
<table class="check-table">
|
||||
<thead><tr><th>Высказывание</th><th>Ответ ученика</th><th>Ключ</th><th>Результат</th></tr></thead>
|
||||
<tbody id="table-b1"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="result-block">
|
||||
<h3>Задания 2–9 — True / False / Not Stated <span id="score-b2-pill" class="score-pill"></span></h3>
|
||||
<table class="check-table">
|
||||
<thead><tr><th>Задание</th><th>Ответ ученика</th><th>Ключ</th><th>Результат</th></tr></thead>
|
||||
<tbody id="table-b2"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="result-block">
|
||||
<h3>Задания 10–18 — Выбор ответа <span id="score-c1-pill" class="score-pill"></span></h3>
|
||||
<table class="check-table">
|
||||
<thead><tr><th>Задание</th><th>Ответ ученика</th><th>Ключ</th><th>Результат</th></tr></thead>
|
||||
<tbody id="table-c1"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="total-box card">
|
||||
<div>
|
||||
<div class="total-label">Итоговый балл</div>
|
||||
<div class="total-score" id="total-score">—</div>
|
||||
<div class="total-label" id="total-label">из 23 первичных баллов</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="total-label" style="margin-bottom:4px">Ошибки</div>
|
||||
<div id="errors-summary" class="errors-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let audioFile = null;
|
||||
let audioFileName = '';
|
||||
|
||||
// Build key input rows
|
||||
function buildKeyRows() {
|
||||
const b1 = document.getElementById('keys-b1');
|
||||
['A','B','C','D','E','F'].forEach(letter => {
|
||||
b1.innerHTML += `<div class="answer-row">
|
||||
<span class="task-label">Задание 1${letter}</span>
|
||||
<input type="text" id="key-b1-${letter}" maxlength="1" placeholder="1–7" style="width:60px">
|
||||
<span></span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const b2 = document.getElementById('keys-b2');
|
||||
for (let i = 2; i <= 9; i++) {
|
||||
b2.innerHTML += `<div class="answer-row">
|
||||
<span class="task-label">Задание ${i}</span>
|
||||
<input type="text" id="key-b2-${i}" maxlength="1" placeholder="1/2/3" style="width:60px">
|
||||
<span></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const c1 = document.getElementById('keys-c1');
|
||||
for (let i = 10; i <= 18; i++) {
|
||||
c1.innerHTML += `<div class="answer-row">
|
||||
<span class="task-label">Задание ${i}</span>
|
||||
<input type="text" id="key-c1-${i}" maxlength="1" placeholder="1/2/3" style="width:60px">
|
||||
<span></span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudio(file) {
|
||||
if (!file) return;
|
||||
audioFile = file;
|
||||
audioFileName = file.name;
|
||||
const info = document.getElementById('audio-info');
|
||||
info.style.display = 'block';
|
||||
info.textContent = `✓ ${file.name} (${(file.size/1024/1024).toFixed(1)} МБ)`;
|
||||
document.getElementById('step1-num').classList.add('done');
|
||||
document.getElementById('step2-num').classList.remove('pending');
|
||||
document.getElementById('run-btn').disabled = false;
|
||||
}
|
||||
|
||||
// Drag & drop
|
||||
const dropZone = document.getElementById('audio-drop');
|
||||
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('over'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over'));
|
||||
dropZone.addEventListener('drop', e => {
|
||||
e.preventDefault(); dropZone.classList.remove('over');
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) { document.getElementById('audio-file').files; handleAudio(f); }
|
||||
});
|
||||
|
||||
function log(msg, type='') {
|
||||
const el = document.getElementById('log');
|
||||
document.getElementById('log-section').style.display = '';
|
||||
const span = document.createElement('span');
|
||||
span.className = type;
|
||||
span.textContent = msg + '\n';
|
||||
el.appendChild(span);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function getKeys() {
|
||||
const b1 = {};
|
||||
['A','B','C','D','E','F'].forEach(l => {
|
||||
const v = document.getElementById(`key-b1-${l}`).value.trim();
|
||||
if (v) b1[l] = v;
|
||||
});
|
||||
const b2 = {};
|
||||
for (let i = 2; i <= 9; i++) {
|
||||
const v = document.getElementById(`key-b2-${i}`).value.trim();
|
||||
if (v) b2[i] = v;
|
||||
}
|
||||
const c1 = {};
|
||||
for (let i = 10; i <= 18; i++) {
|
||||
const v = document.getElementById(`key-c1-${i}`).value.trim();
|
||||
if (v) c1[i] = v;
|
||||
}
|
||||
return { b1, b2, c1 };
|
||||
}
|
||||
|
||||
async function transcribeAudio(file) {
|
||||
const url = document.getElementById('whisper-url').value.trim();
|
||||
const key = document.getElementById('whisper-key').value.trim();
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
formData.append('model', 'whisper-1');
|
||||
formData.append('language', 'en');
|
||||
formData.append('prompt', 'Student answers to English listening exam: task 1 matching A B C D E F with numbers 1-7, tasks 2-9 True False Not Stated as 1 2 or 3, tasks 10-18 multiple choice 1 2 or 3.');
|
||||
|
||||
const headers = {};
|
||||
if (key) headers['Authorization'] = 'Bearer ' + key;
|
||||
|
||||
const resp = await fetch(url, { method: 'POST', headers, body: formData });
|
||||
if (!resp.ok) throw new Error('Whisper error: ' + resp.status + ' ' + await resp.text());
|
||||
const data = await resp.json();
|
||||
return data.text || '';
|
||||
}
|
||||
|
||||
async function parseAnswersWithLLM(transcript, keys) {
|
||||
const url = document.getElementById('llm-url').value.trim().replace(/\/$/, '');
|
||||
const model = document.getElementById('llm-model').value.trim();
|
||||
const key = document.getElementById('llm-key').value.trim();
|
||||
|
||||
const keysText = JSON.stringify(keys, null, 2);
|
||||
|
||||
const prompt = `You are grading a Russian EGE English listening exam.
|
||||
|
||||
The student recorded their answers verbally. Here is the transcript:
|
||||
"""
|
||||
${transcript}
|
||||
"""
|
||||
|
||||
Extract the student's answers for each task. The exam structure:
|
||||
- Task 1 (matching): letters A,B,C,D,E,F each matched to a number 1-7
|
||||
- Tasks 2-9 (True/False/Not Stated): answers are 1 (True), 2 (False), or 3 (Not Stated)
|
||||
- Tasks 10-18 (multiple choice): answers are 1, 2, or 3
|
||||
|
||||
The teacher's answer key is:
|
||||
${keysText}
|
||||
|
||||
Now compare student answers to the key and return ONLY valid JSON, no markdown:
|
||||
{
|
||||
"transcript_clean": "cleaned up version of student answers",
|
||||
"student_answers": {
|
||||
"b1": {"A":"3","B":"1","C":"5","D":"7","E":"2","F":"4"},
|
||||
"b2": {"2":"1","3":"2","4":"1","5":"3","6":"2","7":"1","8":"3","9":"2"},
|
||||
"c1": {"10":"2","11":"1","12":"3","13":"1","14":"2","15":"3","16":"1","17":"2","18":"1"}
|
||||
},
|
||||
"results": {
|
||||
"b1": {"A":true,"B":false,"C":true,"D":true,"E":true,"F":true},
|
||||
"b2": {"2":true,"3":false,"4":true,"5":true,"6":false,"7":true,"8":true,"9":true},
|
||||
"c1": {"10":true,"11":true,"12":false,"13":true,"14":true,"15":true,"16":true,"17":false,"18":true}
|
||||
},
|
||||
"scores": {
|
||||
"b1": 5,
|
||||
"b2": 6,
|
||||
"c1": 7,
|
||||
"total": 18
|
||||
},
|
||||
"errors": [
|
||||
"Задание 1B: ученик ответил 5, верный ответ 1",
|
||||
"Задание 6: ученик ответил 1, верный ответ 2"
|
||||
]
|
||||
}
|
||||
|
||||
Only include tasks that have keys provided. If student answer is unclear, mark it as "?" and count as wrong.`;
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (key) headers['Authorization'] = 'Bearer ' + key;
|
||||
|
||||
const resp = await fetch(url + '/chat/completions', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 2000,
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
})
|
||||
});
|
||||
if (!resp.ok) throw new Error('LLM error: ' + resp.status + ' ' + await resp.text());
|
||||
const data = await resp.json();
|
||||
const raw = data.choices?.[0]?.message?.content || '';
|
||||
const clean = raw.replace(/```json|```/g, '').trim();
|
||||
return JSON.parse(clean);
|
||||
}
|
||||
|
||||
function scorePill(score, max) {
|
||||
const pct = score / max;
|
||||
const cls = pct >= 0.8 ? 'ok' : pct >= 0.5 ? 'mid' : 'low';
|
||||
return `<span class="score-pill ${cls}">${score} / ${max}</span>`;
|
||||
}
|
||||
|
||||
function renderResults(data, keys) {
|
||||
document.getElementById('result-section').style.display = '';
|
||||
document.getElementById('transcript-box').textContent = data.transcript_clean || '(не удалось извлечь)';
|
||||
|
||||
// Show parsed answers
|
||||
const sa = data.student_answers || {};
|
||||
let parsedText = '';
|
||||
if (sa.b1) parsedText += 'Задание 1: ' + Object.entries(sa.b1).map(([k,v]) => `${k}=${v}`).join(' ') + '\n';
|
||||
if (sa.b2) parsedText += 'Задания 2-9: ' + Object.entries(sa.b2).map(([k,v]) => `${k}=${v}`).join(' ') + '\n';
|
||||
if (sa.c1) parsedText += 'Задания 10-18: ' + Object.entries(sa.c1).map(([k,v]) => `${k}=${v}`).join(' ');
|
||||
document.getElementById('parsed-answers-box').textContent = parsedText;
|
||||
|
||||
// B1 table
|
||||
const b1res = data.results?.b1 || {};
|
||||
const b1body = document.getElementById('table-b1');
|
||||
b1body.innerHTML = '';
|
||||
['A','B','C','D','E','F'].filter(l => keys.b1[l]).forEach(l => {
|
||||
const ok = b1res[l];
|
||||
const studentAns = sa.b1?.[l] ?? '?';
|
||||
b1body.innerHTML += `<tr>
|
||||
<td>${l}</td>
|
||||
<td>${studentAns}</td>
|
||||
<td>${keys.b1[l]}</td>
|
||||
<td class="${ok ? 'mark-ok' : 'mark-err'}">${ok ? '✓' : '✗'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
const b1max = Object.keys(keys.b1).length;
|
||||
document.getElementById('score-b1-pill').outerHTML = scorePill(data.scores?.b1 ?? 0, b1max);
|
||||
|
||||
// B2 table
|
||||
const b2res = data.results?.b2 || {};
|
||||
const b2body = document.getElementById('table-b2');
|
||||
b2body.innerHTML = '';
|
||||
for (let i = 2; i <= 9; i++) {
|
||||
if (!keys.b2[i]) continue;
|
||||
const ok = b2res[i];
|
||||
const studentAns = sa.b2?.[i] ?? '?';
|
||||
const labels = { '1': 'True', '2': 'False', '3': 'Not Stated' };
|
||||
b2body.innerHTML += `<tr>
|
||||
<td>${i}</td>
|
||||
<td>${studentAns} ${labels[studentAns] ? `<span style="color:var(--muted);font-size:11px">(${labels[studentAns]})</span>` : ''}</td>
|
||||
<td>${keys.b2[i]} <span style="color:var(--muted);font-size:11px">(${labels[keys.b2[i]] || ''})</span></td>
|
||||
<td class="${ok ? 'mark-ok' : 'mark-err'}">${ok ? '✓' : '✗'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
const b2max = Object.keys(keys.b2).length;
|
||||
document.getElementById('score-b2-pill').outerHTML = scorePill(data.scores?.b2 ?? 0, b2max);
|
||||
|
||||
// C1 table
|
||||
const c1res = data.results?.c1 || {};
|
||||
const c1body = document.getElementById('table-c1');
|
||||
c1body.innerHTML = '';
|
||||
for (let i = 10; i <= 18; i++) {
|
||||
if (!keys.c1[i]) continue;
|
||||
const ok = c1res[i];
|
||||
const studentAns = sa.c1?.[i] ?? '?';
|
||||
c1body.innerHTML += `<tr>
|
||||
<td>${i}</td>
|
||||
<td>${studentAns}</td>
|
||||
<td>${keys.c1[i]}</td>
|
||||
<td class="${ok ? 'mark-ok' : 'mark-err'}">${ok ? '✓' : '✗'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
const c1max = Object.keys(keys.c1).length;
|
||||
document.getElementById('score-c1-pill').outerHTML = scorePill(data.scores?.c1 ?? 0, c1max);
|
||||
|
||||
// Total
|
||||
const total = data.scores?.total ?? 0;
|
||||
const maxTotal = b1max + b2max + c1max;
|
||||
const totalEl = document.getElementById('total-score');
|
||||
totalEl.textContent = total;
|
||||
totalEl.style.color = total / maxTotal >= 0.8 ? 'var(--ok)' : total / maxTotal >= 0.5 ? 'var(--warn)' : 'var(--err)';
|
||||
document.getElementById('total-label').textContent = `из ${maxTotal} первичных баллов`;
|
||||
|
||||
// Errors
|
||||
const errList = document.getElementById('errors-summary');
|
||||
if (data.errors?.length) {
|
||||
errList.innerHTML = data.errors.map(e => `<div class="error-item">✗ ${e}</div>`).join('');
|
||||
} else {
|
||||
errList.innerHTML = '<span style="color:var(--ok)">Ошибок нет!</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
if (!audioFile) return;
|
||||
const keys = getKeys();
|
||||
const btn = document.getElementById('run-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span>Обработка...';
|
||||
document.getElementById('log-section').style.display = '';
|
||||
document.getElementById('log').innerHTML = '';
|
||||
document.getElementById('result-section').style.display = 'none';
|
||||
|
||||
try {
|
||||
log('→ Отправляю аудио в Whisper...', 'info');
|
||||
const transcript = await transcribeAudio(audioFile);
|
||||
log('✓ Транскрипция получена: ' + transcript.substring(0, 80) + '...', 'ok');
|
||||
|
||||
log('→ Отправляю в LLM для разбора ответов и проверки...', 'info');
|
||||
const result = await parseAnswersWithLLM(transcript, keys);
|
||||
log('✓ Проверка завершена. Итог: ' + result.scores?.total + ' баллов', 'ok');
|
||||
|
||||
renderResults(result, keys);
|
||||
} catch(e) {
|
||||
log('✗ Ошибка: ' + e.message, 'err');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Распознать и проверить →';
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
audioFile = null;
|
||||
document.getElementById('audio-info').style.display = 'none';
|
||||
document.getElementById('audio-file').value = '';
|
||||
document.getElementById('run-btn').disabled = true;
|
||||
document.getElementById('result-section').style.display = 'none';
|
||||
document.getElementById('log-section').style.display = 'none';
|
||||
document.getElementById('log').innerHTML = '';
|
||||
document.getElementById('step1-num').classList.remove('done');
|
||||
document.getElementById('step2-num').classList.add('pending');
|
||||
}
|
||||
|
||||
buildKeyRows();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
503
recognition.py
Normal file
503
recognition.py
Normal file
|
|
@ -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)
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
faster-whisper==1.2.1
|
||||
torch==2.11.0
|
||||
|
Before Width: | Height: | Size: 903 KiB After Width: | Height: | Size: 903 KiB |
|
Before Width: | Height: | Size: 930 KiB After Width: | Height: | Size: 930 KiB |
Loading…
Add table
Add a link
Reference in a new issue