Skip to content

Commit a91df82

Browse files
rootroot
authored andcommitted
feat: Tab/auto-indent in code editor + RU error dictionary (P1.4+P1.2)
1 parent b32e9ed commit a91df82

7 files changed

Lines changed: 205 additions & 10 deletions

File tree

app/error_hints.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Короткие подсказки к распространённым ошибкам Python."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
8+
def translate_error(error_text: str) -> dict[str, str]:
9+
"""Возвращает понятную новичку подсказку, сохраняя исходную ошибку."""
10+
text = error_text or ""
11+
lower = text.casefold()
12+
title, hint = (
13+
"Неизвестная ошибка",
14+
"Прочитай текст ошибки и проверь последнюю изменённую строку кода.",
15+
)
16+
17+
if "unexpected eof while parsing" in lower:
18+
title, hint = "Незаконченный код", "Проверь, закрыты ли скобки, кавычки и все блоки кода."
19+
elif "eol while scanning string literal" in lower or "unterminated string literal" in lower:
20+
title, hint = "Незакрытая строка", "Проверь, есть ли закрывающая кавычка у строки."
21+
elif "invalid character" in lower:
22+
title, hint = (
23+
"Недопустимый символ",
24+
"Проверь строку: в код случайно мог попасть необычный символ.",
25+
)
26+
elif "syntaxerror" in lower:
27+
title, hint = (
28+
"Синтаксическая ошибка",
29+
"Проверь знаки препинания, скобки, кавычки и написание команды.",
30+
)
31+
elif "expected an indented block" in lower or "unindent does not match" in lower:
32+
title, hint = (
33+
"Ошибка отступа",
34+
"После двоеточия добавь отступ в 4 пробела и выровняй строки блока.",
35+
)
36+
elif "taberror" in lower:
37+
title, hint = (
38+
"Смешаны табы и пробелы",
39+
"Используй для отступов только пробелы — по 4 на каждый уровень.",
40+
)
41+
elif "nameerror" in lower:
42+
name = re.search(r"name ['\"](.+?)['\"] is not defined", text)
43+
variable = f" «{name.group(1)}»" if name else ""
44+
title, hint = (
45+
"Неизвестное имя",
46+
f"Проверь написание{variable}: переменную нужно объявить до использования.",
47+
)
48+
elif "typeerror" in lower:
49+
title, hint = (
50+
"Неподходящий тип данных",
51+
"Проверь, какие значения участвуют в операции: строку и число нельзя сложить напрямую.",
52+
)
53+
elif "valueerror" in lower:
54+
title, hint = (
55+
"Некорректное значение",
56+
"Проверь формат значения и то, подходит ли оно для этой операции.",
57+
)
58+
elif "indexerror" in lower:
59+
title, hint = (
60+
"Индекс вне диапазона",
61+
"Проверь номер элемента: отсчёт в списке начинается с 0.",
62+
)
63+
elif "keyerror" in lower:
64+
title, hint = "Ключ не найден", "Проверь название ключа и убедись, что он есть в словаре."
65+
elif "attributeerror" in lower:
66+
title, hint = (
67+
"Нет такого свойства или метода",
68+
"Проверь название метода и подходит ли он для этого типа данных.",
69+
)
70+
elif "zerodivisionerror" in lower:
71+
title, hint = "Деление на ноль", "Перед делением убедись, что делитель не равен нулю."
72+
elif "modulenotfounderror" in lower:
73+
title, hint = (
74+
"Модуль не найден",
75+
"Проверь название модуля и его доступность в учебном редакторе.",
76+
)
77+
elif "importerror" in lower:
78+
title, hint = (
79+
"Ошибка импорта",
80+
"Проверь название модуля или объекта, который хочешь импортировать.",
81+
)
82+
elif "timeouterror" in lower or "слишком долго" in lower:
83+
title, hint = (
84+
"Время выполнения истекло",
85+
"Проверь условие цикла: оно должно когда-нибудь становиться ложным.",
86+
)
87+
88+
return {"title": title, "hint": hint, "original": text}

app/evaluator.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,13 @@ def run_code(source: str, tests: list[dict]) -> dict:
150150
tree = ast.parse(source)
151151
SafetyVisitor().visit(tree)
152152
except (SyntaxError, ValueError) as error:
153+
error_text = f"{type(error).__name__}: {error}"
153154
return {
154155
"correct": False,
155156
"message": str(error),
156157
"stdout": "",
157158
"stderr": "",
158-
"error": str(error),
159+
"error": error_text,
159160
"timed_out": False,
160161
}
161162

@@ -186,12 +187,13 @@ def run_code(source: str, tests: list[dict]) -> dict:
186187
check=False,
187188
)
188189
except subprocess.TimeoutExpired:
190+
error_text = "TimeoutError: Код выполнялся слишком долго. Проверь условие цикла."
189191
return {
190192
"correct": False,
191-
"message": "Код выполнялся слишком долго. Проверь условие цикла.",
193+
"message": error_text,
192194
"stdout": "",
193195
"stderr": "",
194-
"error": "Код выполнялся слишком долго. Проверь условие цикла.",
196+
"error": error_text,
195197
"timed_out": True,
196198
}
197199

