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:
13
CLAUDE.md
13
CLAUDE.md
@@ -451,6 +451,19 @@ preferred setup, because there is then no key to leak. **Never let the server ru
|
|||||||
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
|
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
|
||||||
anyone who can read this repo can forge a token for any user.**
|
anyone who can read this repo can forge a token for any user.**
|
||||||
|
|
||||||
|
## جزیرههای تمرینِ تازه و شکل در سوال — ۲۰۲۶-۱۰-۱۲ (عصر)
|
||||||
|
|
||||||
|
- **۱۶۰ جزیرهی «تمرین: …»** آخرِ فصلهای پایههای ۳ تا ۱۲، با **۱۳٬۶۸۹ سوالِ تازه** (کلِ `questions.json` ۱۸٬۵۲۲). هر جزیره ۳ تا ۶ درس دارد و هر درس
|
||||||
|
تا ۳۰ سوال. سوالها با برنامههای کوتاهِ تولید ساخته شدند که جوابِ هر سوال را **محاسبه** میکنند (نه حفظ)، با گزینهی غلطِ از جنسِ خطای رایج
|
||||||
|
(علامت، فراموشیِ مشتقِ داخل، …)؛ بخشی هم بانکِ مفهومیِ دستنویس است. **برنامهها در ریپو نیستند**؛ فایلهای seed خودشان منبعند و ویرایش از ادمین/JSON است.
|
||||||
|
سنجیده: ۹۵۸ تست سبز، چشمیِ نمونهی هر خانواده، و ۳۶ شکلِ نمونه در مرورگر. **نسنجیده:** هر سوال با چشمِ معلم؛ قالبِ سوالها در هر خانواده یکی است (عددها
|
||||||
|
عوض میشوند)، پس تنوعِ مفهومی کمتر از سوالِ دستنویس است.
|
||||||
|
- **`Question.figure`** (`content.0019`): همان قالبِ `Lesson.figures[].diagram`، با `rag.services.diagram.validate` در ورودِ فایل اعتبارسنجی میشود (شکلِ
|
||||||
|
نامعتبر فایل را رد میکند، نه بیصدا حذف). `/api/nodes/{id}/quiz` و `/api/lessons/{id}/questions` آن را برمیگردانند؛ فرانت (`QuizScreen`) زیرِ صورتِ سوال میکشد.
|
||||||
|
- **آزمونِ دوبارهبازی** (`NodeQuizView`): مبنای درهمریزی شمارِ آزمونهای تمامشدهی کاربر روی همان جزیره است؛ رفرش همان آزمون را میدهد و بعد از پایان آزمونِ تازه.
|
||||||
|
بی`?seed=` و فقط برای کاربرِ واردشده. با استخرِ بزرگ معنی دارد؛ **نسنجیده** اثرش روی کشِ فرانت.
|
||||||
|
- جزیرههای تمرین آخرِ هر فصلاند و قفلِ ترتیبی دارند؛ فصلِ اولِ هر کتاب رایگان است، پس تمرینهایش هم رایگاناند.
|
||||||
|
|
||||||
## کتابها (Subject)، جزیرههای بیشتر و شکلِ درسهای کتابهای تازه — ۲۰۲۶-۱۰-۱۲
|
## کتابها (Subject)، جزیرههای بیشتر و شکلِ درسهای کتابهای تازه — ۲۰۲۶-۱۰-۱۲
|
||||||
|
|
||||||
- **`Subject`** (`content.0014..0018`): ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ `Unit.subject` اجباری است.
|
- **`Subject`** (`content.0014..0018`): ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ `Unit.subject` اجباری است.
|
||||||
|
|||||||
12
content/migrations/0019_question_figure.py
Normal file
12
content/migrations/0019_question_figure.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
54
content/migrations/0020_plain_math_lesson_texts.py
Normal file
54
content/migrations/0020_plain_math_lesson_texts.py
Normal 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)]
|
||||||
@@ -167,6 +167,9 @@ class Question(models.Model):
|
|||||||
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
|
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
|
||||||
prompt = models.TextField()
|
prompt = models.TextField()
|
||||||
asset_group = models.CharField(max_length=100, blank=True)
|
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):
|
def __str__(self):
|
||||||
return self.prompt[:50]
|
return self.prompt[:50]
|
||||||
|
|||||||
96
content/plain_math.py
Normal file
96
content/plain_math.py
Normal 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
|
||||||
@@ -24,6 +24,8 @@ import json
|
|||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
from rag.services import diagram as diagram_spec
|
||||||
|
|
||||||
from .models import Choice, Lesson, Question
|
from .models import Choice, Lesson, Question
|
||||||
|
|
||||||
VALID_TYPES = {value for value, _ in Question.TYPE_CHOICES}
|
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)):
|
if not isinstance(correct, int) or not (0 <= correct < len(choices)):
|
||||||
raise ImportError_(f'{where}: correct باید شمارهی یکی از گزینهها باشد (از صفر).')
|
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 {
|
return {
|
||||||
'lesson_id': lesson_id,
|
'lesson_id': lesson_id,
|
||||||
'prompt': prompt,
|
'prompt': prompt,
|
||||||
'type': qtype,
|
'type': qtype,
|
||||||
'choices': choices,
|
'choices': choices,
|
||||||
'correct': correct,
|
'correct': correct,
|
||||||
|
'figure': figure,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +145,7 @@ def import_questions(
|
|||||||
continue
|
continue
|
||||||
question = Question.objects.create(
|
question = Question.objects.create(
|
||||||
lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'],
|
lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'],
|
||||||
|
figure=row.get('figure'),
|
||||||
)
|
)
|
||||||
Choice.objects.bulk_create([
|
Choice.objects.bulk_create([
|
||||||
Choice(question=question, value=value, is_correct=(i == row['correct']))
|
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'):
|
for question in queryset.prefetch_related('choices').select_related('lesson'):
|
||||||
choices = list(question.choices.all())
|
choices = list(question.choices.all())
|
||||||
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
|
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
|
||||||
out.append({
|
row = {
|
||||||
'lessonId': question.lesson_id,
|
'lessonId': question.lesson_id,
|
||||||
'prompt': question.prompt,
|
'prompt': question.prompt,
|
||||||
'type': question.type,
|
'type': question.type,
|
||||||
'choices': [c.value for c in choices],
|
'choices': [c.value for c in choices],
|
||||||
'correct': correct,
|
'correct': correct,
|
||||||
})
|
}
|
||||||
|
if question.figure:
|
||||||
|
row['figure'] = question.figure
|
||||||
|
out.append(row)
|
||||||
return out
|
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)
|
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
|
||||||
lesson = question.lesson
|
lesson = question.lesson
|
||||||
node = lesson.node
|
node = lesson.node
|
||||||
out.append({
|
row = {
|
||||||
'path': [node.unit.grade.title, node.unit.title, node.title, lesson.title],
|
'path': [node.unit.grade.title, node.unit.title, node.title, lesson.title],
|
||||||
'prompt': question.prompt,
|
'prompt': question.prompt,
|
||||||
'type': question.type,
|
'type': question.type,
|
||||||
'choices': [c.value for c in choices],
|
'choices': [c.value for c in choices],
|
||||||
'correct': correct,
|
'correct': correct,
|
||||||
})
|
}
|
||||||
|
if question.figure:
|
||||||
|
row['figure'] = question.figure
|
||||||
|
out.append(row)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
254153
content/seed/questions.json
254153
content/seed/questions.json
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,8 @@ class QuestionSerializer(serializers.Serializer):
|
|||||||
type = serializers.CharField()
|
type = serializers.CharField()
|
||||||
prompt = serializers.CharField()
|
prompt = serializers.CharField()
|
||||||
options = ChoiceOptionSerializer(many=True)
|
options = ChoiceOptionSerializer(many=True)
|
||||||
|
# شکلِ سوال (توصیفِ JSON مثلِ `LessonFigure.diagram`)؛ null یعنی شکل ندارد.
|
||||||
|
figure = serializers.JSONField(allow_null=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
class QuizQuestionSerializer(serializers.Serializer):
|
class QuizQuestionSerializer(serializers.Serializer):
|
||||||
@@ -155,6 +157,7 @@ class QuizQuestionSerializer(serializers.Serializer):
|
|||||||
type = serializers.CharField()
|
type = serializers.CharField()
|
||||||
prompt = serializers.CharField()
|
prompt = serializers.CharField()
|
||||||
options = ChoiceOptionSerializer(many=True)
|
options = ChoiceOptionSerializer(many=True)
|
||||||
|
figure = serializers.JSONField(allow_null=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
class NodeQuizResponseSerializer(serializers.Serializer):
|
class NodeQuizResponseSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -231,3 +231,37 @@ class NodeQuizCompleteTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(body['stats']['progressPercent'], 10.0) # ۱ از ۱۰ درس
|
self.assertEqual(body['stats']['progressPercent'], 10.0) # ۱ از ۱۰ درس
|
||||||
self.assertTrue(UserLessonCompletion.objects.filter(user_id=self.user.id).exists())
|
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())
|
||||||
|
|||||||
52
content/tests_plain_math.py
Normal file
52
content/tests_plain_math.py
Normal 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, [])
|
||||||
46
content/tests_question_figure.py
Normal file
46
content/tests_question_figure.py
Normal 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')
|
||||||
@@ -589,6 +589,7 @@ class LessonQuestionsView(APIView):
|
|||||||
'type': q.type,
|
'type': q.type,
|
||||||
'prompt': q.prompt,
|
'prompt': q.prompt,
|
||||||
'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()],
|
'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()],
|
||||||
|
'figure': q.figure,
|
||||||
}
|
}
|
||||||
for q in questions
|
for q in questions
|
||||||
]}
|
]}
|
||||||
@@ -1035,7 +1036,15 @@ class NodeQuizView(APIView):
|
|||||||
# درهمریزیِ پایدار: همان کاربر روی همان مبحث همیشه همان ترتیب را
|
# درهمریزیِ پایدار: همان کاربر روی همان مبحث همیشه همان ترتیب را
|
||||||
# میبیند، ولی دو کاربرِ مختلف دو ترتیبِ مختلف.
|
# میبیند، ولی دو کاربرِ مختلف دو ترتیبِ مختلف.
|
||||||
seed = request.query_params.get('seed') or str(getattr(request.user, 'id', '') or '')
|
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)
|
rng.shuffle(questions)
|
||||||
|
|
||||||
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
|
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
|
||||||
@@ -1069,6 +1078,7 @@ class NodeQuizView(APIView):
|
|||||||
'lessonTitle': q.lesson.title,
|
'lessonTitle': q.lesson.title,
|
||||||
'type': q.type,
|
'type': q.type,
|
||||||
'prompt': q.prompt,
|
'prompt': q.prompt,
|
||||||
|
'figure': q.figure,
|
||||||
'options': [
|
'options': [
|
||||||
{'id': str(c.pk), 'value': c.value} for c in q.choices.all()
|
{'id': str(c.pk), 'value': c.value} for c in q.choices.all()
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -137,10 +137,11 @@ def _convert(s: str) -> str:
|
|||||||
elif name == 'mathbb':
|
elif name == 'mathbb':
|
||||||
body, i = _read_group(s, i)
|
body, i = _read_group(s, i)
|
||||||
out.append(_BLACKBOARD.get(body, body))
|
out.append(_BLACKBOARD.get(body, body))
|
||||||
elif name in ('vec', 'overrightarrow'):
|
elif name in ('vec', 'overrightarrow', 'overline', 'bar', 'widehat', 'hat'):
|
||||||
|
# فلشِ بالای حرف (U+20D7) در فونتِ بسیاری از گوشیها دیده نمیشود یا روی
|
||||||
|
# حرف مینشیند؛ کتابهای درسی هم «بردار a» مینویسند. هیچ نشانی نمیگذاریم.
|
||||||
body, i = _read_group(s, i)
|
body, i = _read_group(s, i)
|
||||||
out.append(_convert(body) + '⃗')
|
out.append(_convert(body))
|
||||||
elif name in ('overline', 'bar', 'widehat', 'hat'):
|
|
||||||
body, i = _read_group(s, i)
|
body, i = _read_group(s, i)
|
||||||
out.append(_convert(body))
|
out.append(_convert(body))
|
||||||
elif name in ('begin', 'end'):
|
elif name in ('begin', 'end'):
|
||||||
|
|||||||
Reference in New Issue
Block a user