Compare commits

...

4 commits

Author SHA1 Message Date
a54192b882 fix SKILL.md 2026-04-02 22:43:46 +03:00
35ad7e2e0a add actual criterias 2026 2026-04-02 22:43:29 +03:00
89063a38b1 remove local testers 2026-04-02 22:42:29 +03:00
fd0fba6823 ignore .scans 2026-04-02 22:42:14 +03:00
6 changed files with 341 additions and 1172 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
.venv .venv
.scans

View file

@ -5,108 +5,106 @@ description: >
Используй этот скилл когда пользователь: Используй этот скилл когда пользователь:
- Загружает фото/скан рукописного сочинения ЕГЭ по русскому языку - Загружает фото/скан рукописного сочинения ЕГЭ по русскому языку
- Просит проверить сочинение ЕГЭ, выставить баллы, оценить работу - Просит проверить сочинение ЕГЭ, выставить баллы, оценить работу
- Загружает аудиозапись или текст ответов по разделу "Аудирование" ЕГЭ по английскому - Загружает аудиозапись ответов по разделу "Аудирование" ЕГЭ по английскому
- Упоминает "ЕГЭ", "сочинение", "критерии К1-К12", "аудирование английский" - Упоминает "ЕГЭ", "сочинение", "критерии К1-К12", "аудирование английский"
- Просит разобрать ошибки в сочинении по критериям ФИПИ - Просит разобрать ошибки в сочинении по критериям ФИПИ
Скилл умеет: OCR рукописного текста через vision (multimodal), оценку по критериям, подробные комментарии. Работает с любой multimodal LLM (Qwen, GPT-4o и др.). Скилл умеет: OCR рукописного текста через vision, STT аудиозаписей через recognition.py,
оценку по критериям ФИПИ 2026. Работает с любой multimodal LLM (Qwen, GPT-4o и др.).
--- ---
# ЕГЭ-Checker # ЕГЭ-Checker
## Обзор возможностей ## Обзор возможностей
| Модуль | Статус | Вход | Выход | | Модуль | Вход — только распознавание | Вход — распознавание + оценка |
|--------|--------|------|-------| |--------|----------------------------|-------------------------------|
| Сочинение (русский) | ✅ MVP | Фото рукописи или текст | Баллы К1К12 + комментарии | | Сочинение (русский) | Сканы бланков | Сканы + исходный текст + тема |
| Аудирование (английский) | ✅ MVP | Аудиофайл + ключи | Баллы по заданиям | | Аудирование (английский) | Аудиозапись | Аудиозапись + ключи проверяющего |
--- ---
## Модуль 1: Сочинение ЕГЭ по русскому языку ## Модуль 1: Сочинение ЕГЭ по русскому языку
### Что получает агент от проверяющего ### Логика запуска
Проверяющий присылает в одном сообщении:
1. **Сканы бланков** — фото рукописного сочинения (один или несколько листов)
2. **Исходный текст задания** — публицистический/художественный текст, по которому написано сочинение
3. **Тему/формулировку задания** — если есть отдельно (иногда тема вытекает из текста)
Без исходного текста невозможно проверить К1, К2, К3 и К12. Если текст не прислан — попроси его перед началом проверки. **Если прислали только сканы бланков (без исходного текста и темы):**
→ Только распознать рукопись и вывести текст. Оценку не выставлять.
→ Сообщить: "Текст распознан. Чтобы выставить баллы по критериям, пришлите также исходный текст задания и тему сочинения."
### Шаг 1 — Получить текст сочинения **Если прислали сканы + исходный текст задания + тему:**
→ Распознать рукопись, затем выставить баллы по К1К10 с объяснением снятий.
**Если загружено фото/скан:**
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: Только распознавание
1. Внимательно рассмотри все изображения — бланки могут быть на нескольких листах
2. Распознай рукописный текст максимально точно, сохраняя абзацное деление
3. Зачёркнутые слова отмечай как ~~зачёркнуто~~
4. Выведи распознанный текст и сообщи количество слов
5. Уточни: "Если есть неточности — поправьте. Чтобы выставить баллы, пришлите исходный текст задания."
---
### Режим 2: Распознавание + оценка по критериям
**Шаг 1 — Распознать текст** (как в режиме 1, шаги 14)
Попроси подтвердить распознанный текст перед оценкой.
**Шаг 2 — Загрузить критерии**
Прочитай `references/russian-essay-criteria.md` — критерии К1К10 с баллами (ЕГЭ 2026).
**Шаг 3 — Проверить предварительные условия**
- Подсчитай слова. Если менее 150 → все критерии = 0, сообщи об этом явно.
- Если К1 = 0 → К2 и К3 автоматически = 0.
**Шаг 4 — Оценить по каждому критерию К1К10 последовательно**
Для каждого критерия:
- Процитируй конкретный фрагмент из сочинения (если уместно)
- Объясни снятие баллов — что именно не выполнено и почему
- Выставь балл
**Шаг 5 — Итоговый вывод**
```
## Результаты проверки сочинения ЕГЭ (2026)
### Количество слов: XX
### Оценка по критериям ### Оценка по критериям
| Критерий | Название | Балл | Макс | | Критерий | Название | Балл | Макс |
|----------|----------|------|------| |----------|----------|------|------|
| К1 | Формулировка проблем исходного текста | X | 1 | | К1 | Позиция автора | X | 1 |
| К2 | Комментарий к проблеме | X | 6 | | К2 | Комментарий к позиции автора | X | 3 |
| К3 | Отражение позиции автора | X | 1 | | К3 | Собственное отношение + аргумент | X | 2 |
| К4 | Отношение к позиции автора | X | 1 | | К4 | Фактическая точность | X | 1 |
| К5 | Смысловая цельность, связность | X | 2 | | К5 | Логичность | X | 2 |
| К6 | Точность и выразительность речи | X | 2 | | К6 | Этические нормы | X | 1 |
| К7 | Орфография | X | 3 | | К7 | Орфография | X | 3 |
| К8 | Пунктуация | X | 3 | | К8 | Пунктуация | X | 3 |
| К9 | Языковые нормы | X | 2 | | К9 | Грамматика | X | 3 |
| К10 | Речевые нормы | X | 2 | | К10 | Речевые нормы | X | 3 |
| К11 | Этические нормы | X | 1 | | **ИТОГО** | | **XX** | **22** |
| К12 | Фактическая точность | X | 1 |
| **ИТОГО** | | **XX** | **25** |
---
### Подробные комментарии ### Подробные комментарии
**К1 — [балл/1]** **К1 — [балл/1]**
[объяснение] [что именно засчитано или почему снято]
**К2 — [балл/6]** **К2 — [балл/3]**
[объяснение с цитатами из текста] [разбор с цитатами: есть ли 2 примера, пояснения, смысловая связь]
... и так далее по каждому критерию ... ...и так далее по каждому критерию...
--- ### Снятия баллов (итого)
- К2: -1 — смысловая связь между примерами есть, но не пояснена
- К7: -1 — ошибка в слове "..."
...
### Главные рекомендации ### Главные рекомендации
1. [самое важное для улучшения] 1. [самое важное]
2. ... 2. ...
``` ```
@ -114,109 +112,129 @@ description: >
## Модуль 2: Аудирование ЕГЭ по английскому ## Модуль 2: Аудирование ЕГЭ по английскому
### Что получает агент от проверяющего ### Логика запуска
Проверяющий присылает в одном сообщении:
1. **Аудиозапись** — файл с устными ответами ученика (MP3, WAV, M4A и т.д.)
2. **Правильные ответы (ключи)** — текстом или фото бланка с ключами
### Шаг 1 — Сохранить аудиофайл во временную директорию **Если прислали только аудиозапись (без ключей):**
→ Только распознать ответы ученика через recognition.py и вывести их таблицей.
→ Сообщить: "Ответы распознаны. Чтобы выставить баллы, пришлите правильные ответы (ключи)."
Сохрани полученный аудиофайл во временный путь `/tmp/ege_audio_<timestamp>.<ext>`. **Если прислали аудиозапись + ключи проверяющего:**
→ Распознать ответы, затем сверить с ключами и выставить баллы с объяснением ошибок.
---
### Режим 1: Только распознавание
**Шаг 1 — Сохранить аудиофайл**
```python ```python
import os, time import os, time
ext = os.path.splitext(original_filename)[1] or ".mp3" ext = os.path.splitext(original_filename)[1] or ".mp3"
tmp_path = f"/tmp/ege_audio_{int(time.time())}{ext}" tmp_path = f"/tmp/ege_audio_{int(time.time())}{ext}"
with open(tmp_path, "wb") as f: with open(tmp_path, "wb") as f:
f.write(audio_bytes) f.write(audio_bytes)
``` ```
### Шаг 2 — Запустить recognition.py **Шаг 2 — Запустить recognition.py**
**Через subprocess (рекомендуется):** Через subprocess (рекомендуется):
```bash ```bash
python3 ~/zeroclaw-bot/recognition.py {tmp_path} --output json python3 ~/zeroclaw-bot/recognition.py <tmp_path> --output json
``` ```
**Через прямой импорт:** Через прямой импорт:
```python ```python
import sys import sys
sys.path.insert(0, os.path.expanduser("~/zeroclaw-bot")) sys.path.insert(0, os.path.expanduser("~/zeroclaw-bot"))
from recognition import process_audio from recognition import process_audio
transcript, answers = process_audio(tmp_path, model_size="medium", verbose=False) transcript, answers = process_audio(tmp_path, model_size="medium", verbose=False)
recognition_json = {
"transcript": transcript.text,
"answers": answers.to_dict(),
"unrecognized": answers.unrecognized,
}
``` ```
Результат — JSON: **Шаг 3 — Удалить временный файл**
- `transcript` — полный текст того, что сказал ученик ```python
- `answers.task1` — {"A": "3", "B": "1", ...} if os.path.exists(tmp_path):
- `answers.task2_9` — {"2": "1", "3": "2", ...} os.remove(tmp_path)
- `answers.task10_18` — {"10": "2", "11": "1", ...}
- `answers.unrecognized` — задания, которые не удалось извлечь (засчитать как 0)
### Шаг 3 — Получить и распознать ключи
Если ключи пришли текстом — использовать напрямую.
Если пришло фото бланка с ключами — распознать через vision.
### Шаг 4 — Передать в LLM для сверки и выставления баллов
Сформируй промпт для LLM на основе результатов recognition.py.
Подставь реальные значения вместо плейсхолдеров:
``` ```
Ты эксперт-проверяющий ЕГЭ по английскому (аудирование).
Транскрипт ответов ученика (распознан через Whisper): **Шаг 4 — Вывести распознанные ответы**
Таблица: задание | распознанный ответ
Если есть нераспознанные — явно отметить.
Сообщить: "Ответы распознаны. Пришлите правильные ответы (ключи) для выставления баллов."
---
### Режим 2: Распознавание + оценка
**Шаги 13** — те же, что в режиме 1 (сохранить, запустить, удалить).
**Шаг 4 — Получить и распознать ключи**
Если ключи текстом — использовать напрямую.
Если ключи фото — распознать через vision.
**Шаг 5 — Загрузить критерии**
Прочитай `references/english-listening-criteria.md`.
**Шаг 6 — Передать в LLM для сверки**
Промпт для модели (подставить реальные значения):
```
Ты эксперт-проверяющий ЕГЭ по английскому (аудирование, 2026).
Транскрипт ответов ученика (Whisper):
[TRANSCRIPT] [TRANSCRIPT]
Извлечённые ответы ученика: Извлечённые ответы ученика:
- Задание 1 (A-F): [TASK1] - Задание 1 (AF): [TASK1]
- Задания 2-9: [TASK2_9] - Задания 23 (True/False/Not stated): [TASK2_3]
- Задания 10-18: [TASK10_18] - Задания 49 (выбор 1/2/3): [TASK4_9]
- Не распознаны (засчитать как 0): [UNRECOGNIZED] - Не распознаны (засчитать как 0): [UNRECOGNIZED]
Правильные ответы (ключи): Правильные ответы (ключи):
[KEYS] [KEYS]
Сверь ответы с ключами. 1 балл за совпадение, 0 — за несовпадение или отсутствие. Сверь ответы с ключами. 1 балл за совпадение, 0 за несовпадение или отсутствие.
Верни строго JSON без markdown: Верни строго JSON без markdown:
{"results":{"task1":{"A":true},"task2_9":{"2":true},"task10_18":{"10":false}}, {"results":{"task1":{"A":true},"task2_3":{"2":true},"task4_9":{"4":false}},
"scores":{"task1":0,"task2_9":0,"task10_18":0,"total":0}, "scores":{"task1":0,"task2_3":0,"task4_9":0,"total":0},
"errors":["Задание 1B: ученик ответил 5, верный ответ 1"]} "errors":["Задание 1B: ученик — 5, верный ответ — 1"]}
``` ```
Читай `references/english-listening-criteria.md` для максимальных баллов каждого блока. **Шаг 7 — Вывести результат**
### Шаг 5 — Удалить временный файл ```
## Результаты: Аудирование ЕГЭ (Английский язык, 2026)
Обязательно удали аудиофайл после получения ответа от LLM: ### Задание 1 — Установление соответствия (макс. 6 баллов)
| Высказывание | Ответ ученика | Ключ | Результат |
| A | 3 | 3 | + |
| B | 5 | 1 | - |
...
Баллов: X / 6
```python ### Задания 23 — True / False / Not stated (макс. 2 балла)
import os ...
if os.path.exists(tmp_path): Баллов: X / 2
os.remove(tmp_path)
### Задания 49 — Выбор ответа (макс. 6 баллов)
...
Баллов: X / 6 (или X / подблок)
### Итого: XX / 12
### Ошибки с пояснением:
- Задание 1B: ученик ответил 5, верный ответ 1
...
[Если были нераспознанные:]
Задания X, Y не были распознаны в аудио и засчитаны как неверные (0 баллов).
``` ```
### Шаг 6 — Вывести результат пользователю
Три блока (Задание 1 / Задания 2-9 / Задания 10-18):
колонки — задание, ответ ученика, ключ, результат (✓/✗).
Итог: баллы по каждому блоку + общий итог из 23.
Если были нераспознанные задания — явно указать:
"Задания X, Y не были распознаны в аудио и засчитаны как неверные."
--- ---
## Общие принципы проверки ## Общие принципы
- **Актуальность**: Критерии соответствуют демоверсии ФИПИ 20242025 - **Актуальность**: Критерии соответствуют ФИПИ ЕГЭ 2026
- **Строгость**: Придерживайся формулировок критериев, не занижай и не завышай - **Строгость**: Придерживайся формулировок критериев точно
- **Пограничные случаи**: При сомнении между баллами — объясни оба варианта и выбери более обоснованный - **Снятия**: Всегда объясняй конкретно что именно не выполнено и почему снят балл
- **Тон**: Конструктивный, поддерживающий. Это учебный инструмент, не карательный - **Пограничные случаи**: При сомнении — объясни оба варианта, выбери более обоснованный
- **Совместимость**: Скилл написан без привязки к конкретной модели. Работает с любым multimodal агентом, поддерживающим vision и аудио (Qwen3, GPT-4o, и др.) - **Тон**: Конструктивный, учебный инструмент — не карательный
- **Совместимость**: Скилл работает с любым multimodal агентом (Qwen3, GPT-4o и др.)

View file

@ -1,30 +1,78 @@
# Критерии оценивания раздела "Аудирование" ЕГЭ по английскому языку # Критерии оценивания раздела "Аудирование" ЕГЭ по английскому языку
## Статус: заглушка — будет заполнено в следующей итерации ## Источник: ФИПИ, спецификация ЕГЭ 2026
### Структура раздела (ЕГЭ 20242025)
**Задание 1** (B1) — установление соответствия: 6 высказываний → 7 утверждений
- 1 балл за каждое верное соответствие
- Максимум: 6 баллов
**Задания 29** (B2) — верно/неверно/не сказано (True/False/Not Stated)
- 1 балл за каждый верный ответ
- Максимум: 8 баллов
**Задания 1018** — краткий ответ/выбор из нескольких вариантов
- 1 балл за каждый верный ответ
- Максимум: 9 баллов
**Итого по разделу "Аудирование": 20 первичных баллов**
--- ---
## STT интеграция (планируется) ## Структура раздела "Аудирование"
Для обработки аудиозаписей потребуется: Раздел состоит из 9 заданий (задания 19), максимум — 12 первичных баллов.
- OpenAI Whisper API (рекомендуется для русской и английской речи) Каждое задание = 1 балл за верный ответ, 0 за неверный.
- Или Deepgram (альтернатива) Критерии объективные — только совпадение с ключом, никакой субъективной оценки.
- Эндпоинт: `https://api.openai.com/v1/audio/transcriptions`
- Модель: `whisper-1`
Временное решение для MVP: попросить пользователя ввести ответы текстом. ---
## Задание 1 — Установление соответствия (1 задание, 6 баллов)
Формат: ученик слышит 6 высказываний от 6 говорящих. Нужно установить соответствие
каждого высказывания одному из 7 утверждений (одно утверждение — лишнее).
Ответ: буква AF → цифра 17.
Оценивание: 1 балл за каждое верное соответствие, максимум 6 баллов.
Пример ответа ученика: A3, B1, C5, D7, E2, F4.
---
## Задания 23 — Верно / Неверно / В тексте не сказано (2 задания, 2 балла)
Формат: ученик слышит диалог. По 2 утверждения, нужно определить:
- 1 (True) — утверждение соответствует диалогу
- 2 (False) — утверждение противоречит диалогу
- 3 (Not stated) — информация в диалоге не упоминается
Оценивание: 1 балл за каждый верный ответ, максимум 2 балла.
---
## Задания 49 — Выбор из трёх вариантов (6 заданий, 6 баллов)
Формат: ученик слышит 6 монологов/диалогов. К каждому — вопрос с 3 вариантами ответа.
Нужно выбрать верный вариант: 1, 2 или 3.
Оценивание: 1 балл за каждый верный ответ, максимум 6 баллов (2 задания по 3 баллов).
> Примечание по структуре: в разных вариантах ФИПИ количество заданий блоков может
> незначительно варьироваться. Агент должен ориентироваться на фактические ключи
> проверяющего, а не на фиксированную нумерацию выше.
---
## Итоговая таблица
| Блок | Задания | Тип | Макс. баллов |
|------|---------|-----|-------------|
| Соответствие | 1 | AF → 17 | 6 |
| True/False/Not stated | 23 | 1/2/3 | 2 |
| Выбор ответа | 49 | 1/2/3 | 6 (иногда делится на подблоки) |
| | | **Итого** | **12** |
---
## Алгоритм проверки агентом
### Что нужно для оценки
1. Распознанные ответы ученика (из аудиозаписи через recognition.py)
2. Правильные ответы (ключи) от проверяющего
### Логика сверки
- Задание 1: совпадение буква → цифра (A=3 у ученика, A=3 в ключе → 1 балл)
- Задания 29: совпадение цифры (ученик: 1, ключ: 1 → 1 балл)
- Нераспознанный ответ ("?") → 0 баллов
- Регистр не важен: "TRUE", "true", "T" — одно и то же
- Для соответствий принимать форматы: "A3", "A-3", "A=3", "A — 3"
### Формат вывода
Три блока с таблицами: задание | ответ ученика | ключ | результат (+ или -)
Итог по каждому блоку + общий итог из 12.
Список ошибок с пояснением: "Задание 1B: ученик — 5, верный ответ — 1".
Если есть нераспознанные задания — явно указать какие и что они засчитаны как 0.

