From 4bf87a20d0e1f67b35cb4b682156cd1d237e3d3c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 11:48:59 +0300 Subject: [PATCH 1/2] feat: shuffled options, term synonyms, mixed exams (P0.3) --- app/extended_curriculum.py | 49 ++++++++++++++++++++++++++++++++------ tests/test_curriculum.py | 25 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/app/extended_curriculum.py b/app/extended_curriculum.py index c66775d..42af0e1 100644 --- a/app/extended_curriculum.py +++ b/app/extended_curriculum.py @@ -7,8 +7,24 @@ from __future__ import annotations +import random from typing import Any +from app.lessons_13_25 import LESSONS_13_25 + +TERM_SYNONYMS = { + "арифметический оператор": ("арифметика", "оператор"), + "оператор %": ("%", "остаток"), + "оператор and": ("and", "логическое и"), + "срез": ("slice", "срез строки"), + "метод replace": ("replace", "замена"), + "метод join": ("join", "соединение строк"), + "f-строка": ("f string", "форматированная строка"), + "кортеж": ("tuple",), + "распаковка": ("unpacking",), + "append": ("метод append", "добавление в список"), +} + def _theory(title: str, text: str, example: str, tip: str = "") -> dict[str, str]: return {"title": title, "text": text, "example": example, "tip": tip} @@ -1389,12 +1405,15 @@ def _make_questions( ) -> list[dict[str, Any]]: _, title, subtitle, keyword, example, concept, _ = lesson_spec code_task = _code_task(task_kind) + choice_id = f"{lesson_id}-choice" + options = [example, "print('Сначала пишем код, потом думаем')", "value = input() + 1"] + random.Random(f"{lesson_id}:{choice_id}").shuffle(options) return [ { - "id": f"{lesson_id}-choice", + "id": choice_id, "kind": "choice", "prompt": f"Какой пример относится к теме «{title}»?", - "options": [example, "print('Сначала пишем код, потом думаем')", "value = input() + 1"], + "options": options, "answer": example, "explanation": concept, }, @@ -1402,8 +1421,8 @@ def _make_questions( "id": f"{lesson_id}-term", "kind": "input", "prompt": f"Какой ключевой инструмент или термин связывает урок «{title}»?", - "answers": [keyword], - "placeholder": "Например: словарь", + "answers": [keyword, *TERM_SYNONYMS.get(keyword, ())], + "placeholder": f"Например: {keyword}", "explanation": f"Ключевой ориентир урока — «{keyword}». {subtitle}.", }, {"id": f"{lesson_id}-code", "kind": "code", **code_task}, @@ -1429,11 +1448,10 @@ def build_extended_course() -> tuple[ "icon": unit["icon"], } ) - question_ids: list[str] = [] + question_ids_by_kind: dict[str, list[str]] = {"choice": [], "input": [], "code": []} for lesson_index, spec in enumerate(unit["lessons"]): slug, title, subtitle, keyword, example, concept, advice = spec lesson_id = f"{module_id}-{slug}" - question_ids.append(f"{lesson_id}-choice") if order >= 26: questions = _make_questions(lesson_id, spec, TASK_CYCLES[unit_index][lesson_index]) lessons.append( @@ -1462,10 +1480,27 @@ def build_extended_course() -> tuple[ "questions": questions, } ) + else: + questions = next(lesson for lesson in LESSONS_13_25 if lesson["id"] == lesson_id)["questions"] + for question in questions: + question_ids_by_kind[question["kind"]].append(question["id"]) order += 1 + question_ids = [ + random.Random(f"{module_id}:{kind}").choice(ids) + for kind, ids in question_ids_by_kind.items() + ] + remaining_ids = [ + question_id + for ids in question_ids_by_kind.values() + for question_id in ids + if question_id not in question_ids + ] + random.Random(f"{module_id}:exam").shuffle(remaining_ids) + question_ids.append(remaining_ids[0]) + random.Random(f"{module_id}:exam-order").shuffle(question_ids) exams[module_id] = { "title": f"Контрольная точка: {unit['title']}", - "description": f"Четыре коротких вопроса по разделу «{unit['title']}».", + "description": f"Четыре вопроса разных типов по разделу «{unit['title']}».", "question_ids": question_ids, } return modules, lessons, exams diff --git a/tests/test_curriculum.py b/tests/test_curriculum.py index 53c002b..cba3fa9 100644 --- a/tests/test_curriculum.py +++ b/tests/test_curriculum.py @@ -110,3 +110,28 @@ def test_lessons_four_to_six_do_not_require_future_topics() -> None: assert QUESTION_BY_ID["while-code"]["tests"] == [ {"kind": "stdout", "expected": "5\n4\n3\n2\n1\nПуск!"} ] + + +def test_extended_questions_are_fair_and_exams_are_mixed() -> None: + extended_lessons = [lesson for lesson in LESSONS if lesson["order"] >= 13] + choice_positions = { + question["options"].index(question["answer"]) + for lesson in extended_lessons + for question in lesson["questions"] + if question["kind"] == "choice" + } + assert len(choice_positions) > 1 + assert ( + sum( + len(question["answers"]) > 1 + for lesson in extended_lessons + for question in lesson["questions"] + if question["kind"] == "input" + ) + >= 10 + ) + extended_modules = {lesson["module_id"] for lesson in extended_lessons} + for module_id in extended_modules: + assert { + QUESTION_BY_ID[question_id]["kind"] for question_id in EXAMS[module_id]["question_ids"] + } == {"choice", "input", "code"} From fc1df8c5b0e2b07fc363297f98705a01200ec30a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 11:58:26 +0300 Subject: [PATCH 2/2] fix: neutral placeholder, exam edge cases, stronger invariants (review) --- app/extended_curriculum.py | 11 +++++-- tests/test_curriculum.py | 64 +++++++++++++++++++++++++++++++++----- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/app/extended_curriculum.py b/app/extended_curriculum.py index 42af0e1..ca9aef2 100644 --- a/app/extended_curriculum.py +++ b/app/extended_curriculum.py @@ -13,6 +13,7 @@ from app.lessons_13_25 import LESSONS_13_25 TERM_SYNONYMS = { + "sorted": ("сортировка",), "арифметический оператор": ("арифметика", "оператор"), "оператор %": ("%", "остаток"), "оператор and": ("and", "логическое и"), @@ -1422,7 +1423,7 @@ def _make_questions( "kind": "input", "prompt": f"Какой ключевой инструмент или термин связывает урок «{title}»?", "answers": [keyword, *TERM_SYNONYMS.get(keyword, ())], - "placeholder": f"Например: {keyword}", + "placeholder": "Введите термин", "explanation": f"Ключевой ориентир урока — «{keyword}». {subtitle}.", }, {"id": f"{lesson_id}-code", "kind": "code", **code_task}, @@ -1481,13 +1482,16 @@ def build_extended_course() -> tuple[ } ) else: - questions = next(lesson for lesson in LESSONS_13_25 if lesson["id"] == lesson_id)["questions"] + questions = next(lesson for lesson in LESSONS_13_25 if lesson["id"] == lesson_id)[ + "questions" + ] for question in questions: question_ids_by_kind[question["kind"]].append(question["id"]) order += 1 question_ids = [ random.Random(f"{module_id}:{kind}").choice(ids) for kind, ids in question_ids_by_kind.items() + if ids ] remaining_ids = [ question_id @@ -1496,7 +1500,8 @@ def build_extended_course() -> tuple[ if question_id not in question_ids ] random.Random(f"{module_id}:exam").shuffle(remaining_ids) - question_ids.append(remaining_ids[0]) + if remaining_ids: + question_ids.append(remaining_ids[0]) random.Random(f"{module_id}:exam-order").shuffle(question_ids) exams[module_id] = { "title": f"Контрольная точка: {unit['title']}", diff --git a/tests/test_curriculum.py b/tests/test_curriculum.py index cba3fa9..75df7bf 100644 --- a/tests/test_curriculum.py +++ b/tests/test_curriculum.py @@ -1,9 +1,10 @@ import re from pathlib import Path +from app import extended_curriculum from app.content import EXAMS, LESSONS, MODULES, QUESTION_BY_ID -from app.evaluator import evaluate -from app.extended_curriculum import EXTRA_LESSONS +from app.evaluator import evaluate, normalize +from app.extended_curriculum import EXTRA_LESSONS, build_extended_course from app.lessons_13_25 import LESSONS_13_25 LESSONS_13_25_IDENTITY = [ @@ -114,13 +115,14 @@ def test_lessons_four_to_six_do_not_require_future_topics() -> None: def test_extended_questions_are_fair_and_exams_are_mixed() -> None: extended_lessons = [lesson for lesson in LESSONS if lesson["order"] >= 13] - choice_positions = { + choice_positions = [ question["options"].index(question["answer"]) for lesson in extended_lessons for question in lesson["questions"] if question["kind"] == "choice" - } - assert len(choice_positions) > 1 + ] + assert len(set(choice_positions)) > 1 + assert all(0 <= position < 3 for position in choice_positions) assert ( sum( len(question["answers"]) > 1 @@ -132,6 +134,52 @@ def test_extended_questions_are_fair_and_exams_are_mixed() -> None: ) extended_modules = {lesson["module_id"] for lesson in extended_lessons} for module_id in extended_modules: - assert { - QUESTION_BY_ID[question_id]["kind"] for question_id in EXAMS[module_id]["question_ids"] - } == {"choice", "input", "code"} + question_ids = EXAMS[module_id]["question_ids"] + assert len(question_ids) == len(set(question_ids)) == 4 + assert {QUESTION_BY_ID[question_id]["kind"] for question_id in question_ids} == { + "choice", + "input", + "code", + } + + term_question = next( + question + for lesson in extended_lessons + for question in lesson["questions"] + if question["kind"] == "input" and len(question["answers"]) > 1 + ) + assert all( + normalize(answer) not in normalize(term_question["placeholder"]) + for answer in term_question["answers"] + ) + + keyword, synonyms = "sorted", ("сортировка",) + answers = next( + question["answers"] + for lesson in extended_lessons + for question in lesson["questions"] + if question["kind"] == "input" and keyword in question["answers"] + ) + assert normalize(f" {synonyms[0].upper()} ") in {normalize(answer) for answer in answers} + + +def test_extended_generation_is_deterministic_and_handles_short_modules(monkeypatch) -> None: + first = build_extended_course() + second = build_extended_course() + assert first == second + + monkeypatch.setattr( + extended_curriculum, + "COURSE_UNITS", + [ + { + **extended_curriculum.COURSE_UNITS[0], + "lessons": extended_curriculum.COURSE_UNITS[0]["lessons"][:1], + } + ], + ) + monkeypatch.setattr( + extended_curriculum, "TASK_CYCLES", [extended_curriculum.TASK_CYCLES[0][:1]] + ) + _, _, exams = build_extended_course() + assert len(next(iter(exams.values()))["question_ids"]) == 3