label every textbook solution, and build quizzes at the level the app plays

سه کارِ به‌هم‌پیوسته که از یک کشف شروع شدند: راه‌حل‌هایی که vision از کتاب
درآورده گاهی غلط‌اند، و این خطا در بازیابی دیده نمی‌شود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان می‌دهد.

۱) بازبینیِ راه‌حل‌ها (rag_verify_solutions)

هر chunk دوباره حل می‌شود و با کتاب مقایسه. نتیجه سه‌حالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد ۳۵٪ از
آن‌ها مسئله‌هایی بودند که به شکلِ کتاب ارجاع می‌دادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.

با سه حالت و پرامپتی که این دو تله را می‌شناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیش‌فیلتر قبل از هر فراخوانی کنارشان گذاشت.

`solution` هرگز بازنویسی نمی‌شود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم می‌نشیند، ولی با نامی که `retrieval._payload_text` نمی‌خواند: یک
نویسه هم به متنی که به مدل می‌رسد اضافه نشد.

نمونه‌ی خطاهای واقعیِ کتاب که پیدا شد:
    BMI = ۱۰۰/(۲٫۷۴)²          کتاب: ۳۳٫۲      درست: ۱۳٫۳
    ۱۶ آبی + ۲۴ صورتی          کتاب: «کل ۵۰»   درست: ۴۰
    بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲     درست: ۹۸۷۶
    g(x) = x³ − ۲              کتاب: «نمایی»   درست: چندجمله‌ای

۲) سوال‌ها فقط از راه‌حل‌های تأییدشده

۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفته‌اند. هر شش پایه برنامه‌ی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.

۳) GET /api/nodes/{id}/quiz

واحدِ بازی در اپ «مبحث» است ولی سوال‌ها به «درس» بسته‌اند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجه‌اش این بود که اپ برای یک
آزمونِ چهارسوالی باید ده‌ها درخواست می‌زد. این اندپوینت از همه‌ی درس‌های مبحث
نمونه برمی‌دارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.

درهم‌ریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را می‌دهد،
نه مجموعه‌ی تازه‌ای که پیشرفت را بی‌معنا کند. `questionCount` جدا از
`requested` می‌آید تا مبحثِ کم‌سوال وانمود نکند کامل است.

──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:

• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته می‌شد و پیش‌فرضِ ۵
  ثانیه‌ایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاک‌سازیِ نقاطِ نیمه‌نوشته: upsert با wait=True ممکن است نوشته باشد و فقط
  جوابش تایم‌اوت شده باشد؛ مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت و هر
  retry یک نسخه‌ی تکراری در Qdrant جا می‌گذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله می‌آید نه زیرخط، و
  محافظِ اول نمی‌گرفتش — یک اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد.
• پرامپتِ vision حالا می‌خواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
  محتوای موجود به شکلی ارجاع می‌دهد که در متن نیست؛ مدل آن را دیده بود ولی
  از او خواسته نشده بود بنویسدش. کتاب‌های موجود قبل از این قاعده وارد شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 02:11:15 +03:30
parent 92dc2242f8
commit b26aca0f63
17 changed files with 18319 additions and 41 deletions

View File

@@ -15,6 +15,9 @@
python manage.py generate_questions_from_chunks --grade نهم
"""
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
@@ -26,29 +29,54 @@ from rag.services import clients
from rag.services.config import build_collection_name, rag_is_configured
from rag.services.textbooks import TEXTBOOKS, get_textbook
PROMPT = """
تو یک معلمِ ریاضیِ پایه‌ی {grade} هستی.
def _is_account_limit(exc) -> bool:
"""
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمی‌دهد» یا فقط کندیِ لحظه‌ای؟
از روی «مسئله» و «راه‌حل»ِ زیر — که مستقیماً از کتابِ درسی درآمده — یک سوالِ
چهارگزینه‌ای بساز.
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعت‌ها
403 insufficient user quota — اعتبار تمام شده
قواعد:
- صورتِ سوال باید کوتاه، خودکفا و به فارسی باشد؛ دانش‌آموز بدونِ دیدنِ کتاب
بتواند جواب دهد.
- دقیقاً چهار گزینه، و دقیقاً یکی درست.
- سه گزینه‌ی نادرست باید **خطاهای رایجِ همین مبحث** باشند (مثلاً اشتباه در
علامتِ توان، جا انداختنِ یک مرحله، مخرجِ مشترک نگرفتن) — نه عددِ تصادفی و
نه گزینه‌ی آشکارا مسخره.
- هر گزینه حداکثر ۴۰ نویسه.
- اگر از روی این متن نمی‌شود سوالِ سالمی ساخت، دقیقاً {{"skip": true}} برگردان.
نکته: متنِ دومی «insufficient user quota» است با فاصله، نه زیرخط. محافظِ
اولِ ما دنبالِ `insufficient_quota` می‌گشت و نمی‌گرفتش، و اجرا ۷۷۰ فراخوانیِ
محکوم‌به‌شکست زد به‌جای اینکه سرِ اولی بایستد.
"""
text = str(exc).lower()
if 'usage_limit' in text or 'quota' in text:
return True
return '403' in text and 'insufficient' in text
PROMPT = """تو یک معلمِ ریاضیِ باتجربه‌ی پایه‌ی {grade} هستی.
از روی «مسئله» و «راه‌حل»ِ زیر — که مستقیماً از کتابِ درسی درآمده و درستیِ
راه‌حلش جداگانه بررسی و تأیید شده — یک سوالِ چهارگزینه‌ای بساز.
⚠️ نکته‌ی نگارشی: «/» بینِ دو عدد در این متن‌ها جداکننده‌ی اعشار است، نه خطِ
کسر. یعنی «۲/۵» عددِ ۲٫۵ است.
قواعد — نقضِ هرکدام یعنی سوالِ بی‌ارزش:
- **خودکفا:** تمامِ داده‌ی لازم باید داخلِ صورتِ سوال باشد. «مساحتِ شکل چند
است؟» بی‌ارزش است چون دانش‌آموز شکل را نمی‌بیند. اگر مسئله بدونِ تصویرِ کتاب
قابلِ بازنویسی نیست، دقیقاً {{"skip": true}} برگردان.
- **دقیقاً یک گزینه‌ی درست.** هر سه گزینه‌ی دیگر را جداگانه بسنج و مطمئن شو
قطعاً غلط‌اند. تله‌ی رایج: در «کدام مجموعه تهی است؟» چند گزینه تهی از آب
دربیایند، یا دو گزینه از نظر عددی برابر باشند و فقط شکلِ نوشتنشان فرق کند
(مثلاً «√۷/√۶» و «√۴۲/۶» یک عددند).
- **گزینه‌های غلط باید خطای واقعیِ دانش‌آموز باشند** — جابه‌جایی علامتِ توان،
مخرجِ مشترک نگرفتن، یک مرحله جا انداختن. نه عددِ تصادفی، نه گزینه‌ی مسخره.
- هیچ دو گزینه‌ای هم‌معنا نباشند.
- **بدونِ LaTeX و بدونِ markdown.** به‌جای \\frac{{a}}{{b}} بنویس (a)/(b).
متن عیناً همان‌طور که می‌نویسی به دانش‌آموز نشان داده می‌شود.
- فارسی، با ارقامِ فارسی. هر گزینه حداکثر ۴۰ نویسه.
نوعِ مسئله: {problem_type}
مسئله: {problem}
راه‌حل: {solution}
فقط این JSON را برگردان، بدونِ markdown و بدونِ توضیح:
{{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}}
"""
{{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}}"""
class Command(BaseCommand):
@@ -64,6 +92,14 @@ class Command(BaseCommand):
help='چند سوال برای هر درس.')
parser.add_argument('--dry-run', action='store_true',
help='فقط نشان بده چند سوال ساخته می‌شود؛ هیچ فراخوانیِ پولی نکن.')
parser.add_argument('--model', default='o4-mini',
help='مدلِ سازنده. پیش‌فرض o4-mini؛ gpt-4o-mini کیفیتِ '
'قابلِ قبولی نداد (۴ سوال از ۶ خراب).')
parser.add_argument('--all-chunks', dest='verified_only', action='store_false',
help='از chunkهای بازبینی‌نشده هم بساز (پیش‌فرض: فقط تأییدشده‌ها).')
parser.set_defaults(verified_only=True)
parser.add_argument('--workers', type=int, default=4,
help='فراخوانیِ هم‌زمان. بیش از ۴ روی این پروکسی ۴۲۹ می‌گیرد.')
def handle(self, *args, **opts):
book = get_textbook(opts['subject'], opts['grade'])
@@ -95,9 +131,10 @@ class Command(BaseCommand):
self.stdout.write(f' … و {len(targets) - 10} مورد دیگر')
# ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini.
cost = len(targets) * (900 / 1e6 * 0.15 + 150 / 1e6 * 0.60)
self.model = opts['model']
cost = len(targets) * 0.0018 # اندازه‌گیریِ واقعیِ o4-mini
self.stdout.write(self.style.WARNING(
f'\nبرآوردِ هزینه با {settings.OPENAI_CHAT_MODEL}: ≈ ${cost:.3f}'
f'\nبرآوردِ هزینه با {self.model}: ≈ ${cost:.2f}'
))
if opts['dry_run']:
self.stdout.write(self.style.WARNING('--dry-run: چیزی ساخته نشد.'))
@@ -106,21 +143,49 @@ class Command(BaseCommand):
if not rag_is_configured():
raise CommandError('RAG پیکربندی نشده (کلیدها خالی‌اند یا RAG_FORCE_MOCK روشن است).')
# سقفِ مصرفِ حسابِ سرویس با فشارِ لحظه‌ای فرق دارد: اولی ساعت‌ها باز
# نمی‌شود و تلاشِ دوباره فقط پنجره را طولانی‌تر می‌کند.
stop = threading.Event()
def work(pair):
lesson, chunk = pair
if stop.is_set():
return lesson, None, None
delay = 4.0
last = None
for _ in range(4):
try:
return lesson, self._ask_model(chunk, book.grade), None
except Exception as exc: # noqa: BLE001
last = exc
text = str(exc).lower()
if _is_account_limit(exc):
stop.set()
return lesson, None, exc
if '429' not in text and 'rate' not in text:
break
time.sleep(delay)
delay *= 2
return lesson, None, last
created = skipped = failed = 0
for lesson, chunk in targets:
try:
data = self._ask_model(chunk, book.grade)
except Exception as exc: # noqa: BLE001
failed += 1
self.stdout.write(self.style.ERROR(f' ✗ {lesson.title}: {str(exc)[:90]}'))
continue
if data is None:
skipped += 1
self.stdout.write(f' — {lesson.title}: مدل گفت از این متن سوال درنمی‌آید')
continue
self._persist(lesson, data)
created += 1
self.stdout.write(self.style.SUCCESS(f' ✓ {lesson.title}: {data["prompt"][:60]}'))
with ThreadPoolExecutor(max_workers=opts['workers']) as pool:
for lesson, data, err in pool.map(work, targets):
if err is not None:
failed += 1
self.stdout.write(self.style.ERROR(f' ✗ {lesson.title}: {str(err)[:85]}'))
continue
if data is None:
skipped += 1
continue
self._persist(lesson, data)
created += 1
self.stdout.write(self.style.SUCCESS(
f' ✓ {lesson.title}: {data["prompt"][:55]}'))
if stop.is_set():
self.stdout.write(self.style.ERROR(
'\nسقفِ مصرفِ حسابِ سرویس پر شد؛ اجرا نیمه‌کاره ماند. '
'اجرای بعدی از همین‌جا ادامه می‌دهد.'))
self.stdout.write(self.style.SUCCESS(
f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.'
@@ -144,32 +209,33 @@ class Command(BaseCommand):
need = opts['per_lesson'] - have
if need <= 0:
continue
chunks = list(
LessonChunk.objects
.filter(collection=collection, problem_type=lesson.title)
.exclude(problem='')
.order_by('source_page', 'order')[:need]
)
# فقط chunkهایی که راه‌حلشان بازبینی و تأیید شده. ساختنِ سوال از
# راه‌حلی که می‌دانیم غلط است، خطای کتاب را به دانش‌آموز منتقل
# می‌کند — و ۱۲۳ موردِ این‌چنینی در همین کتاب‌ها پیدا شد.
candidates = LessonChunk.objects.filter(
collection=collection, problem_type=lesson.title).exclude(problem='')
if opts['verified_only']:
candidates = candidates.filter(
solution_verdict=LessonChunk.VERDICT_AGREES)
chunks = list(candidates.order_by('source_page', 'order')[:need])
for chunk in chunks:
targets.append((lesson, chunk))
if opts['limit'] and len(targets) >= opts['limit']:
return targets
return targets
def _ask_model(self, chunk, grade):
def _ask_model(self, chunk, grade): # noqa: D401
prompt = PROMPT.format(
grade=grade, problem_type=chunk.problem_type,
problem=chunk.problem, solution=chunk.solution,
)
client = clients.get_openai_client()
completion = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
model=self.model,
messages=[
{'role': 'system', 'content': 'You always answer with a single valid JSON object.'},
{'role': 'user', 'content': prompt},
],
temperature=0.4,
max_tokens=400,
)
raw = (completion.choices[0].message.content or '').strip()
for fence in ('```json', '```'):

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -108,6 +108,34 @@ class QuestionSerializer(serializers.Serializer):
options = ChoiceOptionSerializer(many=True)
class QuizQuestionSerializer(serializers.Serializer):
"""
مثلِ QuestionSerializer است به‌علاوه‌ی اینکه سوال از کدام درس آمده.
چرا lessonId و lessonTitle: آزمونِ مبحث از چند درس نمونه برمی‌دارد، و
وقتی دانش‌آموز غلط جواب داد باید بشود گفت «این مهارت را مرور کن». بدونِ
این دو فیلد، فرانت فقط می‌داند «یک سوال غلط شد» و نمی‌داند کدام مبحثِ ریز.
"""
id = serializers.CharField()
lessonId = serializers.CharField()
lessonTitle = serializers.CharField()
type = serializers.CharField()
prompt = serializers.CharField()
options = ChoiceOptionSerializer(many=True)
class NodeQuizResponseSerializer(serializers.Serializer):
nodeId = serializers.CharField()
nodeTitle = serializers.CharField()
unitTitle = serializers.CharField()
# questionCount ممکن است از requested کمتر باشد؛ مبحث‌هایی هستند که هنوز
# سوالِ کافی ندارند و فرانت باید بتواند این را ببیند نه اینکه حدس بزند.
questionCount = serializers.IntegerField()
requested = serializers.IntegerField()
questions = QuizQuestionSerializer(many=True)
class LessonQuestionsResponseSerializer(serializers.Serializer):
questions = QuestionSerializer(many=True)

View File

@@ -218,3 +218,86 @@ class LessonWithoutQuestionsTests(TestCase):
response = self.client.get(f'/api/lessons/{self.empty.id}/intro')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['introText'], 'متنِ درس')
class NodeQuizTests(TestCase):
"""
آزمونِ مبحث: واحدِ بازی در اپ «مبحث» است نه «درس».
درس در این پروژه ریزترین دانه است (یک نوعِ مسئله از کتاب) و معمولاً یکی-دو
سوال دارد؛ اپ برای هر جزیره چهار سوال می‌خواهد. بدونِ این اندپوینت، فرانت
باید درس‌به‌درس می‌گشت و خودش سرِ هم می‌کرد.
"""
def setUp(self):
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(6):
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
question = Question.objects.create(
lesson=lesson, type='multiple_choice', prompt=f'سوال {i}')
Choice.objects.create(question=question, value='الف', is_correct=True)
Choice.objects.create(question=question, value='ب', is_correct=False)
def test_returns_four_questions_by_default(self):
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
self.assertEqual(body['questionCount'], 4)
self.assertEqual(len(body['questions']), 4)
self.assertEqual(body['nodeTitle'], 'توان')
def test_each_question_says_which_lesson_it_came_from(self):
"""برای اینکه بعدِ جوابِ غلط بشود گفت کدام مهارت را مرور کند."""
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
for question in body['questions']:
self.assertTrue(question['lessonId'])
self.assertTrue(question['lessonTitle'])
def test_it_spreads_across_lessons_instead_of_drilling_one(self):
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
lesson_ids = [q['lessonId'] for q in body['questions']]
self.assertEqual(len(set(lesson_ids)), len(lesson_ids))
def test_the_same_seed_gives_the_same_quiz(self):
"""رفرشِ صفحه نباید آزمونِ تازه‌ای بدهد و پیشرفت را بی‌معنا کند."""
first = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
again = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
self.assertEqual([q['id'] for q in first['questions']],
[q['id'] for q in again['questions']])
def test_a_different_seed_gives_a_different_order(self):
a = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=one').json()
b = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=two').json()
self.assertNotEqual([q['id'] for q in a['questions']],
[q['id'] for q in b['questions']])
def test_a_thin_node_reports_what_it_actually_has(self):
"""
مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید
بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد.
"""
thin_unit = Unit.objects.create(
grade=self.node.unit.grade, title='فصل ۵', order=2)
thin = Node.objects.create(unit=thin_unit, title='کم‌سوال', order=1)
lesson = Lesson.objects.create(node=thin, title='تنها درس')
question = Question.objects.create(
lesson=lesson, type='multiple_choice', prompt='تنها سوال')
Choice.objects.create(question=question, value='الف', is_correct=True)
body = self.client.get(f'/api/nodes/{thin.pk}/quiz').json()
self.assertEqual(body['questionCount'], 1)
self.assertEqual(body['requested'], 4)
def test_size_is_capped_so_one_request_cannot_pull_everything(self):
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz?size=999').json()
self.assertLessEqual(body['questionCount'], 20)
def test_unknown_node_is_a_clean_404(self):
self.assertEqual(self.client.get('/api/nodes/999999/quiz').status_code, 404)

View File

@@ -7,6 +7,7 @@ from .views import (
LessonIntroView,
LessonQuestionsView,
NodeLessonsView,
NodeQuizView,
NodeProgressListView,
QuestionAnswerView,
QuestionReportView,
@@ -21,5 +22,6 @@ urlpatterns = [
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
path('nodes/<int:node_id>/quiz', NodeQuizView.as_view(), name='node-quiz'),
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
]

View File

@@ -2,6 +2,8 @@ from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.db.models import Count
from django.http import Http404
import random
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
@@ -29,6 +31,7 @@ from .serializers import (
LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer,
NodeLessonsResponseSerializer,
NodeQuizResponseSerializer,
NodeProgressListResponseSerializer,
QuestionReportRequestSerializer,
QuestionReportResponseSerializer,
@@ -635,6 +638,95 @@ class QuestionReportView(APIView):
)
class NodeQuizView(APIView):
"""
GET /api/nodes/{id}/quiz — یک آزمونِ آماده برای کلِ مبحث.
چرا لازم شد: واحدِ بازی در اپ «مبحث» است (همان جزیره‌ی روی نقشه)، ولی
سوال‌ها به «درس» بسته‌اند و درس در این پروژه ریزترین دانه است — یک نوعِ
مسئله‌ی استخراج‌شده از کتاب، نه یک جلسه‌ی درسی. نتیجه‌اش این بود که هر
درس یکی-دو سوال داشت و اپ برای ساختنِ یک آزمونِ چهارسوالی باید ده‌ها
درخواستِ `lessons/{id}/questions` می‌زد و خودش سرِ هم می‌کرد.
این اندپوینت همان کار را یک‌جا می‌کند: از همه‌ی درس‌های مبحث نمونه
برمی‌دارد و یک آزمون می‌دهد.
ترتیب تصادفی نیست: `?seed=` یا شناسه‌ی کاربر مبنای درهم‌ریزی است تا
دانش‌آموزی که صفحه را رفرش می‌کند همان آزمون را ببیند، نه مجموعه‌ی تازه‌ای
که پیشرفتش را بی‌معنا کند.
"""
throttle_scope = 'read'
throttle_classes = [GuestAwareContentThrottle]
DEFAULT_SIZE = 4
MAX_SIZE = 20
@extend_schema(responses=NodeQuizResponseSerializer, tags=['Curriculum (content)'])
def get(self, request, node_id: int):
node = get_object_or_404(
Node.objects.select_related('unit'), pk=node_id,
)
try:
size = int(request.query_params.get('size', self.DEFAULT_SIZE))
except (TypeError, ValueError):
size = self.DEFAULT_SIZE
size = max(1, min(size, self.MAX_SIZE))
questions = list(
Question.objects
.filter(lesson__node_id=node_id)
.select_related('lesson')
.prefetch_related('choices')
.order_by('lesson__id', 'id')
)
# درهم‌ریزیِ پایدار: همان کاربر روی همان مبحث همیشه همان ترتیب را
# می‌بیند، ولی دو کاربرِ مختلف دو ترتیبِ مختلف.
seed = request.query_params.get('seed') or str(getattr(request.user, 'id', '') or '')
rng = random.Random(f'{node_id}:{seed}')
rng.shuffle(questions)
# حداکثر یک سوال از هر درس تا آزمون روی یک مهارت گیر نکند؛ اگر با این
# شرط به اندازه‌ی لازم نرسیدیم، بقیه از همان استخر پر می‌شوند.
picked, seen_lessons = [], set()
for question in questions:
if question.lesson_id in seen_lessons:
continue
seen_lessons.add(question.lesson_id)
picked.append(question)
if len(picked) >= size:
break
if len(picked) < size:
for question in questions:
if question in picked:
continue
picked.append(question)
if len(picked) >= size:
break
return Response(NodeQuizResponseSerializer({
'nodeId': str(node.pk),
'nodeTitle': node.title,
'unitTitle': node.unit.title,
'questionCount': len(picked),
'requested': size,
'questions': [
{
'id': str(q.pk),
'lessonId': str(q.lesson_id),
'lessonTitle': q.lesson.title,
'type': q.type,
'prompt': q.prompt,
'options': [
{'id': str(c.pk), 'value': c.value} for c in q.choices.all()
],
}
for q in picked
],
}).data)
class NodeLessonsView(APIView):
"""
GET /api/nodes/{id}/lessons — قدم‌های یک مبحث برای نقشه‌ی مهارت.