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