Skip to content

Commit b32e9ed

Browse files
authored
Merge pull request #14 from gon7187/feat/generator-fairness
feat: shuffled options, term synonyms, mixed exams (P0.3)
2 parents e287740 + fc1df8c commit b32e9ed

2 files changed

Lines changed: 122 additions & 9 deletions

File tree

app/extended_curriculum.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,25 @@
77

88
from __future__ import annotations
99

10+
import random
1011
from typing import Any
1112

13+
from app.lessons_13_25 import LESSONS_13_25
14+
15+
TERM_SYNONYMS = {
16+
"sorted": ("сортировка",),
17+
"арифметический оператор": ("арифметика", "оператор"),
18+
"оператор %": ("%", "остаток"),
19+
"оператор and": ("and", "логическое и"),
20+
"срез": ("slice", "срез строки"),
21+
"метод replace": ("replace", "замена"),
22+
"метод join": ("join", "соединение строк"),
23+
"f-строка": ("f string", "форматированная строка"),
24+
"кортеж": ("tuple",),
25+
"распаковка": ("unpacking",),
26+
"append": ("метод append", "добавление в список"),
27+
}
28+
1229

1330
def _theory(title: str, text: str, example: str, tip: str = "") -> dict[str, str]:
1431
return {"title": title, "text": text, "example": example, "tip": tip}
@@ -1389,21 +1406,24 @@ def _make_questions(
13891406
) -> list[dict[str, Any]]:
13901407
_, title, subtitle, keyword, example, concept, _ = lesson_spec
13911408
code_task = _code_task(task_kind)
1409+
choice_id = f"{lesson_id}-choice"
1410+
options = [example, "print('Сначала пишем код, потом думаем')", "value = input() + 1"]
1411+
random.Random(f"{lesson_id}:{choice_id}").shuffle(options)
13921412
return [
13931413
{
1394-
"id": f"{lesson_id}-choice",
1414+
"id": choice_id,
13951415
"kind": "choice",
13961416
"prompt": f"Какой пример относится к теме «{title}»?",
1397-
"options": [example, "print('Сначала пишем код, потом думаем')", "value = input() + 1"],
1417+
"options": options,
13981418
"answer": example,
13991419
"explanation": concept,
14001420
},
14011421
{
14021422
"id": f"{lesson_id}-term",
14031423
"kind": "input",
14041424
"prompt": f"Какой ключевой инструмент или термин связывает урок «{title}»?",
1405-
"answers": [keyword],
1406-
"placeholder": "Например: словарь",
1425+
"answers": [keyword, *TERM_SYNONYMS.get(keyword, ())],
1426+
"placeholder": "Введите термин",
14071427
"explanation": f"Ключевой ориентир урока — «{keyword}». {subtitle}.",
14081428
},
14091429
{"id": f"{lesson_id}-code", "kind": "code", **code_task},
@@ -1429,11 +1449,10 @@ def build_extended_course() -> tuple[
14291449
"icon": unit["icon"],
14301450
}
14311451
)
1432-
question_ids: list[str] = []
1452+
question_ids_by_kind: dict[str, list[str]] = {"choice": [], "input": [], "code": []}
14331453
for lesson_index, spec in enumerate(unit["lessons"]):
14341454
slug, title, subtitle, keyword, example, concept, advice = spec
14351455
lesson_id = f"{module_id}-{slug}"
1436-
question_ids.append(f"{lesson_id}-choice")
14371456
if order >= 26:
14381457
questions = _make_questions(lesson_id, spec, TASK_CYCLES[unit_index][lesson_index])
14391458
lessons.append(
@@ -1462,10 +1481,31 @@ def build_extended_course() -> tuple[
14621481
"questions": questions,
14631482
}
14641483
)
1484+
else:
1485+
questions = next(lesson for lesson in LESSONS_13_25 if lesson["id"] == lesson_id)[
1486+
"questions"
1487+
]
1488+
for question in questions:
1489+
question_ids_by_kind[question["kind"]].append(question["id"])
14651490
order += 1
1491+
question_ids = [
1492+
random.Random(f"{module_id}:{kind}").choice(ids)
1493+
for kind, ids in question_ids_by_kind.items()
1494+
if ids
1495+
]
1496+
remaining_ids = [
1497+
question_id
1498+
for ids in question_ids_by_kind.values()
1499+
for question_id in ids
1500+
if question_id not in question_ids
1501+
]
1502+
random.Random(f"{module_id}:exam").shuffle(remaining_ids)
1503+
if remaining_ids:
1504+
question_ids.append(remaining_ids[0])
1505+
random.Random(f"{module_id}:exam-order").shuffle(question_ids)
14661506
exams[module_id] = {
14671507
"title": f"Контрольная точка: {unit['title']}",
1468-
"description": f"Четыре коротких вопроса по разделу «{unit['title']}».",
1508+
"description": f"Четыре вопроса разных типов по разделу «{unit['title']}».",
14691509
"question_ids": question_ids,
14701510
}
14711511
return modules, lessons, exams

