Compare commits
4 commits
f0ee3ecc28
...
a54192b882
| Author | SHA1 | Date | |
|---|---|---|---|
| a54192b882 | |||
| 35ad7e2e0a | |||
| 89063a38b1 | |||
| fd0fba6823 |
6 changed files with 341 additions and 1172 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
.venv
|
.venv
|
||||||
|
.scans
|
||||||
|
|
|
||||||
|
|
@ -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, шаги 1–4)
|
||||||
|
Попроси подтвердить распознанный текст перед оценкой.
|
||||||
|
|
||||||
|
**Шаг 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: Распознавание + оценка
|
||||||
|
|
||||||
|
**Шаги 1–3** — те же, что в режиме 1 (сохранить, запустить, удалить).
|
||||||
|
|
||||||
|
**Шаг 4 — Получить и распознать ключи**
|
||||||
|
Если ключи текстом — использовать напрямую.
|
||||||
|
Если ключи фото — распознать через vision.
|
||||||
|
|
||||||
|
**Шаг 5 — Загрузить критерии**
|
||||||
|
Прочитай `references/english-listening-criteria.md`.
|
||||||
|
|
||||||
|
**Шаг 6 — Передать в LLM для сверки**
|
||||||
|
|
||||||
|
Промпт для модели (подставить реальные значения):
|
||||||
|
```
|
||||||
|
Ты эксперт-проверяющий ЕГЭ по английскому (аудирование, 2026).
|
||||||
|
|
||||||
|
Транскрипт ответов ученика (Whisper):
|
||||||
[TRANSCRIPT]
|
[TRANSCRIPT]
|
||||||
|
|
||||||
Извлечённые ответы ученика:
|
Извлечённые ответы ученика:
|
||||||
- Задание 1 (A-F): [TASK1]
|
- Задание 1 (A–F): [TASK1]
|
||||||
- Задания 2-9: [TASK2_9]
|
- Задания 2–3 (True/False/Not stated): [TASK2_3]
|
||||||
- Задания 10-18: [TASK10_18]
|
- Задания 4–9 (выбор 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
|
### Задания 2–3 — True / False / Not stated (макс. 2 балла)
|
||||||
import os
|
...
|
||||||
if os.path.exists(tmp_path):
|
Баллов: X / 2
|
||||||
os.remove(tmp_path)
|
|
||||||
|
### Задания 4–9 — Выбор ответа (макс. 6 баллов)
|
||||||
|
...
|
||||||
|
Баллов: X / 6 (или X / подблок)
|
||||||
|
|
||||||
|
### Итого: XX / 12
|
||||||
|
|
||||||
|
### Ошибки с пояснением:
|
||||||
|
- Задание 1B: ученик ответил 5, верный ответ 1
|
||||||
|
...
|
||||||
|
|
||||||
|
[Если были нераспознанные:]
|
||||||
|
Задания X, Y не были распознаны в аудио и засчитаны как неверные (0 баллов).
|
||||||
```
|
```
|
||||||
|
|
||||||
### Шаг 6 — Вывести результат пользователю
|
|
||||||
|
|
||||||
Три блока (Задание 1 / Задания 2-9 / Задания 10-18):
|
|
||||||
колонки — задание, ответ ученика, ключ, результат (✓/✗).
|
|
||||||
Итог: баллы по каждому блоку + общий итог из 23.
|
|
||||||
|
|
||||||
Если были нераспознанные задания — явно указать:
|
|
||||||
"Задания X, Y не были распознаны в аудио и засчитаны как неверные."
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Общие принципы проверки
|
## Общие принципы
|
||||||
|
|
||||||
- **Актуальность**: Критерии соответствуют демоверсии ФИПИ 2024–2025
|
- **Актуальность**: Критерии соответствуют ФИПИ ЕГЭ 2026
|
||||||
- **Строгость**: Придерживайся формулировок критериев, не занижай и не завышай
|
- **Строгость**: Придерживайся формулировок критериев точно
|
||||||
- **Пограничные случаи**: При сомнении между баллами — объясни оба варианта и выбери более обоснованный
|
- **Снятия**: Всегда объясняй конкретно что именно не выполнено и почему снят балл
|
||||||
- **Тон**: Конструктивный, поддерживающий. Это учебный инструмент, не карательный
|
- **Пограничные случаи**: При сомнении — объясни оба варианта, выбери более обоснованный
|
||||||
- **Совместимость**: Скилл написан без привязки к конкретной модели. Работает с любым multimodal агентом, поддерживающим vision и аудио (Qwen3, GPT-4o, и др.)
|
- **Тон**: Конструктивный, учебный инструмент — не карательный
|
||||||
|
- **Совместимость**: Скилл работает с любым multimodal агентом (Qwen3, GPT-4o и др.)
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,78 @@
|
||||||
# Критерии оценивания раздела "Аудирование" ЕГЭ по английскому языку
|
# Критерии оценивания раздела "Аудирование" ЕГЭ по английскому языку
|
||||||
## Статус: заглушка — будет заполнено в следующей итерации
|
## Источник: ФИПИ, спецификация ЕГЭ 2026
|
||||||
|
|
||||||
### Структура раздела (ЕГЭ 2024–2025)
|
|
||||||
|
|
||||||
**Задание 1** (B1) — установление соответствия: 6 высказываний → 7 утверждений
|
|
||||||
- 1 балл за каждое верное соответствие
|
|
||||||
- Максимум: 6 баллов
|
|
||||||
|
|
||||||
**Задания 2–9** (B2) — верно/неверно/не сказано (True/False/Not Stated)
|
|
||||||
- 1 балл за каждый верный ответ
|
|
||||||
- Максимум: 8 баллов
|
|
||||||
|
|
||||||
**Задания 10–18** — краткий ответ/выбор из нескольких вариантов
|
|
||||||
- 1 балл за каждый верный ответ
|
|
||||||
- Максимум: 9 баллов
|
|
||||||
|
|
||||||
**Итого по разделу "Аудирование": 20 первичных баллов**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## STT интеграция (планируется)
|
## Структура раздела "Аудирование"
|
||||||
|
|
||||||
Для обработки аудиозаписей потребуется:
|
Раздел состоит из 9 заданий (задания 1–9), максимум — 12 первичных баллов.
|
||||||
- OpenAI Whisper API (рекомендуется для русской и английской речи)
|
Каждое задание = 1 балл за верный ответ, 0 за неверный.
|
||||||
- Или Deepgram (альтернатива)
|
Критерии объективные — только совпадение с ключом, никакой субъективной оценки.
|
||||||
- Эндпоинт: `https://api.openai.com/v1/audio/transcriptions`
|
|
||||||
- Модель: `whisper-1`
|
|
||||||
|
|
||||||
Временное решение для MVP: попросить пользователя ввести ответы текстом.
|
---
|
||||||
|
|
||||||
|
## Задание 1 — Установление соответствия (1 задание, 6 баллов)
|
||||||
|
|
||||||
|
Формат: ученик слышит 6 высказываний от 6 говорящих. Нужно установить соответствие
|
||||||
|
каждого высказывания одному из 7 утверждений (одно утверждение — лишнее).
|
||||||
|
|
||||||
|
Ответ: буква A–F → цифра 1–7.
|
||||||
|
Оценивание: 1 балл за каждое верное соответствие, максимум 6 баллов.
|
||||||
|
Пример ответа ученика: A–3, B–1, C–5, D–7, E–2, F–4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задания 2–3 — Верно / Неверно / В тексте не сказано (2 задания, 2 балла)
|
||||||
|
|
||||||
|
Формат: ученик слышит диалог. По 2 утверждения, нужно определить:
|
||||||
|
- 1 (True) — утверждение соответствует диалогу
|
||||||
|
- 2 (False) — утверждение противоречит диалогу
|
||||||
|
- 3 (Not stated) — информация в диалоге не упоминается
|
||||||
|
|
||||||
|
Оценивание: 1 балл за каждый верный ответ, максимум 2 балла.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Задания 4–9 — Выбор из трёх вариантов (6 заданий, 6 баллов)
|
||||||
|
|
||||||
|
Формат: ученик слышит 6 монологов/диалогов. К каждому — вопрос с 3 вариантами ответа.
|
||||||
|
Нужно выбрать верный вариант: 1, 2 или 3.
|
||||||
|
|
||||||
|
Оценивание: 1 балл за каждый верный ответ, максимум 6 баллов (2 задания по 3 баллов).
|
||||||
|
|
||||||
|
> Примечание по структуре: в разных вариантах ФИПИ количество заданий блоков может
|
||||||
|
> незначительно варьироваться. Агент должен ориентироваться на фактические ключи
|
||||||
|
> проверяющего, а не на фиксированную нумерацию выше.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итоговая таблица
|
||||||
|
|
||||||
|
| Блок | Задания | Тип | Макс. баллов |
|
||||||
|
|------|---------|-----|-------------|
|
||||||
|
| Соответствие | 1 | A–F → 1–7 | 6 |
|
||||||
|
| True/False/Not stated | 2–3 | 1/2/3 | 2 |
|
||||||
|
| Выбор ответа | 4–9 | 1/2/3 | 6 (иногда делится на подблоки) |
|
||||||
|
| | | **Итого** | **12** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Алгоритм проверки агентом
|
||||||
|
|
||||||
|
### Что нужно для оценки
|
||||||
|
1. Распознанные ответы ученика (из аудиозаписи через recognition.py)
|
||||||
|
2. Правильные ответы (ключи) от проверяющего
|
||||||
|
|
||||||
|
### Логика сверки
|
||||||
|
- Задание 1: совпадение буква → цифра (A=3 у ученика, A=3 в ключе → 1 балл)
|
||||||
|
- Задания 2–9: совпадение цифры (ученик: 1, ключ: 1 → 1 балл)
|
||||||
|
- Нераспознанный ответ ("?") → 0 баллов
|
||||||
|
- Регистр не важен: "TRUE", "true", "T" — одно и то же
|
||||||
|
- Для соответствий принимать форматы: "A3", "A-3", "A=3", "A — 3"
|
||||||
|
|
||||||
|
### Формат вывода
|
||||||
|
|
||||||
|
Три блока с таблицами: задание | ответ ученика | ключ | результат (+ или -)
|
||||||
|
Итог по каждому блоку + общий итог из 12.
|
||||||
|
Список ошибок с пояснением: "Задание 1B: ученик — 5, верный ответ — 1".
|
||||||
|
Если есть нераспознанные задания — явно указать какие и что они засчитаны как 0.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
# Критерии оценивания сочинения ЕГЭ по русскому языку
|
# Критерии оценивания сочинения ЕГЭ по русскому языку
|
||||||
## Источник: ФИПИ, демоверсия 2024–2025
|
## Источник: ФИПИ, демоверсия ЕГЭ 2026 (задание 27)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Формулировка задания 27
|
||||||
|
|
||||||
|
Напишите сочинение-рассуждение по проблеме, поставленной в исходном тексте.
|
||||||
|
Сформулируйте позицию автора (рассказчика) по указанной проблеме.
|
||||||
|
Прокомментируйте, как в тексте раскрывается эта позиция. Включите в комментарий два
|
||||||
|
примера-иллюстрации из текста, важные для понимания позиции автора, и поясните их.
|
||||||
|
Укажите и поясните смысловую связь между примерами-иллюстрациями.
|
||||||
|
Сформулируйте и обоснуйте своё отношение к позиции автора. Включите в обоснование
|
||||||
|
пример-аргумент (читательский, историко-культурный или жизненный опыт).
|
||||||
|
|
||||||
|
Объём — не менее 150 слов.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -7,200 +21,151 @@
|
||||||
|
|
||||||
### Подсчёт слов
|
### Подсчёт слов
|
||||||
- Считаются все слова, включая служебные (предлоги, союзы, частицы)
|
- Считаются все слова, включая служебные (предлоги, союзы, частицы)
|
||||||
- Если слов **меньше 70** → все критерии = 0 (кроме К10)
|
- Инициалы с фамилией = одно слово («М.Ю. Лермонтов» — одно слово)
|
||||||
- Если слов **70–149** → рекомендуемый объём не выдержан, но работа проверяется
|
- Цифры при подсчёте не учитываются («5 лет» — одно слово, «пять лет» — два)
|
||||||
- Рекомендуемый объём: **150–300 слов**
|
- «всё-таки» — одно слово, «всё же» — два слова
|
||||||
|
- Если слов менее 150 → работа не засчитывается, все критерии = 0
|
||||||
|
- Если сочинение написано не по тексту → 0 баллов
|
||||||
|
- Если полный пересказ/переписывание исходного текста → 0 по К1–К10
|
||||||
|
|
||||||
### Правило нулевого К1
|
### Правило нулевого К1
|
||||||
Если К1 = 0 (проблема не сформулирована или сформулирована неверно):
|
Если К1 = 0 → К2 = 0, К3 = 0 автоматически
|
||||||
→ К2 = 0, К3 = 0, К4 = 0 **автоматически**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К1 — Формулировка проблем исходного текста (0–1 балл)
|
## I. Содержание сочинения
|
||||||
|
|
||||||
**1 балл**: Экзаменуемый (в той или иной форме) верно сформулировал одну из проблем исходного текста. Фактических ошибок, связанных с пониманием и формулировкой проблемы, нет.
|
### К1 — Отражение позиции автора (рассказчика) (0–1 балл)
|
||||||
|
|
||||||
**0 баллов**: Экзаменуемый не смог верно сформулировать ни одну из проблем исходного текста.
|
1 балл: Позиция автора по указанной проблеме сформулирована верно.
|
||||||
|
0 баллов: Позиция автора не сформулирована или сформулирована неверно.
|
||||||
|
|
||||||
**Подсказки для проверки:**
|
Важно (изменение 2026): проблема задана в формулировке задания. Ученик раскрывает
|
||||||
- Проблема должна быть сформулирована как вопрос или тезис
|
позицию АВТОРА по этой проблеме — не формулирует проблему самостоятельно.
|
||||||
- Она должна соответствовать содержанию исходного текста
|
|
||||||
- Допустимы разные формулировки одной и той же проблемы
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К2 — Комментарий к сформулированной проблеме исходного текста (0–6 баллов)
|
### К2 — Комментарий к позиции автора (0–3 балла)
|
||||||
|
|
||||||
Структура: **2 примера-иллюстрации** из текста + **смысловая связь** между ними.
|
Структура: 2 примера-иллюстрации с пояснениями + смысловая связь с пояснением.
|
||||||
|
|
||||||
| Баллы | Условия |
|
| Баллов | Условия |
|
||||||
|-------|---------|
|
|--------|---------|
|
||||||
| 6 | Оба примера с пояснениями + связь с анализом |
|
| 3 | 2 примера с пояснениями + смысловая связь с пояснением |
|
||||||
| 5 | Оба примера с пояснениями + связь без анализа |
|
| 2 | 2 примера с пояснениями + связь без пояснения ИЛИ связь не указана/неверна |
|
||||||
| 4 | Оба примера с пояснениями, связь не указана |
|
| 1 | 1 пример с пояснением |
|
||||||
| 3 | Один пример с пояснением + связь |
|
| 0 | Пример без пояснения ИЛИ нет примеров ИЛИ пересказ ИЛИ цитирование большого фрагмента ИЛИ комментарий без опоры на текст |
|
||||||
| 2 | Оба примера без пояснений + связь |
|
|
||||||
| 1 | Один пример без пояснения |
|
|
||||||
| 0 | Примеры отсутствуют или текст пересказан |
|
|
||||||
|
|
||||||
**Важно:**
|
Правила:
|
||||||
- Пример-иллюстрация = конкретный фрагмент/факт из текста с объяснением его роли
|
- Пример без пояснения не засчитывается
|
||||||
- Смысловая связь: сравнение, противопоставление, причина-следствие, вывод и т.д.
|
- Фактическая ошибка в комментарии учитывается по К4
|
||||||
- Пересказ без анализа = 0 баллов
|
- Не принимаются: комикс, аниме, манга, фанфик, графический роман, компьютерная игра
|
||||||
- Фактические ошибки в комментарии снижают балл (−1 за каждую, но не более −2)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К3 — Отражение позиции автора исходного текста (0–1 балл)
|
### К3 — Собственное отношение к позиции автора (0–2 балла)
|
||||||
|
|
||||||
**1 балл**: Позиция автора верно сформулирована. Фактических ошибок нет.
|
2 балла: Отношение сформулировано и обосновано + приведён пример-аргумент.
|
||||||
|
1 балл: Отношение обосновано, но аргумент не приведён.
|
||||||
|
ИЛИ Аргумент есть, но отношение формальное («Я согласен с автором»).
|
||||||
|
0 баллов: Только формальное согласие/несогласие без обоснования.
|
||||||
|
ИЛИ Отношение не сформулировано.
|
||||||
|
ИЛИ Не соответствует указанной проблеме.
|
||||||
|
|
||||||
**0 баллов**: Позиция автора не сформулирована или сформулирована неверно.
|
Источники аргумента: читательский, историко-культурный или жизненный опыт.
|
||||||
|
Не принимаются: комикс, аниме, манга, фанфик, графический роман, компьютерная игра.
|
||||||
**Подсказки:**
|
|
||||||
- Позиция = ответ автора на поставленную проблему
|
|
||||||
- Должна быть явно выражена, не просто угадана
|
|
||||||
- Можно цитировать или пересказывать
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К4 — Отношение к позиции автора по проблеме исходного текста (0–1 балл)
|
## II. Речевое оформление
|
||||||
|
|
||||||
**1 балл**: Экзаменуемый выразил своё отношение к позиции автора и обосновал его.
|
### К4 — Фактическая точность речи (0–1 балл)
|
||||||
|
|
||||||
**0 баллов**: Экзаменуемый не выразил своё отношение или оно не обосновано.
|
1 балл: Фактические ошибки отсутствуют.
|
||||||
|
0 баллов: Допущена 1 фактическая ошибка или более.
|
||||||
|
|
||||||
**Подсказки:**
|
Фактические ошибки: неверные имена/даты/названия, искажение содержания упоминаемых
|
||||||
- Формальное согласие/несогласие без обоснования = 0
|
текстов и событий, ошибки в именах реальных людей.
|
||||||
- Обоснование: личный опыт, примеры из жизни/литературы, логические доводы
|
|
||||||
- Фраза "я согласен с автором" без аргумента = 0
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К5 — Смысловая цельность, речевая связность и последовательность изложения (0–2 балла)
|
### К5 — Логичность речи (0–2 балла)
|
||||||
|
|
||||||
**2 балла**: Работа характеризуется смысловой цельностью, речевой связностью и последовательностью изложения. Логических ошибок нет. Абзацное членение присутствует.
|
2 балла: Логические ошибки отсутствуют.
|
||||||
|
1 балл: 1–2 логические ошибки.
|
||||||
|
0 баллов: 3 и более логические ошибки.
|
||||||
|
|
||||||
**1 балл**: 1–2 логические ошибки ИЛИ нарушено абзацное членение.
|
Типичные: нарушение последовательности, противоречия, нарушение причинно-следственных
|
||||||
|
связей, отсутствие связи между абзацами.
|
||||||
**0 баллов**: 3+ логические ошибки ИЛИ отсутствует абзацное членение.
|
|
||||||
|
|
||||||
**Типичные логические ошибки:**
|
|
||||||
- Нарушение последовательности мысли
|
|
||||||
- Отсутствие связи между абзацами
|
|
||||||
- Повтор одной мысли разными словами
|
|
||||||
- Противоречия в тексте
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К6 — Точность и выразительность речи (0–2 балла)
|
### К6 — Соблюдение этических норм (0–1 балл)
|
||||||
|
|
||||||
**2 балла**: Работа характеризуется точностью выражения мысли, разнообразием грамматического строя речи.
|
1 балл: Этические ошибки отсутствуют.
|
||||||
|
0 баллов: Пропаганда экстремизма/фашизма/нетрадиционных ценностей, нецензурная брань,
|
||||||
**1 балл**: Работа характеризуется точностью выражения мысли, но однообразием грамматического строя речи ИЛИ выразительностью, но использованием неточных слов.
|
материалы запрещённые среди несовершеннолетних, иностранные слова при наличии
|
||||||
|
общеупотребительных русских аналогов не из нормативных словарей.
|
||||||
**0 баллов**: Точность и выразительность речи существенно снижены.
|
|
||||||
|
|
||||||
**Примечание:** К6 не может быть выше К10. Если К10 = 0, то К6 = 0.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К7 — Соблюдение орфографических норм (0–3 балла)
|
## III. Грамотность
|
||||||
|
|
||||||
| Баллы | Количество ошибок |
|
> Нормы К7–К10 разработаны для сочинений от 150 слов.
|
||||||
|-------|-------------------|
|
> При объёме менее 150 слов — работа не засчитывается, все баллы = 0.
|
||||||
| 3 | 0 ошибок |
|
|
||||||
| 2 | 1 ошибка |
|
|
||||||
| 1 | 2–3 ошибки |
|
|
||||||
| 0 | 4+ ошибки |
|
|
||||||
|
|
||||||
**Что считается ошибкой:**
|
### К7 — Орфография (0–3 балла)
|
||||||
- Неверное написание слова
|
|
||||||
- Слитное/раздельное/дефисное написание
|
|
||||||
- Прописные/строчные буквы
|
|
||||||
|
|
||||||
**Что НЕ считается ошибкой:**
|
| Баллов | Ошибок |
|
||||||
- Описки (если исправлены)
|
|--------|--------|
|
||||||
- Перенос слова
|
| 3 | 0 |
|
||||||
|
| 2 | 1–2 |
|
||||||
|
| 1 | 3–4 |
|
||||||
|
| 0 | 5+ |
|
||||||
|
|
||||||
|
### К8 — Пунктуация (0–3 балла)
|
||||||
|
|
||||||
|
| Баллов | Ошибок |
|
||||||
|
|--------|--------|
|
||||||
|
| 3 | 0 |
|
||||||
|
| 2 | 1–2 |
|
||||||
|
| 1 | 3–4 |
|
||||||
|
| 0 | 5+ |
|
||||||
|
|
||||||
|
### К9 — Грамматика (0–3 балла)
|
||||||
|
|
||||||
|
| Баллов | Ошибок |
|
||||||
|
|--------|--------|
|
||||||
|
| 3 | 0 |
|
||||||
|
| 2 | 1–2 |
|
||||||
|
| 1 | 3–4 |
|
||||||
|
| 0 | 5+ |
|
||||||
|
|
||||||
|
Типичные: ошибки в управлении и согласовании, неправильное образование форм слова,
|
||||||
|
нарушение синтаксических норм.
|
||||||
|
|
||||||
|
### К10 — Речевые нормы (0–3 балла)
|
||||||
|
|
||||||
|
| Баллов | Ошибок |
|
||||||
|
|--------|--------|
|
||||||
|
| 3 | 0 |
|
||||||
|
| 2 | 1–2 |
|
||||||
|
| 1 | 3–4 |
|
||||||
|
| 0 | 5+ |
|
||||||
|
|
||||||
|
Типичные: тавтология, плеоназм, неуместное/несвойственное употребление слова.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## К8 — Соблюдение пунктуационных норм (0–3 балла)
|
## Итоговая таблица (ЕГЭ 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 | 1–2 ошибки |
|
|
||||||
| 1 | 3–4 ошибки |
|
|
||||||
| 0 | 5+ ошибок |
|
|
||||||
|
|
||||||
**Типичные ошибки:**
|
Изменения 2026 vs 2025:
|
||||||
- Пропуск запятой при однородных членах
|
- К2: максимум снижен с 6 до 3 баллов
|
||||||
- Ошибки в сложных предложениях
|
- К3: максимум увеличен с 1 до 2 баллов
|
||||||
- Неверное оформление прямой речи
|
- К9, К10: максимум увеличен с 2 до 3 баллов каждый
|
||||||
|
- К11 (этика) и К12 (фактика) упразднены, включены в К5 и К6
|
||||||
---
|
- Итоговый максимум: 22 балла (было 25)
|
||||||
|
|
||||||
## К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** |
|
|
||||||
|
|
|
||||||
|
|
@ -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 — Соответствие (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>
|
|
||||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue