Files
MathApp/content/management/commands/fill_lesson_texts.py
Zareian 8cb1ecb85e Subjects per grade (math, calculus, geometry, stats, discrete), 5 new textbooks, 202 new islands, long islands split, hand-written figures and lesson texts for the new books
- content.Subject + Unit.subject, /api/home?subject=, tutor grounded in the chosen book
- 5 new books ingested; ~1150 hand-written questions, islands named by hand
- node_split: islands over 7 lessons split into parts of <=6 (lesson ids and progress kept)
- Lesson.figures hand-authored and visually checked for 653 lessons of the new books;
  gen_lesson_figures got --subject and survives network errors
- seed files re-exported

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 05:35:18 +03:30

176 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
پر کردنِ متن‌های خالیِ درس و فصل از خودِ کتاب — بدونِ هیچ فراخوانیِ پولی.
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