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

@@ -575,6 +575,11 @@ OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))
OPENAI_TIMEOUT_SECONDS = int(os.environ.get('OPENAI_TIMEOUT_SECONDS', '60'))
QDRANT_URL = os.environ.get('QDRANT_URL', 'https://qdrant.tadrisino2.ir:443')
# پیش‌فرضِ خودِ qdrant-client پنج ثانیه است و برای این سرور کم است: ingestِ
# کتابِ هشتم روی همان اولین فراخوانی (ساختِ collection) افتاد و صفر صفحه رفت.
# سی ثانیه در مسیرِ ingest که دقیقه‌ها طول می‌کشد هزینه‌ای ندارد، و جلوی
# مردنِ یک اجرای طولانی بابتِ یک کندیِ لحظه‌ای را می‌گیرد.
QDRANT_TIMEOUT_SECONDS = int(os.environ.get('QDRANT_TIMEOUT_SECONDS', '30'))
QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', '')
# نامِ collection در دادهٔ تیمِ دیگه = f"{subject} {grade}".lower() . کلیدِ

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 — قدم‌های یک مبحث برای نقشه‌ی مهارت.

View File

@@ -0,0 +1,379 @@
"""
راستی‌آزماییِ راه‌حل‌هایی که vision از کتابِ درسی درآورده.
چرا لازم است: استخراج گاهی راه‌حلِ غلط می‌سازد و این خطا در بازیابی دیده
نمی‌شود — فقط وقتی کسی به `solution` اعتماد کند (ساختِ سؤال، یا خودِ دستیار
هنگامِ نقلِ جواب) خودش را نشان می‌دهد. نمونه‌های واقعی از همین کتاب‌ها:
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجمله‌ایِ درجه ۳
`solution` هرگز بازنویسی نمی‌شود. کتاب باید کتاب بماند: اگر جوابِ مدل را
جایش می‌گذاشتیم و مدل اشتباه می‌کرد، منبعِ حقیقت بی‌صدا خراب می‌شد.
──────────────────────────────────────────────────────────────────────────
درس‌هایی که از اجرای اولِ این دستور گرفته شد و در طراحیِ فعلی نشسته‌اند:
۱. نتیجه باید سه‌حالته باشد. با بله/خیر، «نمی‌دانم» ناچار به «غلط است»
ترجمه می‌شد: ۳۵٪ از گزارش‌ها مسئله‌هایی بودند که به شکل یا جدولِ کتاب
ارجاع می‌دادند و اصلاً قابلِ قضاوت نبودند.
۲. «۳/۱۰۴» در این کتاب‌ها عددِ اعشاریِ ۳٫۱۰۴ است، نه کسرِ ۳÷۱۰۴. بدونِ گفتنِ
این به مدل، ۲۴٪ از گزارش‌ها هشدارِ کاذب بود — بیشترشان در پایه ششم که
پر از اعشار است.
۳. مسئله‌های وابسته به شکل اصلاً فرستاده نمی‌شوند. هزینه دارند و جوابشان
بی‌معناست؛ همان‌جا برچسبِ unjudgeable می‌خورند.
۴. دو مدل، دو نقش. deepseek-reasoner ارزان است و تشخیصش خوب، ولی جوابِ
خودش گاهی غلط است (مسئله‌ی سرعتِ نور را ۱۰^۱۷ داد به‌جای ۱۰^۱۴). پس
او تور می‌اندازد و o4-mini فقط روی صیدها داوری می‌کند.
──────────────────────────────────────────────────────────────────────────
python manage.py rag_verify_solutions --all
python manage.py rag_verify_solutions --all --adjudicate
python manage.py rag_verify_solutions --collection "ریاضی ششم" --limit 20
python manage.py rag_verify_solutions --report
"""
import json
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Count, Q
from django.utils import timezone
from rag.models import LessonChunk
from rag.services import clients
from rag.services.config import rag_is_configured
SCOUT_MODEL = 'deepseek-reasoner'
JUDGE_MODEL = 'o4-mini'
# دو خطای کاملاً متفاوت که هر دو ۴۲۹‌اند و نباید یکسان با آن‌ها رفتار کرد:
#
# • فشارِ لحظه‌ایِ موازی‌کاری — گذراست، عقب‌نشینی حلش می‌کند.
# • سقفِ مصرفِ حساب — ساعت‌ها باز نمی‌شود. تلاشِ دوباره نه‌تنها بی‌فایده
# است، پنجره‌ی محدودیت را هم تمدید می‌کند: یک اجرا ۱۱۹ بار دوباره تلاش
# کرد، ۶۵ دقیقه طول کشید، هیچ کاری نکرد، و مهلت را از ۱۴ ساعت جلوتر برد.
class AccountLimitReached(Exception):
"""حسابِ سرویس جواب نمی‌دهد؛ ادامه دادن بی‌معناست."""
def is_account_limit(exc) -> bool:
"""
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمی‌دهد» یا فقط کندیِ لحظه‌ای؟
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعت‌ها
403 insufficient user quota — اعتبار تمام شده
نکته: متنِ دومی «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
# مسئله‌ای که به شکل/جدولِ کتاب ارجاع می‌دهد بدونِ آن شکل قابلِ حل نیست.
# فرستادنش هم پول می‌سوزاند هم آمار را خراب می‌کند.
NEEDS_FIGURE = re.compile(
r'شکل|جدول|نمودار|روبه‌رو|روبرو|مقابل|تصویر|نقشه|رسم کنید|بکشید|'
r'رنگ[‌ ]?آمیزی|زیر را|بالا را|وِن'
)
PROMPT = """تو یک ریاضی‌دانِ دقیق هستی که راه‌حل‌های کتابِ درسیِ ایران را بازبینی می‌کند.
⚠️ سه قاعده‌ی مهم درباره‌ی نگارشِ این متن‌ها:
۱. علامتِ «/» بینِ دو عدد، در این کتاب‌ها تقریباً همیشه **جداکننده‌ی اعشار**
است، نه خطِ کسر. یعنی «۳/۱۰۴» عددِ ۳٫۱۰۴ است و «۲/۵» عددِ ۲٫۵.
کسرها معمولاً به شکلِ «۳ بر ۴» یا با کسرِ عمودی می‌آیند.
۲. صفر در ریاضیِ مدرسه‌ی ایران عددِ طبیعی **نیست**.
۳. متن با OCR از PDF درآمده؛ فاصله و نیم‌فاصله ممکن است به‌هم‌ریخته باشد.
تفاوتِ صرفاً نگارشی، گِردکردن، یا ترتیبِ نوشتن **خطا نیست**.
مسئله:
{problem}
راه‌حلِ کتاب:
{solution}
کارَت:
۱. اول خودت مسئله را قدم‌به‌قدم حل کن.
۲. بعد راه‌حلِ کتاب را با جوابِ خودت بسنج.
یکی از این سه را برگردان:
• "agrees" — راه‌حلِ کتاب از نظرِ ریاضی درست است (حتی اگر نگارشش فرق دارد)
• "disagrees" — خطای قطعیِ ریاضی دارد. فقط وقتی مطمئنی.
• "unjudgeable" — بدونِ شکل/جدولِ کتاب یا داده‌ی جاافتاده نمی‌شود قضاوت کرد،
یا مسئله جوابِ عددیِ مشخصی ندارد (پرسشِ باز یا توضیحی).
اگر شک داری بینِ disagrees و unjudgeable، unjudgeable را انتخاب کن.
فقط این JSON را برگردان، بدونِ هیچ متنِ اضافه:
{{"verdict": "agrees|disagrees|unjudgeable", "answer": "جوابِ خودت، کوتاه", "why": "حداکثر ۱۲۰ نویسه"}}"""
def first_json(text: str):
"""
اولین شیءِ JSON را از متن بیرون می‌کشد.
مدل‌های استدلالی گاهی قبل یا بعد از JSON توضیح می‌چسبانند و json.loads خام
روی همان می‌افتد. در محکِ اولیه همین باعث شد deepseek به‌اشتباه ۷ از ۸
به‌نظر برسد در حالی که ۸ از ۸ بود.
"""
depth = 0
start = None
for i, ch in enumerate(text or ''):
if ch == '{':
if depth == 0:
start = i
depth += 1
elif ch == '}':
depth -= 1
if depth == 0 and start is not None:
try:
return json.loads(text[start:i + 1])
except json.JSONDecodeError:
start = None
return None
class Command(BaseCommand):
help = 'بازبینیِ درستیِ راه‌حل‌های استخراج‌شده از کتاب (هزینه دارد، کم).'
def add_arguments(self, parser):
parser.add_argument('--collection', default=None)
parser.add_argument('--all', action='store_true')
parser.add_argument('--limit', type=int, default=None)
parser.add_argument('--workers', type=int, default=12)
parser.add_argument('--adjudicate', action='store_true',
help='پاسِ دوم: مشکوک‌ها را با مدلِ دقیق‌تر بسنج.')
parser.add_argument('--recheck', action='store_true')
parser.add_argument('--report', action='store_true',
help='فقط گزارشِ وضعیت؛ هیچ فراخوانی‌ای نمی‌کند.')
parser.add_argument('--sync-qdrant', action='store_true',
help='برچسب‌ها را در payloadِ Qdrant بنویس (رایگان).')
def handle(self, *args, **opts):
if opts['report']:
return self._report()
if opts['sync_qdrant']:
return self._sync_qdrant(opts)
if not rag_is_configured():
raise CommandError('RAG پیکربندی نشده (کلیدها خالی‌اند).')
if opts['adjudicate']:
return self._adjudicate(opts)
qs = LessonChunk.objects.exclude(problem='').exclude(solution='')
if opts['collection']:
qs = qs.filter(collection=opts['collection'])
elif not opts['all']:
raise CommandError('یا --collection بدهید یا --all.')
if not opts['recheck']:
qs = qs.filter(solution_checked_at__isnull=True)
rows = list(qs.order_by('collection', 'source_page', 'order'))
skipped = self._mark_figure_dependent(rows)
rows = [r for r in rows if not NEEDS_FIGURE.search(r.problem or '')]
if opts['limit']:
rows = rows[:opts['limit']]
if skipped:
self.stdout.write(
f' {skipped} مسئله‌ی وابسته به شکل/جدول بدونِ فراخوانی '
'برچسبِ unjudgeable گرفتند.'
)
if not rows:
self.stdout.write(self.style.WARNING('چیزی برای بررسی نماند.'))
return
self.stdout.write(self.style.MIGRATE_HEADING(
f'\n{len(rows)} chunk با {SCOUT_MODEL} (~${len(rows) * 0.00014:.2f})\n'))
self._run(rows, SCOUT_MODEL, opts['workers'])
def _mark_figure_dependent(self, rows) -> int:
"""قبل از خرجِ یک ریال، آن‌هایی که ذاتاً قابلِ قضاوت نیستند کنار می‌روند."""
marked = []
for chunk in rows:
if NEEDS_FIGURE.search(chunk.problem or ''):
chunk.solution_verdict = LessonChunk.VERDICT_UNJUDGEABLE
chunk.check_note = 'به شکل/جدولِ کتاب ارجاع می‌دهد؛ از متن قابلِ قضاوت نیست.'
chunk.checked_by = 'pre-filter'
chunk.solution_checked_at = timezone.now()
marked.append(chunk)
if marked:
LessonChunk.objects.bulk_update(
marked, ['solution_verdict', 'check_note', 'checked_by',
'solution_checked_at'])
return len(marked)
def _adjudicate(self, opts):
qs = LessonChunk.objects.filter(
solution_verdict=LessonChunk.VERDICT_DISAGREES).exclude(problem='')
if opts['collection']:
qs = qs.filter(collection=opts['collection'])
if not opts['recheck']:
qs = qs.exclude(checked_by=JUDGE_MODEL)
rows = list(qs.order_by('collection', 'source_page')[:opts['limit'] or 10_000])
if not rows:
self.stdout.write(self.style.WARNING('مشکوکی برای داوری نمانده.'))
return
# مدلِ داور روی این پروکسی سخت‌گیرتر نرخ‌بندی می‌شود؛ نصفِ کارگرها.
workers = max(1, min(opts['workers'], 4))
self.stdout.write(self.style.MIGRATE_HEADING(
f'\nداوریِ {len(rows)} مورد با {JUDGE_MODEL} ({workers} کارگر) '
f'(~${len(rows) * 0.0018:.2f})\n'))
self._run(rows, JUDGE_MODEL, workers)
def _run(self, rows, model, workers):
"""
فراخوانی‌ها موازی، نوشتن دسته‌ای از نخِ اصلی.
هر chunk چند ثانیه منتظرِ شبکه است، پس ترتیبی یعنی ساعت‌ها؛ ولی نوشتنِ
هم‌زمان از چند نخ روی SQLite به قفلِ نوشتن می‌خورد.
"""
client = clients.get_openai_client()
stop = threading.Event()
def ask(chunk):
# اگر یک نخ به سقفِ حساب خورده، بقیه بی‌درنگ برمی‌گردند؛ ادامه دادن
# فقط پنجره‌ی محدودیت را طولانی‌تر می‌کند.
if stop.is_set():
return chunk, None, None
delay = 4.0
last = None
for _ in range(5):
try:
completion = client.chat.completions.create(
model=model,
messages=[{'role': 'user', 'content': PROMPT.format(
problem=chunk.problem[:900], solution=chunk.solution[:900])}],
)
data = first_json(completion.choices[0].message.content)
verdict = (data or {}).get('verdict')
if verdict not in dict(LessonChunk.VERDICT_CHOICES):
raise ValueError(f'verdictِ نامعتبر: {verdict!r}')
return chunk, data, None
except Exception as exc: # noqa: BLE001
last = exc
text = str(exc).lower()
if is_account_limit(exc):
stop.set()
return chunk, None, AccountLimitReached(str(exc)[:200])
if '429' not in text and 'rate' not in text:
break
if stop.is_set():
break
time.sleep(delay)
delay *= 2
return chunk, None, last
counts = {v: 0 for v, _ in LessonChunk.VERDICT_CHOICES}
failed = 0
aborted: AccountLimitReached | None = None
batch: list[LessonChunk] = []
t0 = time.time()
done = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
for chunk, data, err in pool.map(ask, rows):
done += 1
if isinstance(err, AccountLimitReached):
aborted = err
continue
if err is None and data is None:
continue # بعد از توقف، نوبتش نرسید
if err is not None:
failed += 1
self.stdout.write(self.style.ERROR(f' ✗ #{chunk.pk}: {str(err)[:75]}'))
else:
chunk.solution_verdict = data['verdict']
chunk.checked_answer = str(data.get('answer') or '')[:2000]
chunk.check_note = str(data.get('why') or '')[:300]
chunk.checked_by = model
chunk.solution_checked_at = timezone.now()
batch.append(chunk)
counts[data['verdict']] += 1
if data['verdict'] == LessonChunk.VERDICT_DISAGREES:
self.stdout.write(
f' ⚠ {chunk.collection} ص{chunk.source_page}: '
f'{chunk.problem[:50]} → {chunk.checked_answer[:40]}')
if len(batch) >= 40:
self._flush(batch)
if done % 100 == 0:
rate = done / max(time.time() - t0, 1)
self.stdout.write(
f' … {done}/{len(rows)} (~{(len(rows)-done)/max(rate,.001)/60:.0f} دقیقه مانده)')
self._flush(batch)
self.stdout.write(self.style.SUCCESS(
f"\n{counts['agrees']} درست، {counts['disagrees']} مشکوک، "
f"{counts['unjudgeable']} غیرقابلِ قضاوت، {failed} خطا — {time.time()-t0:.0f}s"))
if aborted is not None:
# آنچه تا اینجا سنجیده شده ذخیره شده و اجرای بعدی از همین‌جا ادامه
# می‌دهد، چون فقط بررسی‌نشده‌ها برداشته می‌شوند.
raise CommandError(
'سقفِ مصرفِ حسابِ سرویس پر شده و اجرا همین‌جا متوقف شد.\n'
f' پیامِ سرویس: {aborted}\n'
' تا باز شدنِ سقف دوباره تلاش نکنید؛ هر تلاش مهلت را جلوتر می‌برد.\n'
' وضعیت با: python manage.py rag_verify_solutions --report'
)
@staticmethod
def _flush(batch):
if batch:
LessonChunk.objects.bulk_update(
batch, ['solution_verdict', 'checked_answer', 'check_note',
'checked_by', 'solution_checked_at'])
batch.clear()
def _sync_qdrant(self, opts):
"""
برچسب‌ها را روی نقاطِ موجود می‌نویسد، بدونِ بردار و بدونِ هزینه.
نقطه‌ها بر اساسِ مقدارِ برچسب دسته می‌شوند، پس برای هر collection سه
فراخوانی کافی است نه هزاران‌تا.
"""
from rag.services.qdrant_store import set_payload_fields
qs = LessonChunk.objects.exclude(solution_verdict='')
if opts['collection']:
qs = qs.filter(collection=opts['collection'])
buckets: dict[tuple[str, str], list[str]] = {}
for collection, verdict, point_id in qs.values_list(
'collection', 'solution_verdict', 'qdrant_point_id'):
buckets.setdefault((collection, verdict), []).append(str(point_id))
if not buckets:
self.stdout.write(self.style.WARNING('برچسبی برای همگام‌سازی نیست.'))
return
total = 0
for (collection, verdict), ids in sorted(buckets.items()):
set_payload_fields(collection, ids, {'solution_verdict': verdict})
total += len(ids)
self.stdout.write(f' {collection:14s} {verdict:12s} {len(ids):4d} نقطه')
self.stdout.write(self.style.SUCCESS(f'\n{total} نقطه در Qdrant به‌روز شد.'))
def _report(self):
rows = (LessonChunk.objects.exclude(problem='').exclude(solution='')
.values('collection')
.annotate(n=Count('id'),
ok=Count('id', filter=Q(solution_verdict='agrees')),
bad=Count('id', filter=Q(solution_verdict='disagrees')),
unk=Count('id', filter=Q(solution_verdict='unjudgeable')),
todo=Count('id', filter=Q(solution_checked_at__isnull=True)))
.order_by('collection'))
self.stdout.write(f"\n{'کتاب':14s} {'کل':>5s} {'درست':>6s} {'مشکوک':>7s} "
f"{'بی‌قضاوت':>9s} {'مانده':>6s}")
for r in rows:
self.stdout.write(f"{r['collection']:14s} {r['n']:5d} {r['ok']:6d} "
f"{r['bad']:7d} {r['unk']:9d} {r['todo']:6d}")

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-09-15 22:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rag', '0004_ingestedpage'),
]
operations = [
migrations.AddField(
model_name='lessonchunk',
name='checked_answer',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='lessonchunk',
name='checked_by',
field=models.CharField(blank=True, max_length=60),
),
migrations.AddField(
model_name='lessonchunk',
name='solution_agrees',
field=models.BooleanField(blank=True, null=True),
),
migrations.AddField(
model_name='lessonchunk',
name='solution_checked_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.17 on 2026-09-15 23:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rag', '0005_lessonchunk_checked_answer_lessonchunk_checked_by_and_more'),
]
operations = [
migrations.RemoveField(
model_name='lessonchunk',
name='solution_agrees',
),
migrations.AddField(
model_name='lessonchunk',
name='check_note',
field=models.CharField(blank=True, max_length=300),
),
migrations.AddField(
model_name='lessonchunk',
name='solution_verdict',
field=models.CharField(blank=True, choices=[('agrees', 'راه\u200cحلِ کتاب درست است'), ('disagrees', 'راه\u200cحلِ کتاب غلط است'), ('unjudgeable', 'بدونِ شکل/جدول قابلِ قضاوت نیست')], db_index=True, max_length=12),
),
]

View File

@@ -53,6 +53,36 @@ class LessonChunk(models.Model):
# اجرای دوباره پس از قطعی، نقاطِ تکراری در Qdrant می‌ساخت.
source_page = models.PositiveIntegerField(null=True, blank=True, db_index=True)
# --- راستی‌آزماییِ راه‌حل ------------------------------------------
# vision گاهی راه‌حلِ غلط از کتاب درمی‌آورد: در پایه نهم ۱۷ مورد پیدا شد،
# مثلِ «۱۰۸×۱۰¹³» به‌جای «۱٫۰۸×۱۰¹⁴» یا دستگاهی بی‌جواب که جوابِ (۸,۵)
# گرفته بود. این خطا در بازیابی دیده نمی‌شود ولی هر مصرف‌کننده‌ای که به
# `solution` اعتماد کند را خراب می‌کند — از ساختِ سؤال تا خودِ دستیار.
#
# `solution` عمداً هیچ‌وقت بازنویسی نمی‌شود: کتاب باید کتاب بماند. اگر
# جوابِ مدل را جایش می‌گذاشتیم و مدل اشتباه می‌کرد، منبعِ حقیقت بی‌صدا
# خراب می‌شد و راهِ برگشتی نبود. پس فقط برچسب می‌خورد و جوابِ مدل جدا
# ذخیره می‌شود تا آدم بتواند داوری کند.
# نتیجه سه‌حالته است، نه بله/خیر. پاسِ اول با بولی اجرا شد و ۱۲٫۵٪ خطا
# گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد نرخِ واقعی ~۴٪ است و بقیه نویز:
# ۳۵٪ مسئله‌هایی که به شکل یا جدولِ کتاب ارجاع می‌دهند و اصلاً قابلِ قضاوت
# نیستند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ اعشاریِ
# ۳٫۱۰۴. با دو حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.
VERDICT_AGREES = 'agrees'
VERDICT_DISAGREES = 'disagrees'
VERDICT_UNJUDGEABLE = 'unjudgeable'
VERDICT_CHOICES = [
(VERDICT_AGREES, 'راه‌حلِ کتاب درست است'),
(VERDICT_DISAGREES, 'راه‌حلِ کتاب غلط است'),
(VERDICT_UNJUDGEABLE, 'بدونِ شکل/جدول قابلِ قضاوت نیست'),
]
solution_checked_at = models.DateTimeField(null=True, blank=True)
solution_verdict = models.CharField(max_length=12, blank=True,
choices=VERDICT_CHOICES, db_index=True)
checked_answer = models.TextField(blank=True)
check_note = models.CharField(max_length=300, blank=True)
checked_by = models.CharField(max_length=60, blank=True)
collection = models.CharField(max_length=200, db_index=True)
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)

View File

@@ -46,6 +46,7 @@ def get_qdrant_client():
url=settings.QDRANT_URL,
api_key=settings.QDRANT_API_KEY,
check_compatibility=False,
timeout=settings.QDRANT_TIMEOUT_SECONDS,
)

View File

@@ -76,6 +76,14 @@ def _payload(chunk: LessonChunk) -> dict:
'chapter_number': chunk.chapter,
'chunk_order': chunk.order,
}
# نتیجه‌ی بازبینیِ راه‌حل. عمداً فقط همین یک کلیدِ کوتاه می‌رود و نه
# `checked_answer`/`check_note`: آن دو در دیتابیس در دسترس‌اند و اینجا فقط
# حجم اضافه می‌کردند. نامش هم عمداً با هیچ‌کدام از کلیدهایی که
# `retrieval._payload_text`/`_payload_title` می‌خوانند یکی نیست، پس یک
# نویسه هم به متنی که به مدل می‌رسد اضافه نمی‌کند — همان اشتباهی که یک بار
# با `summary` رخ داد و context را خراب کرد.
if chunk.solution_verdict:
payload['solution_verdict'] = chunk.solution_verdict
if chunk.lesson_id is not None:
payload['lesson_id'] = chunk.lesson_id
if chunk.source_page is not None:

View File

@@ -103,6 +103,16 @@ def extract_problem_types(image, grade: str, chapter_name: str) -> dict:
"""
یک عکسِ صفحه‌ی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
خروجی همون شکلی‌ست که uploadِ ریاضیِ تیمِ دیگه هم تولید می‌کرد.
⚠️ قاعده‌ی «self-contained» در پرامپت تازه اضافه شده و کتاب‌هایی که تا امروز
وارد شده‌اند بدونِ آن استخراج شده‌اند. اثرش با عدد: از ۲٬۱۱۹ مسئله‌ی موجود،
۸۸۱ تا (۴۲٪) به «شکلِ زیر» یا «جدولِ روبه‌رو» ارجاع می‌دهند و بدونِ آن تصویر
قابلِ حل نیستند — نه برای دانش‌آموز، نه برای دستیار، نه برای ساختِ سؤال.
نکته این است که مدل آن شکل را **دیده بود**؛ محدودیتِ vision نبود، فقط از او
خواسته نشده بود آنچه می‌بیند را بنویسد. برای همین با ingestِ دوباره‌ی همان
صفحه‌ها قابلِ جبران است، ولی چون هزینه دارد فعلاً انجام نشده. هر کتابِ
تازه‌ای که از این پس وارد شود این مشکل را ندارد.
"""
prompt = f"""
You are {teacher_persona(grade)}.
@@ -121,6 +131,15 @@ Rules:
- exampleFromImage must be a real problem from the image (not invented).
- Keep the mathematical notation of the textbook; do not simplify the concept
to a lower grade level than "{grade}".
- SELF-CONTAINED PROBLEMS. You can see the figures, tables and diagrams on this
page, but the reader of your output cannot. Whenever a problem refers to one
("the shape below", "the table opposite", "the given diagram"), write what it
shows INTO the problem text itself: the measurements, the vertex labels, the
angles, the table's values, what is shaded. Someone with only your text must
be able to solve it. If a figure genuinely cannot be put into words (a freehand
drawing task, a colouring exercise), skip that problem instead of writing a
reference to something the reader will never see.
- The same applies to the solution: no step may depend on looking at the page.
- If the page has no math problem at all (cover, foreword, table of contents,
a page of pure text), return an empty "problemTypes" list instead of inventing one.
- Output ONLY the JSON object. No markdown, no backticks.

