build the real curriculum from the textbook structure
تا پیش از این کلِ محتوای دیتابیس ردیفهای [DEMO] و [SEED] بود — دانشآموزِ
پایه نهم در دمو «جمع تا ۱۰» میدید. سینیور «یک بیسیکِ کامل با دیتای واقعی»
خواسته بود.
seed_curriculum_from_textbook (رایگان، هیچ فراخوانیِ پولی ندارد):
Grade ← پایهی کتاب
Unit ← فصلِ کتاب
Node ← درسِ داخلِ فصل، از فهرستِ خودِ کتاب
Lesson ← نوعِ مسئلهی استخراجشده در بازهی صفحهی همان درس
intro_text مستقیم از generalSolution کتاب میآید.
generate_questions_from_chunks سوالِ چهارگزینهای با گزینههای انحرافیِ
باورپذیر میسازد (هزینهدار، اعتبارسنجیِ سختگیرانه). سوال اینجا جدا شد
چون ساختنِ گزینهی انحرافیِ درست به مدل نیاز دارد.
purge_demo_data ردیفهای [SEED]/[DEMO] را گزارش و — فقط با --apply — پاک
میکند؛ بدونِ آن این ردیفها در دمو داخلِ API دیده میشوند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
212
content/management/commands/generate_questions_from_chunks.py
Normal file
212
content/management/commands/generate_questions_from_chunks.py
Normal file
@@ -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'])
|
||||||
|
])
|
||||||
87
content/management/commands/purge_demo_data.py
Normal file
87
content/management/commands/purge_demo_data.py
Normal file
@@ -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'
|
||||||
|
))
|
||||||
201
content/management/commands/seed_curriculum_from_textbook.py
Normal file
201
content/management/commands/seed_curriculum_from_textbook.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user