View file

@ -1,5 +1,19 @@
# Критерии оценивания сочинения ЕГЭ по русскому языку # Критерии оценивания сочинения ЕГЭ по русскому языку
## Источник: ФИПИ, демоверсия 20242025 ## Источник: ФИПИ, демоверсия ЕГЭ 2026 (задание 27)
---
## Формулировка задания 27
Напишите сочинение-рассуждение по проблеме, поставленной в исходном тексте.
Сформулируйте позицию автора (рассказчика) по указанной проблеме.
Прокомментируйте, как в тексте раскрывается эта позиция. Включите в комментарий два
примера-иллюстрации из текста, важные для понимания позиции автора, и поясните их.
Укажите и поясните смысловую связь между примерами-иллюстрациями.
Сформулируйте и обоснуйте своё отношение к позиции автора. Включите в обоснование
пример-аргумент (читательский, историко-культурный или жизненный опыт).
Объём — не менее 150 слов.
--- ---
@ -7,200 +21,151 @@
### Подсчёт слов ### Подсчёт слов
- Считаются все слова, включая служебные (предлоги, союзы, частицы) - Считаются все слова, включая служебные (предлоги, союзы, частицы)
- Если слов **меньше 70** → все критерии = 0 (кроме К10) - Инициалы с фамилией = одно слово («М.Ю. Лермонтов» — одно слово)
- Если слов **70149** → рекомендуемый объём не выдержан, но работа проверяется - Цифры при подсчёте не учитываются («5 лет» — одно слово, «пять лет» — два)
- Рекомендуемый объём: **150300 слов** - «всё-таки» — одно слово, «всё же» — два слова
- Если слов менее 150 → работа не засчитывается, все критерии = 0
- Если сочинение написано не по тексту → 0 баллов
- Если полный пересказ/переписывание исходного текста → 0 по К1К10
### Правило нулевого К1 ### Правило нулевого К1
Если К1 = 0 (проблема не сформулирована или сформулирована неверно): Если К1 = 0 → К2 = 0, К3 = 0 автоматически
К2 = 0, К3 = 0, К4 = 0 **автоматически**
--- ---
## К1 — Формулировка проблем исходного текста (01 балл) ## I. Содержание сочинения
**1 балл**: Экзаменуемый (в той или иной форме) верно сформулировал одну из проблем исходного текста. Фактических ошибок, связанных с пониманием и формулировкой проблемы, нет. ### К1 — Отражение позиции автора (рассказчика) (01 балл)
**0 баллов**: Экзаменуемый не смог верно сформулировать ни одну из проблем исходного текста. 1 балл: Позиция автора по указанной проблеме сформулирована верно.
0 баллов: Позиция автора не сформулирована или сформулирована неверно.
**Подсказки для проверки:** Важно (изменение 2026): проблема задана в формулировке задания. Ученик раскрывает
- Проблема должна быть сформулирована как вопрос или тезис позицию АВТОРА по этой проблеме — не формулирует проблему самостоятельно.
- Она должна соответствовать содержанию исходного текста
- Допустимы разные формулировки одной и той же проблемы
--- ---
## К2 — Комментарий к сформулированной проблеме исходного текста (06 баллов) ### К2 — Комментарий к позиции автора (03 балла)
Структура: **2 примера-иллюстрации** из текста + **смысловая связь** между ними. Структура: 2 примера-иллюстрации с пояснениями + смысловая связь с пояснением.
| Баллы | Условия | | Баллов | Условия |
|-------|---------| |--------|---------|
| 6 | Оба примера с пояснениями + связь с анализом | | 3 | 2 примера с пояснениями + смысловая связь с пояснением |
| 5 | Оба примера с пояснениями + связь без анализа | | 2 | 2 примера с пояснениями + связь без пояснения ИЛИ связь не указана/неверна |
| 4 | Оба примера с пояснениями, связь не указана | | 1 | 1 пример с пояснением |
| 3 | Один пример с пояснением + связь | | 0 | Пример без пояснения ИЛИ нет примеров ИЛИ пересказ ИЛИ цитирование большого фрагмента ИЛИ комментарий без опоры на текст |
| 2 | Оба примера без пояснений + связь |
| 1 | Один пример без пояснения |
| 0 | Примеры отсутствуют или текст пересказан |
**Важно:** Правила:
- Пример-иллюстрация = конкретный фрагмент/факт из текста с объяснением его роли - Пример без пояснения не засчитывается
- Смысловая связь: сравнение, противопоставление, причина-следствие, вывод и т.д. - Фактическая ошибка в комментарии учитывается по К4
- Пересказ без анализа = 0 баллов - Не принимаются: комикс, аниме, манга, фанфик, графический роман, компьютерная игра
- Фактические ошибки в комментарии снижают балл (1 за каждую, но не более 2)
--- ---
## К3 — Отражение позиции автора исходного текста (01 балл) ### К3 — Собственное отношение к позиции автора (02 балла)
**1 балл**: Позиция автора верно сформулирована. Фактических ошибок нет. 2 балла: Отношение сформулировано и обосновано + приведён пример-аргумент.
1 балл: Отношение обосновано, но аргумент не приведён.
ИЛИ Аргумент есть, но отношение формальное («Я согласен с автором»).
0 баллов: Только формальное согласие/несогласие без обоснования.
ИЛИ Отношение не сформулировано.
ИЛИ Не соответствует указанной проблеме.
**0 баллов**: Позиция автора не сформулирована или сформулирована неверно. Источники аргумента: читательский, историко-культурный или жизненный опыт.
Не принимаются: комикс, аниме, манга, фанфик, графический роман, компьютерная игра.
**Подсказки:**
- Позиция = ответ автора на поставленную проблему
- Должна быть явно выражена, не просто угадана
- Можно цитировать или пересказывать
--- ---
## К4 — Отношение к позиции автора по проблеме исходного текста (01 балл) ## II. Речевое оформление
**1 балл**: Экзаменуемый выразил своё отношение к позиции автора и обосновал его. ### К4 — Фактическая точность речи (01 балл)
**0 баллов**: Экзаменуемый не выразил своё отношение или оно не обосновано. 1 балл: Фактические ошибки отсутствуют.
0 баллов: Допущена 1 фактическая ошибка или более.
**Подсказки:** Фактические ошибки: неверные имена/даты/названия, искажение содержания упоминаемых
- Формальное согласие/несогласие без обоснования = 0 текстов и событий, ошибки в именах реальных людей.
- Обоснование: личный опыт, примеры из жизни/литературы, логические доводы
- Фраза "я согласен с автором" без аргумента = 0
--- ---
## К5 — Смысловая цельность, речевая связность и последовательность изложения (02 балла) ### К5 — Логичность речи (02 балла)
**2 балла**: Работа характеризуется смысловой цельностью, речевой связностью и последовательностью изложения. Логических ошибок нет. Абзацное членение присутствует. 2 балла: Логические ошибки отсутствуют.
1 балл: 12 логические ошибки.
0 баллов: 3 и более логические ошибки.
**1 балл**: 12 логические ошибки ИЛИ нарушено абзацное членение. Типичные: нарушение последовательности, противоречия, нарушение причинно-следственных
связей, отсутствие связи между абзацами.
**0 баллов**: 3+ логические ошибки ИЛИ отсутствует абзацное членение.
**Типичные логические ошибки:**
- Нарушение последовательности мысли
- Отсутствие связи между абзацами
- Повтор одной мысли разными словами
- Противоречия в тексте
--- ---
## К6 — Точность и выразительность речи (02 балла) ### К6 — Соблюдение этических норм (01 балл)
**2 балла**: Работа характеризуется точностью выражения мысли, разнообразием грамматического строя речи. 1 балл: Этические ошибки отсутствуют.
0 баллов: Пропаганда экстремизма/фашизма/нетрадиционных ценностей, нецензурная брань,
**1 балл**: Работа характеризуется точностью выражения мысли, но однообразием грамматического строя речи ИЛИ выразительностью, но использованием неточных слов. материалы запрещённые среди несовершеннолетних, иностранные слова при наличии
общеупотребительных русских аналогов не из нормативных словарей.
**0 баллов**: Точность и выразительность речи существенно снижены.
**Примечание:** К6 не может быть выше К10. Если К10 = 0, то К6 = 0.
--- ---
## К7 — Соблюдение орфографических норм (03 балла) ## III. Грамотность
| Баллы | Количество ошибок | > Нормы К7К10 разработаны для сочинений от 150 слов.
|-------|-------------------| > При объёме менее 150 слов — работа не засчитывается, все баллы = 0.
| 3 | 0 ошибок |
| 2 | 1 ошибка |
| 1 | 23 ошибки |
| 0 | 4+ ошибки |
**Что считается ошибкой:** ### К7 — Орфография (03 балла)
- Неверное написание слова
- Слитное/раздельное/дефисное написание
- Прописные/строчные буквы
**Что НЕ считается ошибкой:** | Баллов | Ошибок |
- Описки (если исправлены) |--------|--------|
- Перенос слова | 3 | 0 |
| 2 | 12 |
| 1 | 34 |
| 0 | 5+ |
### К8 — Пунктуация (03 балла)
| Баллов | Ошибок |
|--------|--------|
| 3 | 0 |
| 2 | 12 |
| 1 | 34 |
| 0 | 5+ |
### К9 — Грамматика (03 балла)
| Баллов | Ошибок |
|--------|--------|
| 3 | 0 |
| 2 | 12 |
| 1 | 34 |
| 0 | 5+ |
Типичные: ошибки в управлении и согласовании, неправильное образование форм слова,
нарушение синтаксических норм.
### К10 — Речевые нормы (03 балла)
| Баллов | Ошибок |
|--------|--------|
| 3 | 0 |
| 2 | 12 |
| 1 | 34 |
| 0 | 5+ |
Типичные: тавтология, плеоназм, неуместное/несвойственное употребление слова.
--- ---
## К8 — Соблюдение пунктуационных норм (03 балла) ## Итоговая таблица (ЕГЭ 2026)
| Баллы | Количество ошибок | | К1 | К2 | К3 | К4 | К5 | К6 | К7 | К8 | К9 | К10 | Итого |
|-------|-------------------| |----|----|----|----|----|----|----|----|----|-----|-------|
| 3 | 0 ошибок | | 1 | 3 | 2 | 1 | 2 | 1 | 3 | 3 | 3 | 3 | 22 |
| 2 | 12 ошибки |
| 1 | 34 ошибки |
| 0 | 5+ ошибок |
**Типичные ошибки:** Изменения 2026 vs 2025:
- Пропуск запятой при однородных членах - К2: максимум снижен с 6 до 3 баллов
- Ошибки в сложных предложениях - К3: максимум увеличен с 1 до 2 баллов
- Неверное оформление прямой речи - К9, К10: максимум увеличен с 2 до 3 баллов каждый
- К11 (этика) и К12 (фактика) упразднены, включены в К5 и К6
--- - Итоговый максимум: 22 балла (было 25)
## К9 — Соблюдение языковых норм (грамматика) (02 балла)
| Баллы | Количество ошибок |
|-------|-------------------|
| 2 | 0 ошибок |
| 1 | 12 ошибки |
| 0 | 3+ ошибки |
**Типичные грамматические ошибки:**
- Ошибки в согласовании (управлении)
- Неправильное образование форм слова
- Нарушение синтаксических норм
---
## К10 — Соблюдение речевых норм (02 балла)
| Баллы | Количество ошибок |
|-------|-------------------|
| 2 | 01 ошибка |
| 1 | 23 ошибки |
| 0 | 4+ ошибки |
**Типичные речевые ошибки:**
- Тавтология (повтор одного слова/однокоренных слов)
- Плеоназм (масло масляное)
- Неуместное употребление слова
- Употребление слова в несвойственном значении
---
## К11 — Соблюдение этических норм (01 балл)
**1 балл**: Этические ошибки отсутствуют.
**0 баллов**: Допущена 1+ этическая ошибка.
**Этические ошибки:**
- Грубость, оскорбления в адрес кого-либо
- Национальная/религиозная нетерпимость
- Речевая агрессия
---
## К12 — Соблюдение фактологической точности (01 балл)
**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** |

