diff --git a/app/error_hints.py b/app/error_hints.py
new file mode 100644
index 0000000..967beb0
--- /dev/null
+++ b/app/error_hints.py
@@ -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}
diff --git a/app/evaluator.py b/app/evaluator.py
index 9541c0d..b1c642a 100644
--- a/app/evaluator.py
+++ b/app/evaluator.py
@@ -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,
}
@@ -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,
}
diff --git a/app/main.py b/app/main.py
index 290a1db..bd4b670 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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
@@ -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})
@@ -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:
@@ -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")
diff --git a/app/static/app.js b/app/static/app.js
index 14c5b8f..352530f 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -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 = '▷ Проверить код';
@@ -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 ? ` (${checks.filter((item) => !item.passed).map((item) => `ожидалось ${esc(item.expected)}, получено ${esc(item.actual)}`).join('; ')})` : '';
node.className = `inline-result visible ${correct ? 'ok' : 'no'}`;
- node.innerHTML = `${correct ? '✓' : '↺'} ${esc(message)}${details}`;
+ const hint = errorHint
+ ? `
${esc(errorHint.title)}${esc(errorHint.hint)}
${esc(errorHint.original)}`
+ : `${correct ? '✓' : '↺'} ${esc(message)}`;
+ node.innerHTML = `${hint}${details}`;
}
function submissionResult(result, retryText = 'Попробовать ещё раз') {
@@ -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); }
@@ -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); }
@@ -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); }
@@ -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');
+ 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));
+ } 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();
diff --git a/app/static/styles.css b/app/static/styles.css
index 6485069..4edebc7 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -77,3 +77,5 @@ h3 { margin: 0; font-size: 17px; }
.bottom-nav { display: none; }
@media (max-width: 760px) { .topbar { height: 63px; padding: 0 17px; }.brand { font-size: 19px; }.brand-mark { width: 33px; height: 33px; }.top-stats { gap: 9px; font-size: 12px; }.top-stats span:last-child { display: none; }.app-shell { display: block; padding: 22px 16px 95px; }.sidebar { display: none; }.hero { padding: 24px; border-radius: 21px; }.hero:after { font-size: 110px; right: 5px; bottom: -30px; }.hero .lead { max-width: 280px; }.stats-grid { gap: 9px; margin: 26px 0; }.stat-card { padding: 12px; }.stat-number { font-size: 20px; }.stat-icon { display: none; }.module-title { padding: 16px; }.module-title h2 { font-size: 17px; }.module-title p { font-size: 11px; }.module-progress { display: none; }.lesson-row { gap: 10px; padding: 10px; }.lesson-xp { display: none; }.badges { grid-template-columns: repeat(2, 1fr); }.bottom-nav { position: fixed; display: flex; justify-content: space-around; align-items: center; height: 65px; padding-bottom: env(safe-area-inset-bottom); bottom: 0; left: 0; right: 0; z-index: 15; background: rgba(255,255,255,.95); backdrop-filter: blur(10px); border-top: 1px solid var(--line); }.bottom-nav a { display: flex; flex-direction: column; align-items: center; gap: 2px; color: var(--muted); font-size: 10px; font-weight: 900; }.bottom-nav a span { font-size: 21px; }.bottom-nav a.active { color: var(--green-dark); }.lesson-head { display: block; }.lesson-head .button { margin-top: 16px; }.theory-card, .question-card { padding: 17px; }.question-prompt { font-size: 16px; }.submit-row { align-items: stretch; flex-direction: column; }.submit-row .button { width: 100%; }.exam-row { display: block; }.exam-row .button { margin-top: 12px; } }
+
+.error-hint { display: grid; gap: 3px; }.error-hint strong { font-weight: 900; }.error-hint span { font-weight: 700; }.raw-error { display: block; margin-top: 7px; color: #8f6870; font: 11px/1.4 "Source Code Pro", monospace; }
diff --git a/tests/test_api.py b/tests/test_api.py
index 8d0a621..4bad006 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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:")
diff --git a/tests/test_error_hints.py b/tests/test_error_hints.py
new file mode 100644
index 0000000..7fa4db7
--- /dev/null
+++ b/tests/test_error_hints.py
@@ -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"]