Skip to content

Commit 0526105

Browse files
committed
Improve guided practice and protect progress
1 parent bc4d580 commit 0526105

8 files changed

Lines changed: 294 additions & 48 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
__pycache__/
33
.pytest_cache/
44
.ruff_cache/
5-
python_path.db
5+
python_path.db*
66
*.pyc

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
5656
- Перед каждым заданием показан план решения; подсказку можно открыть до ответа.
5757
- Следующий урок открывается после успешного прохождения предыдущего.
5858
- После каждого раздела открывается контрольная точка; для зачёта требуется 70% правильных ответов.
59-
- Режим практики отдаёт сначала задания, в которых ученик ошибался.
59+
- Практика работает сериями по три шага: текущая тема, повторение ошибок, смешанное повторение или выбранный открытый раздел.
60+
- Ошибки из практики автоматически попадают в отдельный мягкий режим повторения.
6061
- Код проверяется в изолированном Python-процессе с AST-фильтром и двухсекундным лимитом.
6162
- Один пользователь, локальный SQLite: можно заниматься без регистрации и облака.
6263

‎app/main.py‎

Lines changed: 152 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from typing import Any
99

10-
from fastapi import FastAPI, HTTPException
10+
from fastapi import FastAPI, HTTPException, Query
1111
from fastapi.responses import FileResponse
1212
from fastapi.staticfiles import StaticFiles
1313
from pydantic import BaseModel
@@ -37,6 +37,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
3737
app = FastAPI(title="Python Path", version="1.0.0", lifespan=lifespan)
3838
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
3939

40+
QUESTION_LESSON = {question["id"]: lesson for lesson in LESSONS for question in lesson["questions"]}
41+
4042

4143
class Answer(BaseModel):
4244
question_id: str
@@ -90,20 +92,38 @@ def course_payload() -> dict:
9092
return {"modules": module_payloads}
9193

9294

