""" پر کردنِ متن‌های خالیِ درس و فصل از خودِ کتاب — بدونِ هیچ فراخوانیِ پولی. python manage.py fill_lesson_texts # همه‌ی پایه‌ها python manage.py fill_lesson_texts --dry-run # فقط گزارش - `Unit.blurb`: یک جمله از عنوانِ جزیره‌های همان فصل («در این فصل: …»). - `Lesson.example` و `Lesson.steps`: یک مسئله‌ی حل‌شده‌ی خودِ کتاب برای همان درس، فقط از تکه‌هایی که راه‌حلشان بازبینی و تأیید شده (`solution_verdict='agrees'`)، با جوابِ بازسنجی‌شده (`checked_answer`) در قدمِ آخر. دو قاعده که عمداً اینجاست: 1. **فقط خانه‌ی خالی پر می‌شود.** متنی که کسی از پنلِ ادمین نوشته دست نمی‌خورد، و اجرای دوباره چیزی را عوض نمی‌کند. 2. **مثال همان مسئله‌ی آزمونِ درس نیست.** سوالِ آزمون از اولین تکه‌ی تأییدشده‌ی درس ساخته شده (generate_questions_from_chunks)؛ اینجا از دومی به بعد برداشته می‌شود و تکه‌ای که جوابش با جوابِ درستِ سوالِ همان درس یکی است کنار می‌رود. بدونِ این، صفحه‌ی درس جوابِ آزمون را پیشاپیش لو می‌داد. آزموده نشده: اینکه بازنویسیِ مدل در ساختِ سوال جواب را عوض کرده باشد و این مقایسه آن را نبیند. """ import re from django.core.management.base import BaseCommand from django.db import transaction from content.management.commands.generate_questions_from_chunks import Command as QuestionCommand from content.models import Choice, Grade, Lesson, Unit from rag.models import LessonChunk from rag.services.config import build_collection_name from rag.services.mathtext import to_plain from rag.services.textbooks import TEXTBOOKS BLURB_MAX = 120 STEP_MAX = 6 STEP_CHARS = 320 EXAMPLE_CHARS = 700 _DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789') def _norm(text: str) -> str: """برای مقایسه‌ی جواب‌ها: رقمِ لاتین، بی‌فاصله، بی‌علامتِ اضافه.""" return re.sub(r'[\s٫.,،:]+', '', (text or '').translate(_DIGITS)).lower() # کلمه‌های عنوانِ درس که چیزی درباره‌ی موضوع نمی‌گویند _STOP = {'و', 'از', 'با', 'در', 'به', 'برای', 'یک', 'دو', 'های', 'ها', 'آن', 'این', 'حل', 'محاسبه', 'تعیین', 'پیدا', 'کردن', 'نوشتن', 'رسم', 'مسئله', 'مسائل', 'تمرین', 'مقدار', 'عدد', 'اعداد', 'شکل', 'کمک', 'داده', 'شده', 'تعداد', 'ممکن', 'شرایط', 'درست', 'نادرست', 'عبارت', 'عبارت‌های', 'استفاده', 'رابطه', 'بررسی', 'تشخیص', 'یافتن', 'نمایش', 'مقایسه', 'کاربرد', 'مفهوم', 'روش', 'انواع', 'ساده', 'طول'} # مسئله‌ای که به شکل یا جدولی اشاره می‌کند که در متن نیست، به‌عنوانِ مثال به کار نمی‌آید _FIGURE_REF = re.compile(r'شکل|روبه‌رو|روبرو|مقابل|صفحه(?:ٔ|ی)?\s*قبل|فعالیت|داده شده را|جدول زیر|نمودار(?: زیر|\s*ون|\s*توابع)|زیر را|توابع f و g|با توجه به نمودار') def related(lesson_title: str, chunk) -> bool: """ آیا مسئله به همین درس مربوط است؟ جفت‌شدن از روی بازه‌ی صفحه گاهی مسئله‌ی نامربوطِ همان صفحه‌ها را می‌داد (برای «نمودارِ قدرمطلق» مسئله‌ای درباره‌ی نمایشِ چندجمله‌ای). دستِ‌کم یک کلمه‌ی معنادارِ عنوان باید در نوع یا متنِ مسئله باشد. """ words = [w for w in re.split(r'[\s،,()«»\-/]+', lesson_title) if len(w) >= 3 and w not in _STOP] if not words: return False haystack = f'{chunk.problem_type} {chunk.problem}' return sum(w in haystack for w in words) >= min(2, len(words)) def unit_blurb(unit: Unit) -> str: titles = [n.title.strip() for n in unit.nodes.order_by('order') if n.title.strip()] if not titles: return '' head = 'در این فصل: ' parts: list[str] = [] for t in titles: candidate = '، '.join(parts + [t]) if len(head) + len(candidate) + 1 > BLURB_MAX: break parts.append(t) if not parts: return (head + titles[0])[:BLURB_MAX - 1] + '…' text = '، '.join(parts[:-1]) + (f' و {parts[-1]}' if len(parts) > 1 else parts[0]) if len(parts) < len(titles): text += ' و …' return f'{head}{text}.'[:BLURB_MAX] def split_steps(solution: str) -> list[str]: """راه‌حلِ کتاب → چند قدمِ کوتاه. اول با خط‌های جدا، بعد با جمله‌ها.""" plain = to_plain(solution or '').strip() lines = [ln.strip(' -•\t') for ln in plain.splitlines() if ln.strip(' -•\t')] if len(lines) <= 1: lines = [s.strip() for s in re.split(r'(?<=[.؛!؟])\s+', plain) if s.strip()] # شماره‌ی قدم را فرانت خودش می‌گذارد lines = [re.sub(r'^(?:(?:گام|مرحله|قدم)\s*[0-9۰-۹]{1,2}\s*[::.\-)]?|[0-9۰-۹]{1,2}[).\-])\s*', '', ln) for ln in lines] if len(lines) > STEP_MAX: # قدم‌های اضافه در قدمِ آخر ادغام می‌شوند تا چیزی گم نشود lines = lines[:STEP_MAX - 1] + [' '.join(lines[STEP_MAX - 1:])] return [ln[:STEP_CHARS] for ln in lines if ln] class Command(BaseCommand): help = 'پر کردنِ Unit.blurb و Lesson.example/steps از کتاب (فقط خانه‌های خالی؛ بدونِ هزینه).' def add_arguments(self, parser): parser.add_argument('--dry-run', action='store_true', help='فقط گزارش بده؛ چیزی ننویس.') def handle(self, *args, **opts): dry = opts['dry_run'] counts = {'blurbs': 0, 'examples': 0, 'no_chunk': 0, 'kept': 0} with transaction.atomic(): for unit in Unit.objects.prefetch_related('nodes'): if unit.blurb.strip(): counts['kept'] += 1 continue blurb = unit_blurb(unit) if blurb: unit.blurb = blurb unit.save(update_fields=['blurb']) counts['blurbs'] += 1 finder = QuestionCommand() for book in TEXTBOOKS.values(): grade = Grade.objects.filter(title=f'پایه {book.grade}').first() if grade is None: continue finder._book = book collection = build_collection_name(book.subject, book.grade) lessons = (Lesson.objects.filter(node__unit__grade=grade, node__unit__subject__title=book.subject) .select_related('node__unit').order_by('node__unit__order', 'node__order', 'id')) for lesson in lessons: if lesson.example.strip() or lesson.steps: continue chunk = self._example_chunk(finder, lesson, collection) if chunk is None: counts['no_chunk'] += 1 continue steps = split_steps(chunk.solution) answer = (chunk.checked_answer or '').strip() if answer and not any(_norm(answer) in _norm(s) for s in steps[-2:]): steps = (steps[:STEP_MAX - 1] if len(steps) >= STEP_MAX else steps) + [f'✅ جواب: {to_plain(answer)}'] lesson.example = to_plain(chunk.problem)[:EXAMPLE_CHARS] lesson.steps = steps lesson.save(update_fields=['example', 'steps']) counts['examples'] += 1 if dry: transaction.set_rollback(True) prefix = 'اگر اجرا شود: ' if dry else '' self.stdout.write(self.style.SUCCESS( f'{prefix}{counts["blurbs"]} توضیحِ فصل و {counts["examples"]} مثالِ درس پر شد؛ ' f'{counts["no_chunk"]} درس مسئله‌ی تأییدشده‌ی دوم نداشت؛ ' f'{counts["kept"]} فصل از قبل متن داشت و دست نخورد.' )) def _example_chunk(self, finder, lesson, collection): """دومین مسئله‌ی تأییدشده‌ی درس، که جوابش جوابِ سوالِ آزمونِ همین درس نباشد.""" by_title = list( LessonChunk.objects.filter(collection=collection, problem_type=lesson.title, solution_verdict=LessonChunk.VERDICT_AGREES) .exclude(problem='').exclude(solution='').order_by('source_page', 'order')[:6] ) candidates = by_title or finder._chunks_by_page( lesson, collection, need=6, opts={'verified_only': True}, used=set(), skip=0) quiz_answers = { _norm(v) for v in Choice.objects.filter(question__lesson=lesson, is_correct=True).values_list('value', flat=True) } # اولی مالِ سوالِ آزمون است (همان ترتیبِ قطعیِ ساختِ سوال) for chunk in candidates[1:]: if not (chunk.solution or '').strip() or _FIGURE_REF.search(chunk.problem): continue if not by_title and not related(lesson.title, chunk): continue if _norm(chunk.checked_answer) and _norm(chunk.checked_answer) in quiz_answers: continue return chunk return None