app/main.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
public_question,
2323
)
2424
from app.db import DATABASE_PATH, connection, init_db, record_attempt, save_exam, save_lesson, state
25+
from app.error_hints import translate_error
2526
from app.evaluator import evaluate, run_code
2627

2728
APP_DIR = Path(__file__).parent
@@ -165,6 +166,8 @@ def grade_answers(items: list[Answer], allowed_ids: set[str]) -> tuple[list[dict
165166
correct_count = 0
166167
for question_id in allowed_ids:
167168
result = evaluate(QUESTION_BY_ID[question_id], provided.get(question_id, ""))
169+
if error_text := result.get("error"):
170+
result["error_hint"] = translate_error(error_text)
168171
record_attempt(question_id, result["correct"])
169172
correct_count += int(result["correct"])
170173
results.append({"question_id": question_id, **result})
@@ -253,6 +256,8 @@ def submit_practice(answer: Answer) -> dict:
253256
if not question:
254257
raise HTTPException(status_code=404, detail="Задание не найдено")
255258
result = evaluate(question, answer.answer)
259+
if error_text := result.get("error"):
260+
result["error_hint"] = translate_error(error_text)
256261
record_attempt(answer.question_id, result["correct"])
257262
gained = 5 if result["correct"] else 0
258263
if gained:
@@ -266,7 +271,10 @@ def check_code(payload: CodeCheck) -> dict:
266271
question = QUESTION_BY_ID.get(payload.question_id)
267272
if not question or question["kind"] != "code":
268273
raise HTTPException(status_code=404, detail="Кодовое задание не найдено")
269-
return evaluate(question, payload.answer)
274+
result = evaluate(question, payload.answer)
275+
if error_text := result.get("error"):
276+
result["error_hint"] = translate_error(error_text)
277+
return result
270278

271279

272280
@app.post("/api/code/run")

app/static/app.js

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ function bindQuestionControls(scope) {
134134
button.textContent = 'Проверяем…';
135135
try {
136136
const result = await api('/api/code/check', { method: 'POST', body: JSON.stringify({ question_id: questionId, answer: editor.value }) });
137-
showInline(questionId, result.correct, result.message, result.checks);
137+
showInline(questionId, result.correct, result.message, result.checks, result.error_hint);
138138
} catch (error) { showInline(questionId, false, error.message); }
139139
button.disabled = false;
140140
button.textContent = '▷ Проверить код';
@@ -167,12 +167,15 @@ function showCodeOutput(questionId, result) {
167167
node.textContent = output.join('\n') || 'Нет вывода.';
168168
}
169169

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

178181
function submissionResult(result, retryText = 'Попробовать ещё раз') {
@@ -203,7 +206,7 @@ async function renderLesson(id) {
203206
const result = await api(`/api/lessons/${id}/submit`, { method: 'POST', body: JSON.stringify({ answers: getAnswers(form, lesson.questions) }) });
204207
document.querySelector('#submission-result')?.remove();
205208
form.insertAdjacentHTML('afterend', submissionResult(result));
206-
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks));
209+
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks, item.error_hint));
207210
await refreshDashboard();
208211
if (result.passed) toast(`+${result.xp_gained} XP — урок пройден!`);
209212
} catch (error) { toast(error.message); }
@@ -223,7 +226,7 @@ async function renderPractice() {
223226
const answer = getAnswers(form, [question])[0];
224227
try {
225228
const result = await api('/api/practice/submit', { method: 'POST', body: JSON.stringify(answer) });
226-
showInline(question.id, result.correct, result.message, result.checks);
229+
showInline(question.id, result.correct, result.message, result.checks, result.error_hint);
227230
await refreshDashboard();
228231
if (result.correct) toast(`Верно! +${result.xp_gained} XP`);
229232
} catch (error) { toast(error.message); }
@@ -243,7 +246,7 @@ async function renderExam(moduleId) {
243246
const result = await api(`/api/exams/${moduleId}/submit`, { method: 'POST', body: JSON.stringify({ answers: getAnswers(form, exam.questions) }) });
244247
document.querySelector('#submission-result')?.remove();
245248
form.insertAdjacentHTML('afterend', submissionResult(result, 'Пересдать экзамен'));
246-
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks));
249+
result.results.forEach((item) => showInline(item.question_id, item.correct, item.message, item.checks, item.error_hint));
247250
await refreshDashboard();
248251
if (result.passed) toast(`Экзамен сдан! +${result.xp_gained} XP`);
249252
} catch (error) { toast(error.message); }
@@ -272,5 +275,41 @@ document.querySelector('#reset-button').addEventListener('click', async () => {
272275
location.hash = '#/';
273276
render();
274277
});
278+
279+
document.addEventListener('keydown', (event) => {
280+
const editor = event.target.closest('.code-editor');
281+
if (!editor) return;
282+
const { value, selectionStart: start, selectionEnd: end } = editor;
283+
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
284+
const lineEndIndex = value.indexOf('\n', Math.max(start, end - 1));
285+
const lineEnd = lineEndIndex < 0 ? value.length : lineEndIndex;
286+
287+
if (event.key === 'Enter') {
288+
const line = value.slice(lineStart, lineEnd);
289+
const indent = (line.match(/^\s*/) || [''])[0] + (line.trimEnd().endsWith(':') ? ' ' : '');
290+
event.preventDefault();
291+
editor.setRangeText(`\n${indent}`, start, end, 'end');
292+
return;
293+
}
294+
295+
if (event.key !== 'Tab') return;
296+
event.preventDefault();
297+
const selected = value.slice(lineStart, lineEnd);
298+
if (!event.shiftKey && !selected.includes('\n')) {
299+
editor.setRangeText(' ', start, end, 'end');
300+
return;
301+
}
302+
if (event.shiftKey) {
303+
const unindented = selected.replace(/^ {1,4}/gm, '');
304+
const removedBeforeCursor = Math.min(4, (value.slice(lineStart, start).match(/^ */) || [''])[0].length);
305+
editor.value = value.slice(0, lineStart) + unindented + value.slice(lineEnd);
306+
editor.setSelectionRange(start - removedBeforeCursor, end - (selected.length - unindented.length));
307+
} else {
308+
const indented = selected.replace(/^/gm, ' ');
309+
const lines = selected.split('\n').length;
310+
editor.value = value.slice(0, lineStart) + indented + value.slice(lineEnd);
311+
editor.setSelectionRange(start + 4, end + lines * 4);
312+
}
313+
});
275314
window.addEventListener('hashchange', render);
276315
render();

