Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions app/error_hints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Короткие подсказки к распространённым ошибкам Python."""

from __future__ import annotations

import re


def translate_error(error_text: str) -> dict[str, str]:
"""Возвращает понятную новичку подсказку, сохраняя исходную ошибку."""
text = error_text or ""
lower = text.casefold()
title, hint = (
"Неизвестная ошибка",
"Прочитай текст ошибки и проверь последнюю изменённую строку кода.",
)

if "unexpected eof while parsing" in lower:
title, hint = "Незаконченный код", "Проверь, закрыты ли скобки, кавычки и все блоки кода."
elif "eol while scanning string literal" in lower or "unterminated string literal" in lower:
title, hint = "Незакрытая строка", "Проверь, есть ли закрывающая кавычка у строки."
elif "invalid character" in lower:
title, hint = (
"Недопустимый символ",
"Проверь строку: в код случайно мог попасть необычный символ.",
)
elif "syntaxerror" in lower:
title, hint = (
"Синтаксическая ошибка",
"Проверь знаки препинания, скобки, кавычки и написание команды.",
)
elif "expected an indented block" in lower or "unindent does not match" in lower:
title, hint = (
"Ошибка отступа",
"После двоеточия добавь отступ в 4 пробела и выровняй строки блока.",
)
elif "taberror" in lower:
title, hint = (
"Смешаны табы и пробелы",
"Используй для отступов только пробелы — по 4 на каждый уровень.",
)
elif "nameerror" in lower:
name = re.search(r"name ['\"](.+?)['\"] is not defined", text)
variable = f" «{name.group(1)}»" if name else ""
title, hint = (
"Неизвестное имя",
f"Проверь написание{variable}: переменную нужно объявить до использования.",
)
elif "typeerror" in lower:
title, hint = (
"Неподходящий тип данных",
"Проверь, какие значения участвуют в операции: строку и число нельзя сложить напрямую.",
)
elif "valueerror" in lower:
title, hint = (
"Некорректное значение",
"Проверь формат значения и то, подходит ли оно для этой операции.",
)
elif "indexerror" in lower:
title, hint = (
"Индекс вне диапазона",
"Проверь номер элемента: отсчёт в списке начинается с 0.",
)
elif "keyerror" in lower:
title, hint = "Ключ не найден", "Проверь название ключа и убедись, что он есть в словаре."
elif "attributeerror" in lower:
title, hint = (
"Нет такого свойства или метода",
"Проверь название метода и подходит ли он для этого типа данных.",
)
elif "zerodivisionerror" in lower:
title, hint = "Деление на ноль", "Перед делением убедись, что делитель не равен нулю."
elif "modulenotfounderror" in lower:
title, hint = (
"Модуль не найден",
"Проверь название модуля и его доступность в учебном редакторе.",
)
elif "importerror" in lower:
title, hint = (
"Ошибка импорта",
"Проверь название модуля или объекта, который хочешь импортировать.",
)
elif "timeouterror" in lower or "слишком долго" in lower:
title, hint = (
"Время выполнения истекло",
"Проверь условие цикла: оно должно когда-нибудь становиться ложным.",
)

return {"title": title, "hint": hint, "original": text}
8 changes: 5 additions & 3 deletions app/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,13 @@ def run_code(source: str, tests: list[dict]) -> dict:
tree = ast.parse(source)
SafetyVisitor().visit(tree)
except (SyntaxError, ValueError) as error:
error_text = f"{type(error).__name__}: {error}"
return {
"correct": False,
"message": str(error),
"stdout": "",
"stderr": "",
"error": str(error),
"error": error_text,
"timed_out": False,
}

Expand Down Expand Up @@ -186,12 +187,13 @@ def run_code(source: str, tests: list[dict]) -> dict:
check=False,
)
except subprocess.TimeoutExpired:
error_text = "TimeoutError: Код выполнялся слишком долго. Проверь условие цикла."
return {
"correct": False,
"message": "Код выполнялся слишком долго. Проверь условие цикла.",
"message": error_text,
"stdout": "",
"stderr": "",
"error": "Код выполнялся слишком долго. Проверь условие цикла.",
"error": error_text,
"timed_out": True,
}

Expand Down
10 changes: 9 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
public_question,
)
from app.db import DATABASE_PATH, connection, init_db, record_attempt, save_exam, save_lesson, state
from app.error_hints import translate_error
from app.evaluator import evaluate, run_code

APP_DIR = Path(__file__).parent
Expand Down Expand Up @@ -165,6 +166,8 @@ def grade_answers(items: list[Answer], allowed_ids: set[str]) -> tuple[list[dict
correct_count = 0
for question_id in allowed_ids:
result = evaluate(QUESTION_BY_ID[question_id], provided.get(question_id, ""))
if error_text := result.get("error"):
result["error_hint"] = translate_error(error_text)
record_attempt(question_id, result["correct"])
correct_count += int(result["correct"])
results.append({"question_id": question_id, **result})
Expand Down Expand Up @@ -253,6 +256,8 @@ def submit_practice(answer: Answer) -> dict:
if not question:
raise HTTPException(status_code=404, detail="Задание не найдено")
result = evaluate(question, answer.answer)
if error_text := result.get("error"):
result["error_hint"] = translate_error(error_text)
record_attempt(answer.question_id, result["correct"])
gained = 5 if result["correct"] else 0
if gained:
Expand All @@ -266,7 +271,10 @@ def check_code(payload: CodeCheck) -> dict:
question = QUESTION_BY_ID.get(payload.question_id)
if not question or question["kind"] != "code":
raise HTTPException(status_code=404, detail="Кодовое задание не найдено")
return evaluate(question, payload.answer)
result = evaluate(question, payload.answer)
if error_text := result.get("error"):
result["error_hint"] = translate_error(error_text)
return result


@app.post("/api/code/run")
Expand Down
51 changes: 45 additions & 6 deletions app/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function bindQuestionControls(scope) {
button.textContent = 'Проверяем…';
try {
const result = await api('/api/code/check', { method: 'POST', body: JSON.stringify({ question_id: questionId, answer: editor.value }) });
showInline(questionId, result.correct, result.message, result.checks);
showInline(questionId, result.correct, result.message, result.checks, result.error_hint);
} catch (error) { showInline(questionId, false, error.message); }
button.disabled = false;
button.textContent = '▷ Проверить код';
Expand Down Expand Up @@ -167,12 +167,15 @@ function showCodeOutput(questionId, result) {
node.textContent = output.join('\n') || 'Нет вывода.';
}

function showInline(questionId, correct, message, checks = []) {
function showInline(questionId, correct, message, checks = [], errorHint = null) {
const node = document.querySelector(`#result-${questionId}`);
if (!node) return;
const details = checks.length && !correct ? ` <small>(${checks.filter((item) => !item.passed).map((item) => `ожидалось ${esc(item.expected)}, получено ${esc(item.actual)}`).join('; ')})</small>` : '';
node.className = `inline-result visible ${correct ? 'ok' : 'no'}`;
node.innerHTML = `${correct ? '✓' : '↺'} ${esc(message)}${details}`;
const hint = errorHint
? `<div class="error-hint"><strong>${esc(errorHint.title)}</strong><span>${esc(errorHint.hint)}</span></div><small class="raw-error">${esc(errorHint.original)}</small>`
: `${correct ? '✓' : '↺'} ${esc(message)}`;
node.innerHTML = `${hint}${details}`;
}

function submissionResult(result, retryText = 'Попробовать ещё раз') {
Expand Down Expand Up @@ -203,7 +206,7 @@ async function renderLesson(id) {
const result = await api(`/api/lessons/${id}/submit`, { method: 'POST', body: JSON.stringify({ answers: getAnswers(form, lesson.questions) }) });
document.querySelector('#submission-result')?.remove();
form.insertAdjacentHTML('afterend', submissionResult(result));
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks));
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks, item.error_hint));
await refreshDashboard();
if (result.passed) toast(`+${result.xp_gained} XP — урок пройден!`);
} catch (error) { toast(error.message); }
Expand All @@ -223,7 +226,7 @@ async function renderPractice() {
const answer = getAnswers(form, [question])[0];
try {
const result = await api('/api/practice/submit', { method: 'POST', body: JSON.stringify(answer) });
showInline(question.id, result.correct, result.message, result.checks);
showInline(question.id, result.correct, result.message, result.checks, result.error_hint);
await refreshDashboard();
if (result.correct) toast(`Верно! +${result.xp_gained} XP`);
} catch (error) { toast(error.message); }
Expand All @@ -243,7 +246,7 @@ async function renderExam(moduleId) {
const result = await api(`/api/exams/${moduleId}/submit`, { method: 'POST', body: JSON.stringify({ answers: getAnswers(form, exam.questions) }) });
document.querySelector('#submission-result')?.remove();
form.insertAdjacentHTML('afterend', submissionResult(result, 'Пересдать экзамен'));
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks));
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks, item.error_hint));
await refreshDashboard();
if (result.passed) toast(`Экзамен сдан! +${result.xp_gained} XP`);
} catch (error) { toast(error.message); }
Expand Down Expand Up @@ -272,5 +275,41 @@ document.querySelector('#reset-button').addEventListener('click', async () => {
location.hash = '#/';
render();
});

document.addEventListener('keydown', (event) => {
const editor = event.target.closest('.code-editor');
if (!editor) return;
const { value, selectionStart: start, selectionEnd: end } = editor;
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const lineEndIndex = value.indexOf('\n', Math.max(start, end - 1));
const lineEnd = lineEndIndex < 0 ? value.length : lineEndIndex;

if (event.key === 'Enter') {
const line = value.slice(lineStart, lineEnd);
const indent = (line.match(/^\s*/) || [''])[0] + (line.trimEnd().endsWith(':') ? ' ' : '');
event.preventDefault();
editor.setRangeText(`\n${indent}`, start, end, 'end');
return;
}

if (event.key !== 'Tab') return;
event.preventDefault();
const selected = value.slice(lineStart, lineEnd);
if (!event.shiftKey && !selected.includes('\n')) {
editor.setRangeText(' ', start, end, 'end');
Comment on lines +298 to +299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve one-line selections when Tab-indenting

When a user selects code within a single line and presses Tab, selected contains the whole line and therefore has no newline, so this branch replaces the actual selection with four spaces. For example, selecting print('hello') to indent it deletes the statement instead of indenting the selected line; distinguish a collapsed selection (start === end) from a nonempty one-line selection.

Useful? React with 👍 / 👎.

return;
}
if (event.shiftKey) {
const unindented = selected.replace(/^ {1,4}/gm, '');
const removedBeforeCursor = Math.min(4, (value.slice(lineStart, start).match(/^ */) || [''])[0].length);
editor.value = value.slice(0, lineStart) + unindented + value.slice(lineEnd);
editor.setSelectionRange(start - removedBeforeCursor, end - (selected.length - unindented.length));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Shift+Tab selection endpoints aligned

When a multi-line selection ends inside the leading 1–3 spaces of its final line, this subtracts every space removed from that line even though some were after selectionEnd. For example, dedenting a selection from the first line's start through column 2 of the next four-space-indented line moves the endpoint two characters into the preceding line rather than to the next line's start, so subsequent typing operates on the wrong range.

Useful? React with 👍 / 👎.

} else {
const indented = selected.replace(/^/gm, ' ');
const lines = selected.split('\n').length;
editor.value = value.slice(0, lineStart) + indented + value.slice(lineEnd);
editor.setSelectionRange(start + 4, end + lines * 4);
}
});
window.addEventListener('hashchange', render);
render();
2 changes: 2 additions & 0 deletions app/static/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,16 @@ def test_lesson_requires_correct_code_for_completion() -> None:
response = client.post("/api/lessons/hello/submit", json={"answers": code_only})
assert response.json()["passed"] is False
client.post("/api/reset")


def test_code_check_includes_translated_error_hint() -> None:
with TestClient(app) as client:
response = client.post(
"/api/code/check",
json={"question_id": "hello-code", "answer": "if True print('Привет')"},
)

result = response.json()
assert result["correct"] is False
assert result["error_hint"]["title"] == "Синтаксическая ошибка"
assert result["error_hint"]["original"].startswith("SyntaxError:")
43 changes: 43 additions & 0 deletions tests/test_error_hints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import pytest

from app.error_hints import translate_error


@pytest.mark.parametrize(
("error_text", "title"),
[
("SyntaxError: invalid syntax", "Синтаксическая ошибка"),
("SyntaxError: unexpected EOF while parsing", "Незаконченный код"),
("SyntaxError: EOL while scanning string literal", "Незакрытая строка"),
("SyntaxError: invalid character '№'", "Недопустимый символ"),
("IndentationError: expected an indented block", "Ошибка отступа"),
("IndentationError: unindent does not match any outer indentation level", "Ошибка отступа"),
("TabError: inconsistent use of tabs and spaces in indentation", "Смешаны табы и пробелы"),
("TypeError: unsupported operand type(s)", "Неподходящий тип данных"),
("ValueError: invalid literal for int()", "Некорректное значение"),
("IndexError: list index out of range", "Индекс вне диапазона"),
("KeyError: 'name'", "Ключ не найден"),
(
"AttributeError: 'str' object has no attribute 'append'",
"Нет такого свойства или метода",
),
("ZeroDivisionError: division by zero", "Деление на ноль"),
("ImportError: cannot import name 'thing'", "Ошибка импорта"),
("ModuleNotFoundError: No module named 'thing'", "Модуль не найден"),
("TimeoutError: Код выполнялся слишком долго", "Время выполнения истекло"),
("RuntimeError: something unusual", "Неизвестная ошибка"),
],
)
def test_translate_error_categories(error_text: str, title: str) -> None:
result = translate_error(error_text)

assert result["title"] == title
assert result["hint"]
assert result["original"] == error_text


def test_translate_error_extracts_name_from_name_error() -> None:
result = translate_error("NameError: name 'total_sum' is not defined")

assert result["title"] == "Неизвестное имя"
assert "total_sum" in result["hint"]
Loading