View file

@ -1,563 +0,0 @@
<!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 — Соответствие (AF → цифра 17)</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">Задания 29 — 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">Задания 1018 — Выбор из 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>Задания 29 — 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>Задания 1018 — Выбор ответа <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="17" 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>

View file

@ -1,300 +0,0 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-sans); }
.wrap { padding: 1rem 0; }
.section { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 1rem 1.25rem; margin-bottom: 12px; }
.label { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.row { display: flex; gap: 8px; align-items: center; }
input[type=text], input[type=password] { flex: 1; font-size: 13px; }
.drop-zone { border: 1px dashed var(--color-border-secondary); border-radius: var(--border-radius-md); padding: 2rem; text-align: center; cursor: pointer; color: var(--color-text-secondary); font-size: 13px; transition: background 0.15s; position: relative; }
.drop-zone:hover { background: var(--color-background-secondary); }
.drop-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.preview-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.preview-img { width: 80px; height: 80px; object-fit: cover; border-radius: var(--border-radius-md); border: 0.5px solid var(--color-border-tertiary); }
.tab-row { display: flex; gap: 4px; margin-bottom: 12px; }
.tab { padding: 6px 14px; font-size: 13px; border-radius: var(--border-radius-md); cursor: pointer; border: 0.5px solid var(--color-border-tertiary); background: transparent; color: var(--color-text-secondary); }
.tab.active { background: var(--color-background-secondary); color: var(--color-text-primary); border-color: var(--color-border-secondary); }
.result-box { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; font-size: 13px; line-height: 1.7; color: var(--color-text-primary); white-space: pre-wrap; min-height: 80px; max-height: 420px; overflow-y: auto; }
.badge { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: var(--border-radius-md); }
.badge-ok { background: var(--color-background-success); color: var(--color-text-success); }
.badge-err { background: var(--color-background-danger); color: var(--color-text-danger); }
.badge-wait { background: var(--color-background-warning); color: var(--color-text-warning); }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--color-border-secondary); border-top-color: var(--color-text-secondary); border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
.score-table { width: 100%; font-size: 13px; border-collapse: collapse; }
.score-table td, .score-table th { padding: 4px 8px; border-bottom: 0.5px solid var(--color-border-tertiary); text-align: left; }
.score-table th { font-size: 11px; color: var(--color-text-secondary); font-weight: 400; }
.total-row td { font-weight: 500; border-top: 1px solid var(--color-border-secondary); }
</style>
<div class="wrap">
<div class="section">
<div class="label">Эндпоинт и модель</div>
<div class="row" style="margin-bottom:8px">
<input type="text" id="endpoint" value="https://llm.lambda.coredump.ru/v1" placeholder="https://...">
</div>
<div class="row" style="margin-bottom:8px">
<input type="text" id="model" value="openai/Qwen3.5-122B-A10B" placeholder="имя модели">
</div>
<div class="row">
<input type="password" id="apikey" placeholder="API key">
</div>
</div>
<div class="section">
<div class="label">Режим</div>
<div class="tab-row">
<button class="tab active" onclick="setMode('ocr',this)">OCR рукописи</button>
<button class="tab" onclick="setMode('grade',this)">Проверка сочинения</button>
<button class="tab" onclick="setMode('full',this)">Полный цикл</button>
</div>
<div id="pane-ocr">
<div class="label">Загрузи фото бланка (можно несколько)</div>
<div class="drop-zone" id="drop">
Нажми или перетащи изображения сюда
<input type="file" id="file-input" accept="image/*" multiple onchange="handleFiles(this.files)">
</div>
<div class="preview-grid" id="previews"></div>
</div>
<div id="pane-grade" style="display:none">
<div class="label">Текст сочинения</div>
<textarea id="essay-text" style="width:100%;height:160px;font-size:13px;padding:8px;border-radius:var(--border-radius-md);border:0.5px solid var(--color-border-tertiary);background:var(--color-background-primary);color:var(--color-text-primary);resize:vertical" placeholder="Вставьте текст сочинения..."></textarea>
</div>
<div id="pane-full" style="display:none">
<div class="label">Загрузи фото + автоматически распознаёт и проверит по критериям</div>
<div class="drop-zone">
Нажми или перетащи изображения сюда
<input type="file" id="file-input-full" accept="image/*" multiple onchange="handleFilesFull(this.files)">
</div>
<div class="preview-grid" id="previews-full"></div>
</div>
</div>
<div style="margin-bottom:12px">
<button onclick="run()" style="width:100%;padding:10px;font-size:14px">Запустить ↗</button>
</div>
<div class="section" id="status-section" style="display:none">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<span style="font-size:13px;font-weight:500">Результат</span>
<span id="status-badge" class="badge badge-wait">ожидание</span>
</div>
<div id="result-content"></div>
</div>
</div>
<script>
let mode = 'ocr';
let images = [];
let imagesFull = [];
function setMode(m, el) {
mode = m;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
document.getElementById('pane-ocr').style.display = m === 'ocr' ? '' : 'none';
document.getElementById('pane-grade').style.display = m === 'grade' ? '' : 'none';
document.getElementById('pane-full').style.display = m === 'full' ? '' : 'none';
}
function handleFiles(files) {
images = [];
const grid = document.getElementById('previews');
grid.innerHTML = '';
Array.from(files).forEach(f => {
const reader = new FileReader();
reader.onload = e => {
images.push({data: e.target.result.split(',')[1], type: f.type});
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-img';
grid.appendChild(img);
};
reader.readAsDataURL(f);
});
}
function handleFilesFull(files) {
imagesFull = [];
const grid = document.getElementById('previews-full');
grid.innerHTML = '';
Array.from(files).forEach(f => {
const reader = new FileReader();
reader.onload = e => {
imagesFull.push({data: e.target.result.split(',')[1], type: f.type});
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'preview-img';
grid.appendChild(img);
};
reader.readAsDataURL(f);
});
}
function showStatus(text, type) {
const s = document.getElementById('status-section');
s.style.display = '';
const badge = document.getElementById('status-badge');
badge.className = 'badge badge-' + type;
badge.textContent = type === 'wait' ? 'загрузка...' : type === 'ok' ? 'готово' : 'ошибка';
document.getElementById('result-content').innerHTML = text;
}
async function callAPI(messages, maxTokens) {
const endpoint = document.getElementById('endpoint').value.replace(/\/$/, '');
const model = document.getElementById('model').value;
const apikey = document.getElementById('apikey').value;
const headers = {'Content-Type': 'application/json'};
if (apikey) headers['Authorization'] = 'Bearer ' + apikey;
const resp = await fetch(endpoint + '/chat/completions', {
method: 'POST',
headers,
body: JSON.stringify({model, max_tokens: maxTokens || 2000, messages})
});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + await resp.text());
const data = await resp.json();
return data.choices?.[0]?.message?.content || '';
}
function buildImageContent(imgList, textPrompt) {
const content = [];
imgList.forEach(img => {
content.push({type: 'image_url', image_url: {url: `data:${img.type};base64,${img.data}`}});
});
content.push({type: 'text', text: textPrompt});
return content;
}
const CRITERIA = `К1 (0-1): Формулировка проблемы
К2 (0-6): Комментарий (2 примера + связь)
К3 (0-1): Позиция автора
К4 (0-1): Отношение к позиции автора
К5 (0-2): Смысловая цельность и связность
К6 (0-2): Точность и выразительность речи
К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): Языковые нормы (грамматика)
К10 (0-2): Речевые нормы
К11 (0-1): Этические нормы
К12 (0-1): Фактическая точность
Итого: 25 баллов
Правила: если К1=0 → К2,К3,К4 автоматически=0. К6 не может быть выше К10.`;
async function run() {
showStatus('<span class="spinner"></span>Отправляю запрос...', 'wait');
try {
if (mode === 'ocr') {
if (images.length === 0) throw new Error('Загрузи хотя бы одно изображение');
const content = buildImageContent(images,
'Ты эксперт по распознаванию рукописного текста. Внимательно прочитай рукописное сочинение ЕГЭ на изображении и выведи его текст максимально точно. Сохраняй абзацное деление. Зачёркнутые слова отмечай в скобках [зачёркнуто]. Не добавляй никаких комментариев — только текст.');
const text = await callAPI([{role:'user', content}], 2000);
showStatus(`<div class="result-box">${escHtml(text)}</div>`, 'ok');
} else if (mode === 'grade') {
const essay = document.getElementById('essay-text').value.trim();
if (!essay) throw new Error('Вставьте текст сочинения');
const prompt = `Ты эксперт-проверяющий ЕГЭ по русскому языку. Проверь сочинение по критериям ФИПИ и выставь баллы.
КРИТЕРИИ:
${CRITERIA}
СОЧИНЕНИЕ:
${essay}
Ответь строго в формате JSON без markdown-обёрток:
{"k1":0,"k2":0,"k3":0,"k4":0,"k5":0,"k6":0,"k7":0,"k8":0,"k9":0,"k10":0,"k11":0,"k12":0,"total":0,"comments":{"k1":"...","k2":"...","k3":"...","k4":"...","k5":"...","k6":"...","k7":"...","k8":"...","k9":"...","k10":"...","k11":"...","k12":"..."},"recommendations":["...","..."]}`;
const raw = await callAPI([{role:'user',content:prompt}], 2000);
renderGrading(raw);
} else if (mode === 'full') {
if (imagesFull.length === 0) throw new Error('Загрузи хотя бы одно изображение');
showStatus('<span class="spinner"></span>Шаг 1/2: распознаю рукопись...', 'wait');
const content = buildImageContent(imagesFull,
'Ты эксперт по распознаванию рукописного текста. Внимательно прочитай рукописное сочинение ЕГЭ на изображении и выведи его текст максимально точно. Сохраняй абзацное деление. Не добавляй никаких комментариев — только текст сочинения.');
const essayText = await callAPI([{role:'user', content}], 2000);
showStatus('<span class="spinner"></span>Шаг 2/2: проверяю по критериям...', 'wait');
const prompt = `Ты эксперт-проверяющий ЕГЭ по русскому языку. Проверь сочинение по критериям ФИПИ и выставь баллы.
КРИТЕРИИ:
${CRITERIA}
СОЧИНЕНИЕ:
${essayText}
Ответь строго в формате JSON без markdown-обёрток:
{"recognized_text":"...","k1":0,"k2":0,"k3":0,"k4":0,"k5":0,"k6":0,"k7":0,"k8":0,"k9":0,"k10":0,"k11":0,"k12":0,"total":0,"comments":{"k1":"...","k2":"...","k3":"...","k4":"...","k5":"...","k6":"...","k7":"...","k8":"...","k9":"...","k10":"...","k11":"...","k12":"..."},"recommendations":["...","..."]}`;
const raw = await callAPI([{role:'user',content:prompt}], 3000);
renderGrading(raw, essayText);
}
} catch(e) {
showStatus(`<div style="font-size:13px;color:var(--color-text-danger)">${escHtml(e.message)}</div>`, 'err');
}
}
function renderGrading(raw, ocrText) {
let data;
try {
const clean = raw.replace(/```json|```/g, '').trim();
data = JSON.parse(clean);
} catch(e) {
showStatus(`<div class="result-box">${escHtml(raw)}</div>`, 'ok');
return;
}
const keys = ['k1','k2','k3','k4','k5','k6','k7','k8','k9','k10','k11','k12'];
const names = {k1:'Формулировка проблемы',k2:'Комментарий',k3:'Позиция автора',k4:'Отношение к позиции',k5:'Связность',k6:'Точность речи',k7:'Орфография',k8:'Пунктуация',k9:'Языковые нормы',k10:'Речевые нормы',k11:'Этика',k12:'Фактика'};
const maxes = {k1:1,k2:6,k3:1,k4:1,k5:2,k6:2,k7:3,k8:3,k9:2,k10:2,k11:1,k12:1};
let rows = keys.map(k => {
const score = data[k] ?? '?';
const max = maxes[k];
const pct = typeof score === 'number' ? score/max : 0;
const color = pct === 1 ? 'var(--color-text-success)' : pct >= 0.5 ? 'var(--color-text-warning)' : 'var(--color-text-danger)';
return `<tr><td style="color:var(--color-text-secondary)">${k.toUpperCase()}</td><td>${names[k]}</td><td style="text-align:right;font-weight:500;color:${color}">${score}/${max}</td></tr>`;
}).join('');
const total = data.total ?? keys.reduce((s,k) => s + (data[k]||0), 0);
const totalColor = total >= 20 ? 'var(--color-text-success)' : total >= 13 ? 'var(--color-text-warning)' : 'var(--color-text-danger)';
let comments = '';
if (data.comments) {
comments = keys.map(k => data.comments[k] ? `<div style="margin-bottom:10px"><span style="font-size:12px;font-weight:500;color:var(--color-text-secondary)">${k.toUpperCase()} — </span><span style="font-size:13px">${escHtml(data.comments[k])}</span></div>` : '').join('');
}
let recs = '';
if (data.recommendations?.length) {
recs = data.recommendations.map((r,i) => `<div style="font-size:13px;margin-bottom:6px">${i+1}. ${escHtml(r)}</div>`).join('');
}
let ocrBlock = '';
if (ocrText || data.recognized_text) {
const t = data.recognized_text || ocrText;
ocrBlock = `<div style="margin-bottom:12px"><div class="label" style="margin-bottom:4px">Распознанный текст</div><div class="result-box" style="max-height:160px">${escHtml(t)}</div></div>`;
}
showStatus(`
${ocrBlock}
<table class="score-table">
<thead><tr><th>Кр.</th><th>Название</th><th style="text-align:right">Балл</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr class="total-row"><td colspan="2">Итого</td><td style="text-align:right;color:${totalColor}">${total}/25</td></tr></tfoot>
</table>
${comments ? `<div style="margin-top:14px"><div class="label" style="margin-bottom:8px">Комментарии</div>${comments}</div>` : ''}
${recs ? `<div style="margin-top:12px"><div class="label" style="margin-bottom:8px">Рекомендации</div>${recs}</div>` : ''}
`, 'ok');
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>');
}
</script>