diff --git a/content/management/commands/generate_questions_from_chunks.py b/content/management/commands/generate_questions_from_chunks.py new file mode 100644 index 0000000..7695a92 --- /dev/null +++ b/content/management/commands/generate_questions_from_chunks.py @@ -0,0 +1,212 @@ +""" +ساختِ سوال‌های چهارگزینه‌ای از مسئله‌های استخراج‌شده‌ی کتاب. + +`seed_curriculum_from_textbook` اسکلت (Grade/Unit/Node/Lesson) را از دادهٔ +موجود و بدونِ هزینه می‌سازد؛ این فرمان تکه‌ی گران را اضافه می‌کند: برای هر درس +یک سوال با سه گزینه‌ی انحرافیِ **باورپذیر**. + +چرا مدل لازم است و مکانیکی نمی‌شود: گزینه‌ی انحرافیِ خوب باید همان اشتباهی +باشد که دانش‌آموز واقعاً می‌کند (علامتِ توان را جابه‌جا کند، مخرج مشترک نگیرد). +عددِ تصادفی یا «جوابِ درست ± ۱» در دمو فوراً تابلو می‌شود و کلِ سنجشِ کیفیت را +بی‌معنا می‌کند. + + python manage.py generate_questions_from_chunks --grade نهم --limit 5 --dry-run + python manage.py generate_questions_from_chunks --grade نهم --limit 20 + python manage.py generate_questions_from_chunks --grade نهم +""" +import json + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from content.models import Choice, Grade, Lesson, Question +from rag.models import LessonChunk +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} هستی. + +از روی «مسئله» و «راه‌حل»ِ زیر — که مستقیماً از کتابِ درسی درآمده — یک سوالِ +چهارگزینه‌ای بساز. + +قواعد: +- صورتِ سوال باید کوتاه، خودکفا و به فارسی باشد؛ دانش‌آموز بدونِ دیدنِ کتاب + بتواند جواب دهد. +- دقیقاً چهار گزینه، و دقیقاً یکی درست. +- سه گزینه‌ی نادرست باید **خطاهای رایجِ همین مبحث** باشند (مثلاً اشتباه در + علامتِ توان، جا انداختنِ یک مرحله، مخرجِ مشترک نگرفتن) — نه عددِ تصادفی و + نه گزینه‌ی آشکارا مسخره. +- هر گزینه حداکثر ۴۰ نویسه. +- اگر از روی این متن نمی‌شود سوالِ سالمی ساخت، دقیقاً {{"skip": true}} برگردان. + +نوعِ مسئله: {problem_type} +مسئله: {problem} +راه‌حل: {solution} + +فقط این JSON را برگردان، بدونِ markdown و بدونِ توضیح: +{{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}} +""" + + +class Command(BaseCommand): + help = 'ساختِ سوالِ چهارگزینه‌ای از chunkهای کتاب (هزینه دارد).' + + def add_arguments(self, parser): + parser.add_argument('--subject', default='ریاضی') + parser.add_argument('--grade', required=True) + parser.add_argument('--chapters', nargs='*', type=int, default=None) + parser.add_argument('--limit', type=int, default=None, + help='سقفِ تعدادِ سوال در این اجرا (کنترلِ هزینه).') + parser.add_argument('--per-lesson', type=int, default=1, + help='چند سوال برای هر درس.') + parser.add_argument('--dry-run', action='store_true', + help='فقط نشان بده چند سوال ساخته می‌شود؛ هیچ فراخوانیِ پولی نکن.') + + def handle(self, *args, **opts): + book = get_textbook(opts['subject'], opts['grade']) + if book is None: + raise CommandError( + f'کتابی برای «{opts["subject"]} {opts["grade"]}» ثبت نشده. ' + 'موجود: ' + '، '.join(sorted(TEXTBOOKS)) + ) + + grade = Grade.objects.filter(title=f'پایه {book.grade}').first() + if grade is None: + raise CommandError( + f'پایه‌ی «پایه {book.grade}» در دیتابیس نیست. ' + f'اول: python manage.py seed_curriculum_from_textbook --grade {book.grade}' + ) + + collection = build_collection_name(book.subject, book.grade) + targets = self._targets(grade, collection, opts) + if not targets: + self.stdout.write(self.style.WARNING('هیچ درسی بدونِ سوال پیدا نشد — کاری لازم نیست.')) + return + + self.stdout.write(self.style.MIGRATE_HEADING( + f'\n{len(targets)} سوال برای پایه {book.grade}\n' + )) + for lesson, chunk in targets[:10]: + self.stdout.write(f' • {lesson.node.unit.title} / {lesson.title}') + if len(targets) > 10: + self.stdout.write(f' … و {len(targets) - 10} مورد دیگر') + + # ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini. + cost = len(targets) * (900 / 1e6 * 0.15 + 150 / 1e6 * 0.60) + self.stdout.write(self.style.WARNING( + f'\nبرآوردِ هزینه با {settings.OPENAI_CHAT_MODEL}: ≈ ${cost:.3f}' + )) + if opts['dry_run']: + self.stdout.write(self.style.WARNING('--dry-run: چیزی ساخته نشد.')) + return + + if not rag_is_configured(): + raise CommandError('RAG پیکربندی نشده (کلیدها خالی‌اند یا RAG_FORCE_MOCK روشن است).') + + 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]}')) + + self.stdout.write(self.style.SUCCESS( + f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.' + )) + + # ------------------------------------------------------------------ + def _targets(self, grade, collection, opts): + """[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند.""" + lessons = ( + Lesson.objects + .filter(node__unit__grade=grade) + .select_related('node__unit') + .order_by('node__unit__order', 'node__order', 'id') + ) + if opts['chapters']: + lessons = lessons.filter(node__unit__order__in=opts['chapters']) + + targets = [] + for lesson in lessons: + have = lesson.questions.count() + 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] + ) + 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): + 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, + 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', '```'): + if raw.startswith(fence): + raw = raw[len(fence):].strip() + if raw.endswith('```'): + raw = raw[:-3].strip() + + data = json.loads(raw) + if data.get('skip'): + return None + + # ⚠️ گزینه‌های خالی را حذف نمی‌کنیم و بعد اعتبارسنجی می‌کنیم: اگر مدل + # پنج گزینه بدهد که یکی‌شان خالی است، حذفِ آن یکی چهارتا باقی می‌گذارد + # ولی correctIndex همچنان به فهرستِ اصلی اشاره می‌کند — یعنی سوال + # می‌سازیم و گزینه‌ی *غلط* را درست علامت می‌زنیم. پس اول ساختار را + # همان‌طور که آمده می‌سنجیم. + raw_options = data.get('options') + index = data.get('correctIndex') + if not isinstance(raw_options, list) or len(raw_options) != 4: + raise ValueError(f'دقیقاً چهار گزینه لازم است: {raw_options!r}') + if not isinstance(index, int) or isinstance(index, bool) or not 0 <= index < 4: + raise ValueError(f'correctIndex نامعتبر: {index!r}') + + options = [str(o).strip()[:100] for o in raw_options] + if not all(options) or len(set(options)) != 4: + raise ValueError(f'گزینه‌ی خالی یا تکراری: {options}') + if not (data.get('prompt') or '').strip(): + raise ValueError('صورتِ سوال خالی بود') + return {'prompt': data['prompt'].strip(), 'options': options, 'correctIndex': index} + + @transaction.atomic + def _persist(self, lesson, data): + question = Question.objects.create( + lesson=lesson, type='multiple_choice', prompt=data['prompt'], + ) + Choice.objects.bulk_create([ + Choice(question=question, value=value, is_correct=(i == data['correctIndex'])) + for i, value in enumerate(data['options']) + ]) diff --git a/content/management/commands/purge_demo_data.py b/content/management/commands/purge_demo_data.py new file mode 100644 index 0000000..8366229 --- /dev/null +++ b/content/management/commands/purge_demo_data.py @@ -0,0 +1,87 @@ +""" +پاک‌کردنِ ردیف‌های نمونه‌ی «[SEED]» و «[DEMO]» از دیتابیسِ محلی. + +چرا لازم است: این ردیف‌ها در APIهای واقعی دیده می‌شوند — «[DEMO] پایهٔ نمونه» +در `/api/grades`، «[SEED] ماهانه» در پلن‌ها، «[SEED] کاربر اصلی» در لیدربورد — +و در دمو کنارِ محتوای واقعیِ کتاب بد به نظر می‌رسند. + +برگشت‌پذیر است: `python manage.py seed_test_data` دوباره می‌سازدشان. + + python manage.py purge_demo_data # فقط نشان می‌دهد + python manage.py purge_demo_data --apply # واقعاً پاک می‌کند +""" +from django.core.management.base import BaseCommand +from django.db import transaction + +PREFIXES = ('[SEED]', '[DEMO]') + + +class Command(BaseCommand): + help = 'پاک‌کردنِ ردیف‌های نمونه‌ی [SEED]/[DEMO] (برگشت‌پذیر با seed_test_data).' + + def add_arguments(self, parser): + parser.add_argument('--apply', action='store_true', + help='بدونِ این فقط گزارش می‌دهد و چیزی پاک نمی‌کند.') + + def handle(self, *args, **opts): + from accounts.models import User + from content.models import Grade + from progress.models import Character + from subscription.models import Plan + + # (مدل، نامِ فیلدِ متنی) — همه‌ی جاهایی که seed_test_data برچسب می‌زند. + targets = [ + (Grade, 'title'), (Plan, 'title'), (Character, 'name'), (User, 'name'), + ] + + found = [] + for model, field in targets: + queryset = model.objects.none() + for prefix in PREFIXES: + queryset = queryset | model.objects.filter(**{f'{field}__startswith': prefix}) + rows = list(queryset.distinct()) + if rows: + found.append((model, field, rows)) + + if not found: + self.stdout.write(self.style.SUCCESS('هیچ ردیفِ نمونه‌ای پیدا نشد.')) + return + + self.stdout.write(self.style.MIGRATE_HEADING('\nردیف‌های نمونه\n')) + total = 0 + for model, field, rows in found: + self.stdout.write(f' {model.__name__} — {len(rows)} ردیف') + for row in rows[:8]: + self.stdout.write(f' #{row.pk} {getattr(row, field)}') + if len(rows) > 8: + self.stdout.write(f' … و {len(rows) - 8} مورد دیگر') + total += len(rows) + + # هشدارِ لازم: بعضی از این‌ها تنها دادهٔ آن endpoint هستند. + empties = [ + name for model, name in ((Plan, 'subscription/plans'), (Character, 'characters')) + if model.objects.count() == len([r for m, _f, rows in found if m is model for r in rows]) + ] + if empties: + self.stdout.write(self.style.WARNING( + '\n⚠ بعد از پاک‌کردن این endpointها خالی می‌شوند: ' + '، '.join(empties) + + '\n (پلن و کاراکتر را ادمین در جنگو-ادمین می‌سازد؛ اگر برای دمو ' + 'لازم‌اند، اول واقعی‌شان را بساز بعد این را اجرا کن.)' + )) + + if not opts['apply']: + self.stdout.write(self.style.WARNING( + f'\n{total} ردیف. چیزی پاک نشد — برای پاک‌کردن --apply بدهید.' + )) + return + + # حذفِ آبشاری: Grade کلِ زیردرختِ Unit/Node/Lesson/Question را می‌برد، + # و User سشن‌ها و پاسخ‌هایش را. همه‌شان دادهٔ نمونه‌اند. + with transaction.atomic(): + for model, _field, rows in found: + model.objects.filter(pk__in=[r.pk for r in rows]).delete() + + self.stdout.write(self.style.SUCCESS(f'\n{total} ردیفِ نمونه پاک شد.')) + self.stdout.write(self.style.HTTP_INFO( + 'برای برگرداندن: python manage.py seed_test_data' + )) diff --git a/content/management/commands/seed_curriculum_from_textbook.py b/content/management/commands/seed_curriculum_from_textbook.py new file mode 100644 index 0000000..2eaad14 --- /dev/null +++ b/content/management/commands/seed_curriculum_from_textbook.py @@ -0,0 +1,201 @@ +""" +ساختِ برنامه‌ی درسیِ واقعی (Grade → Unit → Node → Lesson) از روی ساختارِ +کتابِ درسی و chunkهای ingest‌شده. + +چرا لازم بود: تا پیش از این کلِ محتوای دیتابیس ردیف‌های «[DEMO] پایهٔ نمونه» +و «[SEED] پایه سوم» بود — یعنی دانش‌آموزِ پایه نهم در دمو «جمع تا ۱۰» می‌دید. +سینیور خواست «یک بیسیکِ کامل با دیتای واقعی کار کند». + +نگاشت: + Grade ← پایه‌ی کتاب («پایه نهم») + Unit ← فصلِ کتاب («فصل ۴: توان و ریشه») + Node ← درسِ داخلِ فصل، از فهرستِ کتاب («توان صحیح») + Lesson ← نوعِ مسئله‌ی استخراج‌شده در بازه‌ی صفحه‌ی همان درس + +سوال‌ها اینجا ساخته نمی‌شوند: ساختنِ گزینه‌های انحرافیِ درست به مدل نیاز دارد +(`generate_questions_from_chunks`). این فرمان هیچ فراخوانیِ پولی ندارد. + + python manage.py seed_curriculum_from_textbook --grade نهم --dry-run + python manage.py seed_curriculum_from_textbook --grade نهم +""" +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from content.models import Grade, Lesson, Node, Unit +from rag.models import LessonChunk +from rag.services.config import build_collection_name +from rag.services.textbooks import TEXTBOOKS, get_textbook + +GRADE_STYLE = { + 'ششم': ('grade-6', '#4CAF50'), + 'هفتم': ('grade-7', '#2196F3'), + 'هشتم': ('grade-8', '#9C27B0'), + 'نهم': ('grade-9', '#FF9800'), + 'دهم': ('grade-10', '#E91E63'), +} + + +class Command(BaseCommand): + help = 'ساختِ Grade/Unit/Node/Lesson از ساختارِ کتاب و chunkهای ingest‌شده (بدونِ هزینه).' + + def add_arguments(self, parser): + parser.add_argument('--subject', default='ریاضی') + parser.add_argument('--grade', required=True, help='مثلاً نهم') + parser.add_argument('--dry-run', action='store_true') + parser.add_argument('--min-chunks', type=int, default=1, + help='درسی که کمتر از این تعداد chunk دارد ساخته نشود.') + parser.add_argument('--group-size', type=int, default=8, + help='وقتی chunkها شماره‌ی صفحه ندارند، هر چند نوع مسئله ' + 'زیرِ یک گره جمع شود.') + + def handle(self, *args, **opts): + subject, grade = opts['subject'], opts['grade'] + book = get_textbook(subject, grade) + if book is None: + raise CommandError( + f'کتابی برای «{subject} {grade}» ثبت نشده. موجود: ' + '، '.join(sorted(TEXTBOOKS)) + ) + + collection = build_collection_name(book.subject, book.grade) + if not LessonChunk.objects.filter(collection=collection).exists(): + raise CommandError( + f'هیچ chunkی در «{collection}» نیست. اول rag_ingest_book را اجرا کنید.' + ) + + plan = self._build_plan(book, collection, opts['min_chunks'], opts['group_size']) + self._print_plan(book, plan) + + if opts['dry_run']: + self.stdout.write(self.style.WARNING('\n--dry-run: چیزی نوشته نشد.')) + return + + with transaction.atomic(): + counts = self._apply(book, plan) + self.stdout.write(self.style.SUCCESS( + f"\nساخته/به‌روزرسانی شد: {counts['units']} فصل، {counts['nodes']} درس، " + f"{counts['lessons']} نوعِ مسئله." + )) + self.stdout.write(self.style.HTTP_INFO( + 'قدمِ بعد: python manage.py generate_questions_from_chunks ' + f'--grade {book.grade} (این یکی هزینه دارد)' + )) + + # ------------------------------------------------------------------ + def _build_plan(self, book, collection, min_chunks, group_size): + """[(chapter, [(node_title, node_order, {نوع: [chunk]}), ...]), ...]""" + plan = [] + for chapter in book.chapters: + groups = self._sectioned(book, collection, chapter, min_chunks) + if not groups: + # chunkهای واردشده از Qdrantِ تیمِ دیگر شماره‌ی صفحه ندارند، پس + # نمی‌شود آن‌ها را به درس‌های فهرستِ کتاب نسبت داد. به‌جای جا + # انداختنِ کلِ فصل، انواعِ مسئله را دسته‌دسته زیرِ گره می‌بریم. + groups = self._grouped(collection, chapter, min_chunks, group_size) + if groups: + plan.append((chapter, groups)) + return plan + + def _sectioned(self, book, collection, chapter, min_chunks): + """درس‌بندیِ واقعی از فهرستِ کتاب — فقط وقتی chunkها شماره‌ی صفحه دارند.""" + groups = [] + sections = chapter.resolved_sections() + for position, section in enumerate(sections): + # اولین درس تا ابتدای خودِ فصل عقب کشیده می‌شود: صفحه‌ی بازکننده‌ی + # فصل (که معمولاً قبل از «درس اول» است) در هیچ بازه‌ای نمی‌افتاد و + # chunkهایش بی‌صدا از برنامه‌ی درسی حذف می‌شدند. + start_page = chapter.first_page if position == 0 else section.first_page + first = start_page + book.page_offset + last = section.last_page + book.page_offset + chunks = list( + LessonChunk.objects + .filter(collection=collection, chapter=chapter.number, + source_page__gte=first, source_page__lte=last) + .order_by('source_page', 'order') + ) + by_type = self._by_type(chunks, min_chunks) + if by_type: + groups.append((section.title, section.number, by_type)) + return groups + + def _grouped(self, collection, chapter, min_chunks, group_size): + """ + جایگزین برای فصلی که chunkهایش شماره‌ی صفحه ندارند (دادهٔ واردشده از + Qdrantِ تیمِ دیگر). + + ⚠️ این گروه‌بندی **مالِ ماست، نه ترتیبِ کتاب**: نقاطِ آن collection نه + شماره‌ی صفحه دارند و نه ترتیب، و scroll هم بر اساسِ UUID برمی‌گرداند + یعنی عملاً تصادفی. پس بر اساسِ نامِ نوعِ مسئله مرتب می‌کنیم تا دستِ‌کم + قطعی و تکرارپذیر باشد، و گره را «گروه» می‌نامیم نه «بخش» تا وانمود + نکند ترتیبِ آموزشیِ کتاب است. + """ + chunks = list( + LessonChunk.objects + .filter(collection=collection, chapter=chapter.number, source_page__isnull=True) + .order_by('order', 'id') + ) + by_type = self._by_type(chunks, min_chunks) + if not by_type: + return [] + + # «فصل ۲: کسر» → «کسر»، تا عنوانِ گره در نقشه‌ی مهارت تکراری نشود. + topic = chapter.title.split(':', 1)[-1].strip() or chapter.title + items = sorted(by_type.items(), key=lambda kv: kv[0]) + parts = [items[i:i + group_size] for i in range(0, len(items), group_size)] + return [ + (topic if len(parts) == 1 else f'{topic} — گروه {index}', index, dict(part)) + for index, part in enumerate(parts, start=1) + ] + + @staticmethod + def _by_type(chunks, min_chunks): + """هر نوعِ مسئله یک Lesson؛ یک نوع که در چند جا آمده یکی می‌شود.""" + by_type: dict[str, list[LessonChunk]] = {} + for chunk in chunks: + key = (chunk.problem_type or '').strip() or 'تمرین' + by_type.setdefault(key, []).append(chunk) + return {k: v for k, v in by_type.items() if len(v) >= min_chunks} + + def _print_plan(self, book, plan): + self.stdout.write(self.style.MIGRATE_HEADING( + f'\nپایه {book.grade} — {len(plan)} فصل با محتوای موجود\n' + )) + for chapter, groups in plan: + total = sum(len(t) for _title, _order, t in groups) + self.stdout.write(f' {chapter.title} ({len(groups)} درس، {total} نوع مسئله)') + for title, order, by_type in groups: + self.stdout.write(f' درس {order}: {title} — {len(by_type)} نوع') + + def _apply(self, book, plan): + icon, color = GRADE_STYLE.get(book.grade, ('grade', '#607D8B')) + grade_title = f'پایه {book.grade}' + grade, _ = Grade.objects.update_or_create( + title=grade_title, defaults={'icon': icon, 'color': color}, + ) + + counts = {'units': 0, 'nodes': 0, 'lessons': 0} + for chapter, groups in plan: + unit, _ = Unit.objects.update_or_create( + grade=grade, title=chapter.title, + defaults={'order': chapter.number}, + ) + counts['units'] += 1 + + for node_title, node_order, by_type in groups: + node, _ = Node.objects.update_or_create( + unit=unit, title=node_title, + defaults={'order': node_order}, + ) + counts['nodes'] += 1 + + for order, (problem_type, chunks) in enumerate(by_type.items(), start=1): + # متنِ مقدمه از generalSolutionِ خودِ کتاب می‌آید — محتوای + # واقعیِ آموزشی، بدونِ هیچ فراخوانیِ مدلی. + intro = next( + (c.general_solution for c in chunks if c.general_solution), '' + ) or f'{problem_type} — {node_title}' + Lesson.objects.update_or_create( + node=node, title=problem_type, + defaults={'intro_text': intro, 'mascot_animation': 'explain'}, + ) + counts['lessons'] += 1 + return counts