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:
@@ -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 — قدمهای یک مبحث برای نقشهی مهارت.
|
||||
|
||||
Reference in New Issue
Block a user