app/static/styles.css

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/test_api.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,16 @@ def test_lesson_requires_correct_code_for_completion() -> None:
116116
response = client.post("/api/lessons/hello/submit", json={"answers": code_only})
117117
assert response.json()["passed"] is False
118118
client.post("/api/reset")
119+
120+
121+
def test_code_check_includes_translated_error_hint() -> None:
122+
with TestClient(app) as client:
123+
response = client.post(
124+
"/api/code/check",
125+
json={"question_id": "hello-code", "answer": "if True print('Привет')"},
126+
)
127+
128+
result = response.json()
129+
assert result["correct"] is False
130+
assert result["error_hint"]["title"] == "Синтаксическая ошибка"
131+
assert result["error_hint"]["original"].startswith("SyntaxError:")

tests/test_error_hints.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pytest
2+
3+
from app.error_hints import translate_error
4+
5+
6+
@pytest.mark.parametrize(
7+
("error_text", "title"),
8+
[
9+
("SyntaxError: invalid syntax", "Синтаксическая ошибка"),
10+
("SyntaxError: unexpected EOF while parsing", "Незаконченный код"),
11+
("SyntaxError: EOL while scanning string literal", "Незакрытая строка"),
12+
("SyntaxError: invalid character '№'", "Недопустимый символ"),
13+
("IndentationError: expected an indented block", "Ошибка отступа"),
14+
("IndentationError: unindent does not match any outer indentation level", "Ошибка отступа"),
15+
("TabError: inconsistent use of tabs and spaces in indentation", "Смешаны табы и пробелы"),
16+
("TypeError: unsupported operand type(s)", "Неподходящий тип данных"),
17+
("ValueError: invalid literal for int()", "Некорректное значение"),
18+
("IndexError: list index out of range", "Индекс вне диапазона"),
19+
("KeyError: 'name'", "Ключ не найден"),
20+
(
21+
"AttributeError: 'str' object has no attribute 'append'",
22+
"Нет такого свойства или метода",
23+
),
24+
("ZeroDivisionError: division by zero", "Деление на ноль"),
25+
("ImportError: cannot import name 'thing'", "Ошибка импорта"),
26+
("ModuleNotFoundError: No module named 'thing'", "Модуль не найден"),
27+
("TimeoutError: Код выполнялся слишком долго", "Время выполнения истекло"),
28+
("RuntimeError: something unusual", "Неизвестная ошибка"),
29+
],
30+
)
31+
def test_translate_error_categories(error_text: str, title: str) -> None:
32+
result = translate_error(error_text)
33+
34+
assert result["title"] == title
35+
assert result["hint"]
36+
assert result["original"] == error_text
37+
38+
39+
def test_translate_error_extracts_name_from_name_error() -> None:
40+
result = translate_error("NameError: name 'total_sum' is not defined")
41+
42+
assert result["title"] == "Неизвестное имя"
43+
assert "total_sum" in result["hint"]

0 commit comments

Comments
 (0)