|
7 | 7 | from pathlib import Path |
8 | 8 | from typing import Any |
9 | 9 |
|
10 | | -from fastapi import FastAPI, HTTPException |
| 10 | +from fastapi import FastAPI, HTTPException, Query |
11 | 11 | from fastapi.responses import FileResponse |
12 | 12 | from fastapi.staticfiles import StaticFiles |
13 | 13 | from pydantic import BaseModel |
@@ -37,6 +37,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: |
37 | 37 | app = FastAPI(title="Python Path", version="1.0.0", lifespan=lifespan) |
38 | 38 | app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") |
39 | 39 |
|
| 40 | +QUESTION_LESSON = {question["id"]: lesson for lesson in LESSONS for question in lesson["questions"]} |
| 41 | + |
40 | 42 |
|
41 | 43 | class Answer(BaseModel): |
42 | 44 | question_id: str |
@@ -90,20 +92,38 @@ def course_payload() -> dict: |
90 | 92 | return {"modules": module_payloads} |
91 | 93 |
|
92 | 94 |
|
| 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 | + |
93 | 121 | def dashboard_payload() -> dict: |
94 | 122 | snapshot = state() |
95 | 123 | course = course_payload() |
96 | 124 | completed = len(snapshot["lessons"]) |
97 | 125 | 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) |
107 | 127 | achievements = [ |
108 | 128 | { |
109 | 129 | "id": "first", |
@@ -208,25 +228,131 @@ def submit_lesson(lesson_id: str, submission: Submission) -> dict: |
208 | 228 | } |
209 | 229 |
|
210 | 230 |
|
| 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 | + |
211 | 347 | @app.get("/api/practice") |
212 | 348 | 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] |
226 | 352 | 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", |
230 | 356 | } |
231 | 357 |
|
232 | 358 |
|
|
0 commit comments