From a91df82f5bf0147130a08fcfa9eb3f15765e41a9 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 11:47:51 +0300 Subject: [PATCH] feat: Tab/auto-indent in code editor + RU error dictionary (P1.4+P1.2) --- app/error_hints.py | 88 +++++++++++++++++++++++++++++++++++++++ app/evaluator.py | 8 ++-- app/main.py | 10 ++++- app/static/app.js | 51 ++++++++++++++++++++--- app/static/styles.css | 2 + tests/test_api.py | 13 ++++++ tests/test_error_hints.py | 43 +++++++++++++++++++ 7 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 app/error_hints.py create mode 100644 tests/test_error_hints.py 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"]