13,689 new quiz questions in 160 practice islands (grades 3-12), Question.figure, replay rotates the quiz, LaTeX removed from lesson/question text

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:58:18 +03:30
parent f9eea56895
commit dd10ff83f4
14 changed files with 259891 additions and 640 deletions

View File

@@ -0,0 +1,12 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('content', '0018_split_long_calculus_nodes')]
operations = [
migrations.AddField(
model_name='question',
name='figure',
field=models.JSONField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,54 @@
"""
متنِ درس‌هایی که LaTeX خام داشتند (`\( \vec{a} \)`، `^{n}`) به متنِ ساده‌ی یونیکد برمی‌گردد.
فرانت LaTeX رندر نمی‌کند، پس دانش‌آموز بک‌اسلش و آکولاد می‌دید (درس‌های بردارِ دوازدهم).
فایل‌های seed همین تبدیل را گرفته‌اند، ولی `seed_content` متنِ مثال/قدم‌های درسِ موجود را
عوض نمی‌کند (فقط خانه‌ی خالی را پر می‌کند)، پس سرور بی‌این migration همان متنِ خراب را نگه می‌داشت.
تبدیل در `content/plain_math.py` است و idempotent: بار دوم هیچ‌چیز عوض نمی‌شود. معکوس ندارد
(متنِ قدیم بی‌فایده بود). نمودارها: فقط `FIGURE_TEXT_KEYS`، نه `expr`.
"""
import hashlib
from django.db import migrations
from content.plain_math import convert, convert_figures
def _h(text):
return hashlib.sha1((text or '').encode('utf-8')).hexdigest()
def _key(*parts):
return hashlib.sha1('\x1f'.join(('intro', *map(str, parts))).encode('utf-8')).hexdigest()
def forwards(apps, schema_editor):
Lesson = apps.get_model('content', 'Lesson')
SeedRecord = apps.get_model('content', 'SeedRecord')
qs = Lesson.objects.select_related('node__unit__grade')
for lesson in qs.iterator(chunk_size=500):
new = {
'intro_text': convert(lesson.intro_text),
'example': convert(lesson.example),
'steps': [convert(s) for s in (lesson.steps or [])],
'figures': convert_figures(lesson.figures or []),
}
changed = [f for f, v in new.items() if v != getattr(lesson, f)]
if not changed:
continue
old_intro = lesson.intro_text
for f in changed:
setattr(lesson, f, new[f])
lesson.save(update_fields=changed)
if 'intro_text' in changed:
# دفترِ seed «آخرین متنی که خودمان نوشتیم» را با هش نگه می‌دارد؛ هشِ قدیم بماند
# متنِ تازه «کارِ ادمین» حساب می‌شود و دیگر از فایل تازه نمی‌شود.
n = lesson.node
key = _key(n.unit.grade.title, n.unit.title, n.title, lesson.title)
SeedRecord.objects.filter(key=key, value=_h(old_intro)).update(value=_h(new['intro_text']))
class Migration(migrations.Migration):
dependencies = [('content', '0019_question_figure')]
operations = [migrations.RunPython(forwards, migrations.RunPython.noop)]

View File

@@ -167,6 +167,9 @@ class Question(models.Model):
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
prompt = models.TextField()
asset_group = models.CharField(max_length=100, blank=True)
# شکلِ سوال (همان قالبِ `Lesson.figures[].diagram`، اعتبارسنجی‌شده با `rag.services.diagram.validate`).
# null یعنی سوال شکل ندارد. هنگامِ نمایش هیچ فراخوانیِ مدل نیست؛ فرانت فقط می‌کشد.
figure = models.JSONField(null=True, blank=True)
def __str__(self):
return self.prompt[:50]

96
content/plain_math.py Normal file
View File

@@ -0,0 +1,96 @@
"""
LaTeX → متنِ ساده‌ی یونیکد، برای متنِ درس‌ها و سوال‌ها.
چرا: فرانت (`MathText`) هیچ LaTeX‌ای رندر نمی‌کند؛ هر `\\( \\vec{a} \\)` که در متنِ درس بماند
عیناً با بک‌اسلش و آکولاد دیده می‌شود (گزارشِ مالک از درس‌های بردارِ دوازدهم، ۲۰۲۶-۱۰-۱۲).
قراردادِ متنِ کتاب‌ها: بردارها بی‌فلش («بردار a»، «|a|»)، توان با نویسه‌ی بالانویس (²، ⁿ).
فقط فرمان‌هایی که در seed دیده شد پشتیبانی می‌شود. فرمانِ ناشناس **تغییر نمی‌کند** و
`leftover()` آن را برمی‌گرداند تا تست بشکند، نه این‌که بی‌صدا حدس زده شود.
"""
import re
_SUP = {**{str(i): c for i, c in enumerate('⁰¹²³⁴⁵⁶⁷⁸⁹')}, **{'۰۱۲۳۴۵۶۷۸۹'[i]: c for i, c in enumerate('⁰¹²³⁴⁵⁶⁷⁸⁹')}, '+': '⁺', '-': '⁻', '−': '⁻', '=': '⁼',
'(': '⁽', ')': '⁾', 'n': 'ⁿ', 'i': 'ⁱ', 'x': 'ˣ', 'y': 'ʸ', 'k': 'ᵏ', 'm': 'ᵐ', 'p': 'ᵖ', 't': 'ᵗ'}
_SUB = {**{str(i): c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, **{'۰۱۲۳۴۵۶۷۸۹'[i]: c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, '+': '₊', '-': '₋', '−': '₋', '=': '₌',
'(': '₍', ')': '₎', 'n': 'ₙ', 'i': 'ᵢ', 'x': 'ₓ', 'k': 'ₖ', 'm': 'ₘ', 'p': 'ₚ', 't': 'ₜ'}
_SYMBOLS = {
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'theta': 'θ', 'pi': 'π', 'alpha': 'α', 'beta': 'β',
'infty': '∞', 'neq': '≠', 'ne': '≠', 'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'to': '→',
'rightarrow': '→', 'Rightarrow': '⇒', 'perp': '⊥', 'parallel': '∥', 'circ': '°', 'ldots': '…', 'dots': '…',
'lim': 'lim', 'sin': 'sin', 'cos': 'cos', 'tan': 'tan', 'log': 'log', 'ln': 'ln',
}
_BLACKBOARD = {'R': 'ℝ', 'Q': 'ℚ', 'N': 'ℕ', 'Z': 'ℤ', 'C': 'ℂ'}
_GROUP = r'\{([^{}]*)\}'
def _script(body: str, table: dict, marker: str) -> str:
body = body.strip()
if body and all(ch in table for ch in body):
return ''.join(table[ch] for ch in body)
return f'{marker}({body})'
def _try(body: str, table: dict):
body = body.strip()
return ''.join(table[ch] for ch in body) if body and all(ch in table for ch in body) else None
def _atomic(s: str) -> bool:
return bool(re.fullmatch(r'[\w۰-۹.]+', s.strip()))
def _wrap(s: str) -> str:
s = s.strip()
return s if _atomic(s) else f'({s})'
def convert(text: str) -> str:
if not isinstance(text, str) or not re.search(r'[\\^]|[A-Za-z]_', text):
return text
s = text
s = re.sub(r'\\[(\[]\s*|\s*\\[)\]]', '', s) # \( \) \[ \] با فاصله‌ی داخلشان
for _ in range(4): # آکولادِ تودرتو، از داخل به بیرون
s = re.sub(r'\\vec' + _GROUP, r'\1', s)
s = re.sub(r'\\(?:overrightarrow|overline|bar|hat)' + _GROUP, r'\1', s)
s = re.sub(r'\\mathbb' + _GROUP, lambda m: _BLACKBOARD.get(m[1], m[1]), s)
s = re.sub(r'\\(?:text|mathrm|operatorname)' + _GROUP, r'\1', s)
s = re.sub(r'\\frac' + _GROUP + _GROUP, lambda m: f'{_wrap(m[1])}/{_wrap(m[2])}', s)
s = re.sub(r'\\sqrt' + _GROUP, lambda m: '√' + _wrap(m[1]), s)
s = re.sub(r'\\lim\s*_' + _GROUP, r'lim(\1)', s) # lim_{x→0} پایینِ حد است، نه زیرنویس
s = re.sub(r'\\(left|right)\s*', '', s)
s = s.replace('\\|', '|').replace('\\{', '{').replace('\\}', '}').replace('\\,', ' ').replace('\\ ', ' ')
s = re.sub(r'\\([A-Za-z]+)', lambda m: _SYMBOLS.get(m[1], m[0]), s)
s = re.sub(r'\^' + _GROUP, lambda m: _script(m[1], _SUP, '^'), s)
s = re.sub(r'_' + _GROUP, lambda m: _script(m[1], _SUB, '_'), s)
# شکل‌های بی‌آکولاد که کتاب‌ها و مدل‌ها نوشته‌اند: a^2، a^n، a^(m-n)، x_1. هر چه نویسه‌ی
# بالانویس/زیرنویس ندارد (a^b، D_f) همان‌طور می‌ماند. «___» (جای خالی) دست نمی‌خورد.
s = re.sub(r'\^\(([^()]*)\)', lambda m: _try(m[1], _SUP) or m[0], s)
s = re.sub(r'\^([+\-−]?[0-9۰-۹]+|[nixykmpt])(?![A-Za-z0-9۰-۹])', lambda m: _try(m[1], _SUP) or m[0], s)
s = re.sub(r'(?<=[A-Za-z\)])_([0-9۰-۹]+|[nikmpt])(?![A-Za-z0-9۰-۹])', lambda m: _try(m[1], _SUB) or m[0], s)
return s
_LEFTOVER = re.compile(r'\\[A-Za-z]+|\\[()\[\]]|\$|[_^]\{')
def leftover(text: str) -> list[str]:
return _LEFTOVER.findall(text) if isinstance(text, str) else []
# کلیدهایی که در `Lesson.figures` متنِ نمایشی‌اند. `expr` (فرمولِ نمودار) و نامِ نقطه‌ها
# (`point`/`from`/`to`) هرگز نباید بازنویسی شوند: `x^2` در expr را به «x²» ببری نمودار می‌شکند.
FIGURE_TEXT_KEYS = frozenset({'title', 'text', 'label', 'labels'})
def convert_figures(obj, _key=None):
"""فقط رشته‌های زیرِ `FIGURE_TEXT_KEYS` را (در هر عمقی) تبدیل می‌کند."""
if isinstance(obj, str):
return convert(obj) if _key in FIGURE_TEXT_KEYS else obj
if isinstance(obj, list):
return [convert_figures(v, _key) for v in obj]
if isinstance(obj, dict):
return {k: convert_figures(v, k) for k, v in obj.items()}
return obj

View File

@@ -24,6 +24,8 @@ import json
from django.db import transaction
from rag.services import diagram as diagram_spec
from .models import Choice, Lesson, Question
VALID_TYPES = {value for value, _ in Question.TYPE_CHOICES}
@@ -63,12 +65,20 @@ def _validate_row(row, index: int) -> dict:
if not isinstance(correct, int) or not (0 <= correct < len(choices)):
raise ImportError_(f'{where}: correct باید شماره‌ی یکی از گزینه‌ها باشد (از صفر).')
figure = row.get('figure')
if figure is not None:
# شکلِ نامعتبر بی‌صدا حذف نمی‌شود (برخلافِ دستیار): فایلِ محتوا باید خراب‌بودنش را نشان دهد.
figure = diagram_spec.validate(figure) if isinstance(figure, dict) else None
if figure is None:
raise ImportError_(f'{where}: «figure» شکلِ معتبر نیست (قالبِ rag.services.diagram).')
return {
'lesson_id': lesson_id,
'prompt': prompt,
'type': qtype,
'choices': choices,
'correct': correct,
'figure': figure,
}
@@ -135,6 +145,7 @@ def import_questions(
continue
question = Question.objects.create(
lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'],
figure=row.get('figure'),
)
Choice.objects.bulk_create([
Choice(question=question, value=value, is_correct=(i == row['correct']))
@@ -157,13 +168,16 @@ def export_questions(queryset) -> list[dict]:
for question in queryset.prefetch_related('choices').select_related('lesson'):
choices = list(question.choices.all())
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
out.append({
row = {
'lessonId': question.lesson_id,
'prompt': question.prompt,
'type': question.type,
'choices': [c.value for c in choices],
'correct': correct,
})
}
if question.figure:
row['figure'] = question.figure
out.append(row)
return out
@@ -189,13 +203,16 @@ def export_questions_by_path(queryset) -> list[dict]:
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
lesson = question.lesson
node = lesson.node
out.append({
row = {
'path': [node.unit.grade.title, node.unit.title, node.title, lesson.title],
'prompt': question.prompt,
'type': question.type,
'choices': [c.value for c in choices],
'correct': correct,
})
}
if question.figure:
row['figure'] = question.figure
out.append(row)
return out

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -138,6 +138,8 @@ class QuestionSerializer(serializers.Serializer):
type = serializers.CharField()
prompt = serializers.CharField()
options = ChoiceOptionSerializer(many=True)
# شکلِ سوال (توصیفِ JSON مثلِ `LessonFigure.diagram`)؛ null یعنی شکل ندارد.
figure = serializers.JSONField(allow_null=True, required=False)
class QuizQuestionSerializer(serializers.Serializer):
@@ -155,6 +157,7 @@ class QuizQuestionSerializer(serializers.Serializer):
type = serializers.CharField()
prompt = serializers.CharField()
options = ChoiceOptionSerializer(many=True)
figure = serializers.JSONField(allow_null=True, required=False)
class NodeQuizResponseSerializer(serializers.Serializer):

View File

@@ -231,3 +231,37 @@ class NodeQuizCompleteTests(TestCase):
self.assertEqual(body['stats']['progressPercent'], 10.0) # ۱ از ۱۰ درس
self.assertTrue(UserLessonCompletion.objects.filter(user_id=self.user.id).exists())
class NodeQuizRotationTests(TestCase):
"""دوباره‌بازیِ یک جزیره آزمونِ تازه می‌دهد؛ رفرش تا پایانِ آزمون همان را می‌دهد."""
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='اول', order=1)
for i in range(4):
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
for j in range(12):
question = Question.objects.create(
lesson=lesson, type='multiple_choice', prompt=f'سوال {i}-{j}')
Choice.objects.create(question=question, value='الف', is_correct=True)
Choice.objects.create(question=question, value='ب', is_correct=False)
self.user = User.objects.create(phone='09120000019', grade_id=grade.id)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def _ids(self):
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz', **self.auth).json()
return [q['id'] for q in body['questions']]
def test_refresh_keeps_the_quiz_until_it_is_completed(self):
self.assertEqual(self._ids(), self._ids())
def test_each_completed_round_draws_a_fresh_quiz(self):
first = self._ids()
UserNodeCompletion.objects.create(
user_id=self.user.id, node=self.node, correct_count=4, total_questions=4, stars=3)
second = self._ids()
self.assertNotEqual(first, second)
self.assertEqual(second, self._ids())

View File

@@ -0,0 +1,52 @@
import json
from pathlib import Path
from django.test import SimpleTestCase
from content.plain_math import convert, convert_figures, leftover
SEED = Path(__file__).parent / 'seed' / 'curriculum.json'
class ConvertTests(SimpleTestCase):
def test_vector_and_sets(self):
self.assertEqual(convert(r'بردار \( \vec{a} \) و \( \vec{AB} \)'), 'بردار a و AB')
self.assertEqual(convert(r'از \( \mathbb{Q} \) و \( \mathbb{R} \)'), 'از ℚ و ℝ')
def test_norm_sqrt_powers(self):
self.assertEqual(convert(r'\( \|\vec{a}\| = \sqrt{x^2 + y^2} \)'), '|a| = √(x² + y²)')
self.assertEqual(convert('(−۱)^{۲+۲}'), '(−۱)²⁺²')
def test_unmappable_script_is_kept_readable_not_dropped(self):
self.assertEqual(convert('D_{g}'), 'D_(g)')
self.assertEqual(convert('a^b'), 'a^b')
def test_blank_placeholder_untouched(self):
self.assertEqual(convert('3، 6، ___، 12'), '3، 6، ___، 12')
def test_idempotent(self):
s = r'\( \frac{1}{2} \vec{a}^{n} \)'
self.assertEqual(convert(convert(s)), convert(s))
def test_figure_expr_is_never_rewritten(self):
fig = [{'diagram': {'items': [{'type': 'function', 'expr': 'x^2', 'label': 'f(x)=x^2'}]}}]
out = convert_figures(fig)[0]['diagram']['items'][0]
self.assertEqual(out['expr'], 'x^2')
self.assertEqual(out['label'], 'f(x)=x²')
class SeedHasNoLatexTests(SimpleTestCase):
"""گزارشِ مالک ۲۰۲۶-۱۰-۱۲: LaTeX خام در درس‌های بردار. فرانت LaTeX رندر نمی‌کند."""
def test_no_latex_in_lesson_text(self):
data = json.loads(SEED.read_text(encoding='utf-8'))
bad = []
for g in data['grades']:
for u in g['units']:
for n in u['nodes']:
for l in n['lessons']:
texts = [l.get('introText'), l.get('example'), *(l.get('steps') or [])]
for t in texts:
if leftover(t):
bad.append((l['title'], leftover(t)))
self.assertEqual(bad, [])

View File

@@ -0,0 +1,46 @@
"""شکلِ سوال: ورود از فایل، خروج، و پاسخِ API. شکلِ نامعتبر باید فایل را رد کند، نه بی‌صدا حذف شود."""
import json
from django.core.cache import cache
from django.test import TestCase
from content import question_io
from content.models import Grade, Lesson, Node, Question, Unit
FIGURE = {'kind': 'bar', 'title': 'میوه‌ها', 'labels': ['سیب', 'پرتقال'], 'values': [5, 8]}
class QuestionFigureTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه سوم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='آمار', order=1)
self.lesson = Lesson.objects.create(node=self.node, title='نمودار')
def _row(self, **extra):
return {'lessonId': self.lesson.pk, 'prompt': 'کدام میوه بیشتر است؟', 'type': 'multiple_choice',
'choices': ['سیب', 'پرتقال'], 'correct': 1, **extra}
def test_valid_figure_is_stored_and_exported(self):
rows = question_io.parse(json.dumps([self._row(figure=FIGURE)]).encode())
question_io.import_questions(rows)
q = Question.objects.get()
self.assertEqual(q.figure['kind'], 'bar')
exported = question_io.export_questions(Question.objects.all())
self.assertEqual(exported[0]['figure']['kind'], 'bar')
def test_question_without_figure_exports_without_the_key(self):
question_io.import_questions(question_io.parse(json.dumps([self._row()]).encode()))
self.assertNotIn('figure', question_io.export_questions(Question.objects.all())[0])
def test_invalid_figure_rejects_the_file(self):
with self.assertRaises(question_io.ImportError_):
question_io.parse(json.dumps([self._row(figure={'kind': 'nope'})]).encode())
def test_quiz_api_returns_the_figure(self):
question_io.import_questions(question_io.parse(json.dumps([self._row(figure=FIGURE)]).encode()))
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=1').json()
self.assertEqual(body['questions'][0]['figure']['kind'], 'bar')
plain = self.client.get(f'/api/lessons/{self.lesson.pk}/questions').json()
self.assertEqual(plain['questions'][0]['figure']['kind'], 'bar')

View File

@@ -589,6 +589,7 @@ class LessonQuestionsView(APIView):
'type': q.type,
'prompt': q.prompt,
'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()],
'figure': q.figure,
}
for q in questions
]}
@@ -1035,7 +1036,15 @@ class NodeQuizView(APIView):
# درهم‌ریزیِ پایدار: همان کاربر روی همان مبحث همیشه همان ترتیب را
# می‌بیند، ولی دو کاربرِ مختلف دو ترتیبِ مختلف.
seed = request.query_params.get('seed') or str(getattr(request.user, 'id', '') or '')
rng = random.Random(f'{node_id}:{seed}')
# دوباره‌بازیِ همان جزیره آزمونِ تازه می‌دهد: شمارِ آزمون‌های تمام‌شده‌ی این کاربر روی این
# جزیره در مبنای درهم‌ریزی می‌آید. تا پایانِ آزمون عدد ثابت است، پس رفرش همان آزمون را
# می‌دهد. با استخرِ یک‌سوال‌به‌درس عملاً چیزی عوض نمی‌شود؛ معنی‌اش با استخرِ بزرگ پیداست.
# فقط برای کاربرِ واردشده و بی‌`?seed=`؛ نسنجیده: اثرش روی کشِ فرانت.
rounds = 0
if not request.query_params.get('seed') and getattr(request.user, 'id', None):
rounds = UserNodeCompletion.objects.filter(
user_id=request.user.id, node_id=node_id).count()
rng = random.Random(f'{node_id}:{seed}:{rounds}' if rounds else f'{node_id}:{seed}')
rng.shuffle(questions)
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
@@ -1069,6 +1078,7 @@ class NodeQuizView(APIView):
'lessonTitle': q.lesson.title,
'type': q.type,
'prompt': q.prompt,
'figure': q.figure,
'options': [
{'id': str(c.pk), 'value': c.value} for c in q.choices.all()
],