95+
def next_lesson_for(snapshot: dict) -> dict | None:
96+
"""Выбирает продолжение, не отправляя старого ученика в новый вводный блок."""
97+
unlocked = [
98+
item
99+
for item in LESSONS
100+
if status_for(item, snapshot["lessons"])["unlocked"]
101+
and item["id"] not in snapshot["lessons"]
102+
]
103+
if not unlocked:
104+
return None
105+
106+
has_legacy_progress = any(
107+
lesson["module_id"] != "gentle-start" and lesson["id"] in snapshot["lessons"]
108+
for lesson in LESSONS
109+
)
110+
has_gentle_start_progress = any(
111+
lesson["module_id"] == "gentle-start" and lesson["id"] in snapshot["lessons"]
112+
for lesson in LESSONS
113+
)
114+
if has_legacy_progress and not has_gentle_start_progress:
115+
legacy_unlocked = [item for item in unlocked if item["module_id"] != "gentle-start"]
116+
if legacy_unlocked:
117+
return legacy_unlocked[0]
118+
return unlocked[0]
119+
120+
93121
def dashboard_payload() -> dict:
94122
snapshot = state()
95123
course = course_payload()
96124
completed = len(snapshot["lessons"])
97125
total = len(LESSONS)
98-
next_lesson = next(
99-
(
100-
item
101-
for item in LESSONS
102-
if status_for(item, snapshot["lessons"])["unlocked"]
103-
and item["id"] not in snapshot["lessons"]
104-
),
105-
None,
106-
)
126+
next_lesson = next_lesson_for(snapshot)
107127
achievements = [
108128
{
109129
"id": "first",
@@ -208,25 +228,131 @@ def submit_lesson(lesson_id: str, submission: Submission) -> dict:
208228
}
209229

210230

231+
def available_practice_lessons(snapshot: dict) -> list[dict]:
232+
"""Возвращает завершённые уроки и текущий открытый шаг — не будущие темы."""
233+
lessons = [lesson for lesson in LESSONS if status_for(lesson, snapshot["lessons"])["unlocked"]]
234+
return lessons or [LESSONS[0]]
235+
236+
237+
def current_practice_lesson(lessons: list[dict], snapshot: dict) -> dict:
238+
return next(
239+
(lesson for lesson in lessons if lesson["id"] not in snapshot["lessons"]), lessons[-1]
240+
)
241+
242+
243+
def practice_modules(lessons: list[dict]) -> list[dict]:
244+
lesson_module_ids = {lesson["module_id"] for lesson in lessons}
245+
return [
246+
{"id": module["id"], "title": module["title"], "icon": module["icon"]}
247+
for module in MODULES
248+
if module["id"] in lesson_module_ids
249+
]
250+
251+
252+
def practice_questions(
253+
mode: str, module_id: str | None, limit: int, snapshot: dict
254+
) -> tuple[list[dict], str, str, str]:
255+
"""Собирает небольшую осмысленную серию вместо случайного одиночного вопроса."""
256+
lessons = available_practice_lessons(snapshot)
257+
current = current_practice_lesson(lessons, snapshot)
258+
all_questions = [question for lesson in lessons for question in lesson["questions"]]
259+
260+
if mode == "guided":
261+
questions = current["questions"]
262+
return (
263+
questions[:limit],
264+
"По текущему шагу",
265+
f"Закрепляем тему «{current['title']}» в спокойном порядке: понять, повторить, применить.",
266+
"Сначала опирайся на пример из урока. В этой серии не будет ничего из будущих тем.",
267+
)
268+
269+
if mode == "review":
270+
weak_ids = set(snapshot["weak_question_ids"])
271+
weak = [question for question in all_questions if question["id"] in weak_ids]
272+
if weak:
273+
filler = [question for question in current["questions"] if question not in weak]
274+
return (
275+
(weak + filler)[:limit],
276+
"Повторяем ошибки",
277+
"Здесь только уже встречавшиеся сложные места. Ошибка — повод потренировать навык, а не оценка.",
278+
"Прочитай план к каждому заданию и решай его заново, не пытаясь вспомнить прежний ответ.",
279+
)
280+
return (
281+
current["questions"][:limit],
282+
"Чистое повторение",
283+
"Пока нет ошибок для разбора — закрепим текущий шаг без спешки.",
284+
"После первых ошибок этот режим будет подбирать их автоматически.",
285+
)
286+
287+
if mode == "mixed":
288+
by_kind = {
289+
kind: [question for question in all_questions if question["kind"] == kind]
290+
for kind in ("choice", "input", "code")
291+
}
292+
questions = [by_kind[kind][0] for kind in ("choice", "input", "code") if by_kind[kind]]
293+
questions.extend(question for question in all_questions if question not in questions)
294+
return (
295+
questions[:limit],
296+
"Смешанная серия",
297+
"Небольшая серия из уже открытых тем: сначала вспомни правило, потом назови его и примени в коде.",
298+
"Темы могут отличаться, но каждая уже есть в твоём маршруте. Не спеши и читай план задания.",
299+
)
300+
301+
if mode == "module":
302+
if not module_id:
303+
raise HTTPException(status_code=422, detail="Выбери тему для тренировки")
304+
module_lessons = [lesson for lesson in lessons if lesson["module_id"] == module_id]
305+
if not module_lessons:
306+
raise HTTPException(status_code=403, detail="Эта тема ещё не открыта в маршруте")
307+
lesson = current_practice_lesson(module_lessons, snapshot)
308+
module = next(item for item in MODULES if item["id"] == module_id)
309+
return (
310+
lesson["questions"][:limit],
311+
f"Тема: {module['title']}",
312+
f"Тренируем «{lesson['title']}» внутри выбранной темы — от простого вопроса к коду.",
313+
"Если нужен пример, вернись к карточке урока: практика проверяет понимание, а не память наизусть.",
314+
)
315+
316+
raise HTTPException(status_code=422, detail="Неизвестный режим практики")
317+
318+
319+
@app.get("/api/practice/session")
320+
def practice_session(
321+
mode: str = "guided",
322+
module_id: str | None = None,
323+
limit: int = Query(default=3, ge=1, le=5),
324+
) -> dict:
325+
snapshot = state()
326+
questions, title, description, tip = practice_questions(mode, module_id, limit, snapshot)
327+
return {
328+
"mode": mode,
329+
"title": title,
330+
"description": description,
331+
"tip": tip,
332+
"questions": [
333+
{**public_question(question), "lesson_title": QUESTION_LESSON[question["id"]]["title"]}
334+
for question in questions
335+
],
336+
"available_modules": practice_modules(available_practice_lessons(snapshot)),
337+
"weak_count": len(
338+
[
339+
question_id
340+
for question_id in snapshot["weak_question_ids"]
341+
if question_id in QUESTION_BY_ID
342+
]
343+
),
344+
}
345+
346+
211347
@app.get("/api/practice")
212348
def practice() -> dict:
213-
snapshot = state()
214-
candidate_ids = [item for item in snapshot["weak_question_ids"] if item in QUESTION_BY_ID]
215-
if not candidate_ids:
216-
candidate_ids = [
217-
question["id"]
218-
for item in LESSONS
219-
if status_for(item, snapshot["lessons"])["unlocked"]
220-
for question in item["questions"]
221-
]
222-
if not candidate_ids:
223-
candidate_ids = [question["id"] for question in LESSONS[0]["questions"]]
224-
question = QUESTION_BY_ID[candidate_ids[0]]
225-
source = next(item for item in LESSONS if question in item["questions"])
349+
"""Сохраняет совместимость со старым экраном и внешними клиентами API."""
350+
session = practice_session()
351+
question = session["questions"][0]
226352
return {
227-
"question": public_question(question),
228-
"lesson_title": source["title"],
229-
"is_review": question["id"] in snapshot["weak_question_ids"],
353+
"question": question,
354+
"lesson_title": question["lesson_title"],
355+
"is_review": session["mode"] == "review",
230356
}
231357

232358

‎app/static/app.js‎

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,10 @@ function renderModule(module) {
9393
</section>`;
9494
}
9595

96-
function questionTemplate(question, number) {
96+
function questionTemplate(question, number, stageLabel = '') {
9797
const stages = ['Разминка: узнай идею', 'Повтори с опорой', 'Сделай сам, но по плану'];
98-
const head = `<div class="question-number">${stages[number - 1] || `Задание ${number}`}</div><div class="question-prompt">${rich(question.prompt)}</div>`;
98+
const label = stageLabel || stages[number - 1] || `Задание ${number}`;
99+
const head = `<div class="question-number">${label}</div><div class="question-prompt">${rich(question.prompt)}</div>`;
99100
const guide = question.guide
100101
? `<aside class="task-guide"><strong>🧭 Как подойти</strong><p>${rich(question.guide)}</p></aside>`
101102
: '';
@@ -194,23 +195,81 @@ async function renderLesson(id) {
194195
});
195196
}
196197

197-
async function renderPractice() {
198+
async function renderPractice(mode = 'guided', moduleId = '') {
198199
loading();
199-
const data = await api('/api/practice');
200-
const question = data.question;
201-
view.innerHTML = `<section class="practice-wrap"><a class="back-link" href="#/">← К маршруту</a><article class="practice-hero card"><p class="eyebrow">Тренировка</p><h1>${data.is_review ? 'Повторим слабое место' : 'Быстрая практика'}</h1><p class="lead">${data.is_review ? `Это задание из темы «${esc(data.lesson_title)}» стоит закрепить.` : `Небольшой повтор из темы «${esc(data.lesson_title)}».`}</p></article><form id="practice-form">${questionTemplate(question, 1)}<div class="submit-row"><button class="button" type="submit">Проверить <span>→</span></button><a class="button ghost" href="#/practice">Другое задание</a></div></form></section>`;
202-
const form = document.querySelector('#practice-form');
203-
bindQuestionControls(form);
204-
form.addEventListener('submit', async (event) => {
205-
event.preventDefault();
206-
const answer = getAnswers(form, [question])[0];
207-
try {
208-
const result = await api('/api/practice/submit', { method: 'POST', body: JSON.stringify(answer) });
209-
showInline(question.id, result.correct, result.message, result.checks);
210-
await refreshDashboard();
211-
if (result.correct) toast(`Верно! +${result.xp_gained} XP`);
212-
} catch (error) { toast(error.message); }
200+
const params = new URLSearchParams({ mode });
201+
if (moduleId) params.set('module_id', moduleId);
202+
const session = await api(`/api/practice/session?${params}`);
203+
let index = 0;
204+
let correctCount = 0;
205+
206+
const modeButton = (id, label, caption) => `<button class="practice-mode ${session.mode === id ? 'active' : ''}" type="button" data-practice-mode="${id}"><strong>${label}</strong><small>${caption}</small></button>`;
207+
const moduleOptions = session.available_modules.map((module) => `<option value="${esc(module.id)}" ${moduleId === module.id ? 'selected' : ''}>${esc(module.icon)} ${esc(module.title)}</option>`).join('');
208+
view.innerHTML = `<section class="practice-wrap"><a class="back-link" href="#/">← К маршруту</a>
209+
<article class="practice-hero card"><p class="eyebrow">Практика без прыжков</p><h1>${esc(session.title)}</h1><p class="lead">${esc(session.description)}</p>
210+
<div class="practice-modes">
211+
${modeButton('guided', '🌱 Текущий шаг', 'Одна тема, три понятных шага')}
212+
${modeButton('review', '🎯 Ошибки', session.weak_count ? `${session.weak_count} слабых мест` : 'Пока ошибок нет')}
213+
${modeButton('mixed', '🧩 Смешанная', 'Повтор уже открытых тем')}
214+
</div>
215+
<label class="practice-select"><span>Или выбери тему</span><select id="practice-module"><option value="">Выбрать открытую тему</option>${moduleOptions}</select></label>
216+
</article>
217+
<aside class="practice-brief"><strong>🧠 Перед началом</strong><p>${esc(session.tip)}</p></aside>
218+
<div id="practice-session"></div>
219+
</section>`;
220+
221+
document.querySelectorAll('[data-practice-mode]').forEach((button) => {
222+
button.addEventListener('click', () => renderPractice(button.dataset.practiceMode));
223+
});
224+
document.querySelector('#practice-module').addEventListener('change', (event) => {
225+
const selected = event.target.value;
226+
if (selected) renderPractice('module', selected);
213227
});
228+
229+
const sessionNode = document.querySelector('#practice-session');
230+
const renderSummary = () => {
231+
const total = session.questions.length;
232+
const message = correctCount === total
233+
? 'Отличная серия: все задания решены. Можно переходить к следующему шагу.'
234+
: `Верно ${correctCount} из ${total}. Ошибки уже добавлены в режим «Ошибки» — вернись к ним после небольшой паузы.`;
235+
sessionNode.innerHTML = `<section class="practice-summary card"><span class="practice-summary-icon">${correctCount === total ? '🏆' : '🔁'}</span><h2>Серия завершена</h2><p>${esc(message)}</p><div class="code-actions"><button class="button" type="button" data-practice-again>Ещё серия <span>→</span></button><a class="button ghost" href="#/">К маршруту</a></div></section>`;
236+
sessionNode.querySelector('[data-practice-again]').addEventListener('click', () => renderPractice(mode, moduleId));
237+
};
238+
239+
const renderStep = () => {
240+
const question = session.questions[index];
241+
const total = session.questions.length;
242+
const percent = Math.round((index / total) * 100);
243+
sessionNode.innerHTML = `<section class="practice-progress" aria-label="Прогресс серии"><div><strong>Серия: ${index + 1} из ${total}</strong><span>Тема: ${esc(question.lesson_title)}</span></div><div class="practice-progress-track"><i style="width:${percent}%"></i></div></section>
244+
<form id="practice-form">${questionTemplate(question, index + 1, `Шаг серии ${index + 1}`)}<div class="submit-row"><button class="button" type="submit">Проверить <span>→</span></button><span class="submit-note">Сначала прочитай план — он не уменьшает ценность ответа.</span></div></form>`;
245+
const form = document.querySelector('#practice-form');
246+
bindQuestionControls(form);
247+
form.addEventListener('submit', async (event) => {
248+
event.preventDefault();
249+
const button = form.querySelector('[type="submit"]');
250+
button.disabled = true;
251+
try {
252+
const answer = getAnswers(form, [question])[0];
253+
const result = await api('/api/practice/submit', { method: 'POST', body: JSON.stringify(answer) });
254+
if (result.correct) correctCount += 1;
255+
showInline(question.id, result.correct, result.message, result.checks);
256+
await refreshDashboard();
257+
if (result.correct) toast(`Верно! +${result.xp_gained} XP`);
258+
const isLast = index + 1 === total;
259+
form.querySelector('.submit-row').innerHTML = `<button class="button ${isLast ? 'blue' : ''}" type="button" data-practice-next>${isLast ? 'Завершить серию' : 'Следующее задание'} <span>→</span></button>`;
260+
form.querySelector('[data-practice-next]').addEventListener('click', () => {
261+
index += 1;
262+
if (index >= total) renderSummary();
263+
else renderStep();
264+
});
265+
} catch (error) {
266+
toast(error.message);
267+
button.disabled = false;
268+
}
269+
});
270+
};
271+
272+
renderStep();
214273
}
215274

216275
async function renderExam(moduleId) {

0 commit comments

Comments
 (0)