Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with computed roots/extrema/tangents, number lines with intervals, solids, charts, tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is split by topic and only the matching sections go next to a question (200-1073 tokens instead of 1854). parse() repairs slips seen on 96 real book problems: Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong. Content: 82 new questions so no island has fewer than four; fill_lesson_texts writes Unit.blurb (58) and Lesson.example/steps (590) from verified book problems, never the lesson's own quiz problem. seed_content only fills empty fields, so admin edits survive a deploy. Tedi unlocks at 500 xp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
9.9 KiB
Python
176 lines
9.9 KiB
Python
"""
|
||
پر کردنِ متنهای خالیِ درس و فصل از خودِ کتاب — بدونِ هیچ فراخوانیِ پولی.
|
||
|
||
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)
|
||
.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
|