Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,19 @@ def on_startup():
finally:
db.close()

# Always run the Preparatory Module Level 1 curriculum sync independently from
# demo/legacy seed flags, mirroring the Master/Intermediate Module syncs above.
# This is idempotent: it only creates or completes PM -> PM-L1 -> Lessons 1-15 ->
# DPS 1-5. PM-L1's first 15 lessons intentionally replicate Bridge Module's first
# 15 lessons exactly -- see app/seed/preparatory_module_l1_config.py for why. It
# does not create students, teachers, assignments, attempts, or demo records.
from app.seed.seed_preparatory_module import seed as seed_preparatory_module
db = SessionLocal()
try:
seed_preparatory_module(db)
finally:
db.close()

app.include_router(health_router)
app.include_router(auth_router)
app.include_router(admin_router)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/question_engine/pm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from app.question_engine.pm.config import PMConfig
from app.question_engine.pm.generator import generate_pm_question_set
33 changes: 33 additions & 0 deletions backend/app/question_engine/pm/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from dataclasses import dataclass, field


@dataclass
class PMConfig:
"""Preparatory Module generation config.

Deliberately self-contained -- PM owns its own generator package (this
directory) instead of falling back to another module's engine, so a
future change to any other module's generator can never silently change
PM's output, and vice versa. See PM_L1_LESSONS in
app/seed/preparatory_module_l1_config.py for how these fields are
populated per DPS from the Bridge Module replica curriculum.
"""
module_code: str
level_code: str
lesson_number: int
dps_number: int
question_count: int = 10
rows: int = 3
concept_family: str = "DIRECT_ADD_LESS"
operation_focus: str = "ADD_LESS"
abacus_rule: str | None = None
target_numbers: list[int] = field(default_factory=list)
place_value: str = "ONES"
digit_pattern: str = "1D"
allow_negative_operands: bool = True
allow_negative_answer: bool = False
seed: str = "PM-SEED"
lesson_title: str | None = None
dps_title: str | None = None
generation_template: str = "DIRECT"
revision_templates: tuple[str, ...] = field(default_factory=tuple)
27 changes: 27 additions & 0 deletions backend/app/question_engine/pm/distractors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import random
from decimal import Decimal

from app.question_engine.smart_distractors import generate_smart_distractors


def generate_distractors(correct_answer: int, operands: list[int], rng: random.Random, allow_negative: bool = False) -> list[int]:
"""Every wrong option shares correct_answer's own last digit so a
units-digit shortcut can never eliminate an option, built from a real
Add/Less mistake (missed row, flipped sign, transposed/mid-digit slip)
rather than a naive small numeric offset. PM is add/subtract-only, so
this always uses that strategy -- see
app.question_engine.smart_distractors for the shared low-level math
utility (already used identically by MM, IM, and YLM; this is generic
"produce plausible wrong numeric options" arithmetic, not curriculum
logic, so sharing it does not create any cross-module curriculum
coupling).
"""
DecimalOperands = [Decimal(int(value)) for value in operands]
Selected = generate_smart_distractors(
Decimal(int(correct_answer)),
rng,
"ADD_SUBTRACT",
DecimalOperands,
allow_negative,
)
return [int(value) for value in Selected]
63 changes: 63 additions & 0 deletions backend/app/question_engine/pm/generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import random

from app.question_engine.option_utils import build_mcq_options, rebalance_correct_option_distribution
from app.question_engine.pm.config import PMConfig
from app.question_engine.pm.operands import generate_unique_operands, question_difficulty_stage
from app.question_engine.pm.validators import question_concept_trace, validate_question
from app.question_engine.pm.distractors import generate_distractors


def generate_pm_question_set(config: PMConfig) -> list[dict]:
"""Preparatory Module's own, fully independent question generator.

Does not import from question_engine.ylm/mm/im, and nothing in those
packages imports from here -- a bug or future change in any of them can
never change PM's output, and a bug here can never touch them. The
algorithm (bead-movement classification, complement-of-5/10 base pools,
difficulty staging) was authored specifically for PM's own use in
validators.py/operands.py in this same directory.
"""
questions: list[dict] = []
seen: set[tuple[int, ...]] = set()
rng = random.Random(config.seed)

for question_number in range(1, config.question_count + 1):
q_rng = random.Random(f"{config.seed}-Q{question_number}")
operands = generate_unique_operands(config, q_rng, seen)
if not validate_question(config, operands):
raise ValueError(f"Generated invalid PM question for lesson {config.lesson_number} DPS {config.dps_number}")
concept_trace = question_concept_trace(config, operands)
correct_answer = sum(operands)
distractors = generate_distractors(correct_answer, operands, q_rng, config.allow_negative_answer)
options = build_mcq_options(correct_answer, distractors, q_rng)
questions.append({
"question_number": question_number,
"display_type": "VERTICAL",
"operands": operands,
"operators": ["+" if n >= 0 else "-" for n in operands],
"correct_answer": correct_answer,
"options": options,
"seed": f"{config.seed}-Q{question_number}",
"metadata": {
"concept_family": config.concept_family,
"operation_focus": config.operation_focus,
"abacus_rule": config.abacus_rule,
"target_numbers": config.target_numbers,
"digit_pattern": config.digit_pattern,
"place_value": config.place_value,
"lesson_title": config.lesson_title,
"dps_title": config.dps_title,
"generation_template": config.generation_template,
"revision_templates": list(config.revision_templates or []),
"primary_concept_tag": concept_trace["primary_concept_tag"],
"primary_concept_label": concept_trace["primary_concept_label"],
"concept_tags": concept_trace["concept_tags"],
"concept_labels": concept_trace["concept_labels"],
"concept_validated": concept_trace["golden_step_validated"],
"concept_trace": concept_trace["step_trace"],
"difficulty_stage": question_difficulty_stage(question_number - 1),
"difficulty_progression": "EASY_TO_CHALLENGE",
},
})
seen.add(tuple(operands))
return rebalance_correct_option_distribution(questions)
Loading
Loading