tests/test_curriculum.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import re
22
from pathlib import Path
33

4+
from app import extended_curriculum
45
from app.content import EXAMS, LESSONS, MODULES, QUESTION_BY_ID
5-
from app.evaluator import evaluate
6-
from app.extended_curriculum import EXTRA_LESSONS
6+
from app.evaluator import evaluate, normalize
7+
from app.extended_curriculum import EXTRA_LESSONS, build_extended_course
78
from app.lessons_13_25 import LESSONS_13_25
89

910
LESSONS_13_25_IDENTITY = [
@@ -110,3 +111,75 @@ def test_lessons_four_to_six_do_not_require_future_topics() -> None:
110111
assert QUESTION_BY_ID["while-code"]["tests"] == [
111112
{"kind": "stdout", "expected": "5\n4\n3\n2\n1\nПуск!"}
112113
]
114+
115+
116+
def test_extended_questions_are_fair_and_exams_are_mixed() -> None:
117+
extended_lessons = [lesson for lesson in LESSONS if lesson["order"] >= 13]
118+
choice_positions = [
119+
question["options"].index(question["answer"])
120+
for lesson in extended_lessons
121+
for question in lesson["questions"]
122+
if question["kind"] == "choice"
123+
]
124+
assert len(set(choice_positions)) > 1
125+
assert all(0 <= position < 3 for position in choice_positions)
126+
assert (
127+
sum(
128+
len(question["answers"]) > 1
129+
for lesson in extended_lessons
130+
for question in lesson["questions"]
131+
if question["kind"] == "input"
132+
)
133+
>= 10
134+
)
135+
extended_modules = {lesson["module_id"] for lesson in extended_lessons}
136+
for module_id in extended_modules:
137+
question_ids = EXAMS[module_id]["question_ids"]
138+
assert len(question_ids) == len(set(question_ids)) == 4
139+
assert {QUESTION_BY_ID[question_id]["kind"] for question_id in question_ids} == {
140+
"choice",
141+
"input",
142+
"code",
143+
}
144+
145+
term_question = next(
146+
question
147+
for lesson in extended_lessons
148+
for question in lesson["questions"]
149+
if question["kind"] == "input" and len(question["answers"]) > 1
150+
)
151+
assert all(
152+
normalize(answer) not in normalize(term_question["placeholder"])
153+
for answer in term_question["answers"]
154+
)
155+
156+
keyword, synonyms = "sorted", ("сортировка",)
157+
answers = next(
158+
question["answers"]
159+
for lesson in extended_lessons
160+
for question in lesson["questions"]
161+
if question["kind"] == "input" and keyword in question["answers"]
162+
)
163+
assert normalize(f" {synonyms[0].upper()} ") in {normalize(answer) for answer in answers}
164+
165+
166+
def test_extended_generation_is_deterministic_and_handles_short_modules(monkeypatch) -> None:
167+
first = build_extended_course()
168+
second = build_extended_course()
169+
assert first == second
170+
171+
monkeypatch.setattr(
172+
extended_curriculum,
173+
"COURSE_UNITS",
174+
[
175+
{
176+
**extended_curriculum.COURSE_UNITS[0],
177+
"lessons": extended_curriculum.COURSE_UNITS[0]["lessons"][:1],
178+
}
179+
],
180+
)
181+
monkeypatch.setattr(
182+
extended_curriculum, "TASK_CYCLES", [extended_curriculum.TASK_CYCLES[0][:1]]
183+
)
184+
_, _, exams = build_extended_course()
185+
assert len(next(iter(exams.values()))["question_ids"]) == 3

0 commit comments

Comments
 (0)