View File

@@ -54,6 +54,25 @@ def delete_points(collection: str, point_ids: list[str]) -> int:
return len(point_ids)
def set_payload_fields(collection: str, point_ids: list[str], fields: dict) -> int:
"""
فقط چند کلیدِ payload را روی نقاطِ موجود می‌نویسد، بدونِ دست زدن به بردار.
برای همگام‌سازیِ برچسب‌ها همین کافی است و از upsertِ کامل خیلی ارزان‌تر:
نه بردار دوباره فرستاده می‌شود نه embedding دوباره خریده می‌شود.
"""
if not point_ids or not fields:
return 0
client = clients.get_qdrant_client()
client.set_payload(
collection_name=collection,
payload=fields,
points=[str(i) for i in point_ids],
wait=True,
)
return len(point_ids)
def search(collection: str, query_vector: list[float], *, lesson_filter_value=None,
lesson_filter_key: str | None = None, limit: int = 4) -> list[dict]:
"""

View File

@@ -6,6 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@@ -911,3 +912,96 @@ class IngestUpsertRollbackTests(TestCase):
)
self.assertEqual(LessonChunk.objects.filter(collection='ریاضی نهم').count(), 0)
class VerifySolutionsLimitTests(TestCase):
"""
دو خطای ۴۲۹ هست و نباید یکسان با آن‌ها رفتار کرد.
فشارِ لحظه‌ایِ موازی‌کاری گذراست و عقب‌نشینی حلش می‌کند؛ ولی سقفِ مصرفِ
حساب ساعت‌ها باز نمی‌شود. یک اجرا ۱۱۹ بار دوباره تلاش کرد، ۶۵ دقیقه طول
کشید، هیچ chunkی سنجیده نشد، و مهلتِ باز شدن را هم جلوتر برد.
"""
def setUp(self):
self.chunk = LessonChunk.objects.create(
subject='ریاضی', lesson_number=1, order=1, title='توان',
original_text='x', summary='x', collection='ریاضی نهم',
problem='حاصل ۲^۳ چند است؟', solution='۸',
)
@override_settings(**RAG_ON)
def test_account_limit_stops_at_once_instead_of_retrying(self):
from django.core.management.base import CommandError
error = Exception("Error code: 429 - {'code': 'initial_usage_limit_exceeded'}")
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(
create=lambda **kw: (_ for _ in ()).throw(error))))
with patch('rag.services.clients.get_openai_client', return_value=client), \
patch('rag.management.commands.rag_verify_solutions.time.sleep') as slept:
with self.assertRaises(CommandError) as caught:
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
self.assertIn('سقفِ مصرف', str(caught.exception))
slept.assert_not_called() # حتی یک بار هم نباید صبر کرده باشد
@override_settings(**RAG_ON)
def test_an_exhausted_quota_also_stops_at_once(self):
"""
۴۰۳ با متنِ «insufficient user quota» — با فاصله، نه زیرخط.
محافظِ اول دنبالِ `insufficient_quota` می‌گشت و این را نمی‌گرفت؛ یک
اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد به‌جای اینکه سرِ اولی بایستد.
"""
from django.core.management.base import CommandError
error = Exception("Error code: 403 - {'message': 'insufficient user quota, "
"remaining quota: $-0.1'}")
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(
create=lambda **kw: (_ for _ in ()).throw(error))))
with patch('rag.services.clients.get_openai_client', return_value=client), \
patch('rag.management.commands.rag_verify_solutions.time.sleep') as slept:
with self.assertRaises(CommandError):
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
slept.assert_not_called()
@override_settings(**RAG_ON)
def test_a_transient_429_is_still_retried(self):
calls = {'n': 0}
def flaky(**kwargs):
calls['n'] += 1
if calls['n'] == 1:
raise Exception('Error code: 429 - rate limit, slow down')
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(
content='{"verdict": "agrees", "answer": "۸", "why": ""}'))])
client = SimpleNamespace(chat=SimpleNamespace(
completions=SimpleNamespace(create=flaky)))
with patch('rag.services.clients.get_openai_client', return_value=client), \
patch('rag.management.commands.rag_verify_solutions.time.sleep'):
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
self.chunk.refresh_from_db()
self.assertEqual(self.chunk.solution_verdict, LessonChunk.VERDICT_AGREES)
self.assertEqual(calls['n'], 2, 'باید دقیقاً یک بار دوباره تلاش می‌کرد.')
@override_settings(**RAG_ON)
def test_figure_dependent_problems_are_labelled_without_any_call(self):
"""هزینه‌ی صفر برای چیزی که جوابش از متن درنمی‌آید."""
self.chunk.problem = 'مساحت شکلِ زیر را حساب کنید.'
self.chunk.save(update_fields=['problem'])
def boom(**kwargs):
raise AssertionError('نباید هیچ فراخوانی‌ای می‌شد.')
client = SimpleNamespace(chat=SimpleNamespace(
completions=SimpleNamespace(create=boom)))
with patch('rag.services.clients.get_openai_client', return_value=client):
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
self.chunk.refresh_from_db()
self.assertEqual(self.chunk.solution_verdict, LessonChunk.VERDICT_UNJUDGEABLE)
self.assertEqual(self.chunk.checked_by, 'pre-filter')