""" واردکردنِ نقاطِ یک collectionِ موجودِ Qdrant به جدولِ محلیِ LessonChunk. چرا: پایه‌های ششم و هفتم را تیمِ دیگر قبلاً ingest کرده و روی Qdrant هستند، ولی سمتِ ما هیچ ردیفی از آن‌ها نداریم — و `seed_curriculum_from_textbook` برنامه‌ی درسی را از روی LessonChunkهای محلی می‌سازد. این فرمان آن شکاف را پر می‌کند. **خواندن از Qdrant رایگان است** (هیچ فراخوانیِ OpenAI ندارد)، پس این مسیر حتی وقتی اعتبارِ API صفر است هم کار می‌کند. فقط می‌خوانَد: هیچ نقطه‌ای در Qdrant نوشته یا عوض نمی‌شود. `qdrant_point_id` همان شناسه‌ی واقعیِ نقطه ذخیره می‌شود، پس اجرای دوباره ردیفِ تکراری نمی‌سازد. python manage.py rag_import_collection --collection "ریاضی ششم" --dry-run python manage.py rag_import_collection --collection "ریاضی ششم" """ from django.core.management.base import BaseCommand, CommandError from rag.models import LessonChunk from rag.services import clients from rag.services.known_collections import MATH_CHAPTERS from rag.services.pdf import problem_type_to_text class Command(BaseCommand): help = 'واردکردنِ نقاطِ یک collectionِ Qdrant به LessonChunk (فقط خواندن، بدونِ هزینه).' def add_arguments(self, parser): parser.add_argument('--collection', required=True) parser.add_argument('--subject', default='', help='پیش‌فرض از نامِ collection حدس زده می‌شود.') parser.add_argument('--batch', type=int, default=256) parser.add_argument('--dry-run', action='store_true') @staticmethod def _split(collection, subject): """«علوم تجربی سوم» → ('علوم تجربی', 'سوم'). آخرین کلمه پایه است.""" if subject and collection.startswith(subject): return subject, collection[len(subject):].strip() parts = collection.rsplit(' ', 1) return (parts[0], parts[1]) if len(parts) == 2 else (collection, '') def handle(self, *args, **opts): collection = opts['collection'] subject = opts['subject'] try: client = clients.get_qdrant_client() existing = {c.name for c in client.get_collections().collections} except Exception as exc: # noqa: BLE001 raise CommandError(f'اتصال به Qdrant شکست خورد: {exc}') from exc if collection not in existing: raise CommandError( f'collection «{collection}» وجود ندارد. موجود: ' + '، '.join(sorted(existing)) ) # «ریاضی ششم» → («ریاضی», «ششم») برای پیدا کردنِ جدولِ فصل‌ها. subject, grade = self._split(collection, subject) chapter_numbers = { title: number for (subj, grd), table in MATH_CHAPTERS.items() if subj == subject and grd == grade for number, title in table.items() } if not chapter_numbers: self.stdout.write(self.style.NOTICE( f'«{subject} {grade}» جدولِ فصلِ ریاضی ندارد — اگر درسِ متنی است ' 'طبیعی‌ست و از lesson_number استفاده می‌شود.' )) known_ids = set( LessonChunk.objects.filter(collection=collection) .values_list('qdrant_point_id', flat=True) ) known = {str(k) for k in known_ids} rows, skipped, unmapped = [], 0, set() offset = None while True: points, offset = client.scroll( collection_name=collection, limit=opts['batch'], offset=offset, with_payload=True, with_vectors=False, ) for point in points: payload = point.payload or {} # دو شکلِ payload روی همین سرور هست: # ریاضی → chapter (رشته‌ی کاملِ فصل) + type/generalSolution/… # درسِ متنی → lesson_number (عدد) + lesson_title + summary # گروه‌بندی برای هر کدام از کلیدِ خودش می‌آید. chapter_title = (payload.get('chapter') or '').strip() if chapter_title and chapter_title not in chapter_numbers: unmapped.add(chapter_title) if str(point.id) in known: skipped += 1 continue rows.append((point.id, payload, chapter_title)) if offset is None: break self.stdout.write(self.style.MIGRATE_HEADING( f'\n«{collection}» — {len(rows)} نقطه‌ی تازه، {skipped} از قبل موجود\n' )) if unmapped: self.stdout.write(self.style.WARNING( 'عنوانِ فصلِ ناشناخته (شماره نمی‌گیرد): ' + '، '.join(sorted(unmapped)) )) if opts['dry_run']: self.stdout.write(self.style.WARNING('--dry-run: چیزی نوشته نشد.')) return if not rows: self.stdout.write('چیزی برای واردکردن نیست.') return def group_number(payload, chapter_title): """شماره‌ی گروه: برای ریاضی شماره‌ی فصل، برای درسِ متنی شماره‌ی درس.""" if chapter_title: return chapter_numbers.get(chapter_title) try: return int(payload.get('lesson_number')) except (TypeError, ValueError): return None # order در هر گروه ادامه‌دار می‌ماند تا اجرای دوباره شماره‌ها را از نو # نزند. کلیدِ گروه همان چیزی‌ست که در دیتابیس هم رویش فیلتر می‌کنیم. orders: dict[tuple[str, int], int] = {} for _pid, payload, chapter_title in rows: key = (chapter_title, group_number(payload, chapter_title) or 0) if key not in orders: orders[key] = LessonChunk.objects.filter( collection=collection, chapter_title=key[0], lesson_number=key[1], ).count() objects = [] for point_id, payload, chapter_title in rows: is_math = bool(payload.get('generalSolution') or payload.get('problem')) number = group_number(payload, chapter_title) key = (chapter_title, number or 0) orders[key] = orders.get(key, 0) + 1 if is_math: text = problem_type_to_text({ 'type': payload.get('type', ''), 'keywords': payload.get('keywords', []), 'generalSolution': payload.get('generalSolution', ''), 'exampleFromImage': { 'problem': payload.get('problem', ''), 'solution': payload.get('solution', ''), }, }) title = chapter_title or subject summary = text else: # درسِ متنی: گروه‌بندی با lesson_number، محتوا در summary. text = summary = (payload.get('summary') or '').strip() title = (payload.get('lesson_title') or '').strip() or f'درس {number or "؟"}' objects.append(LessonChunk( lesson_id=None, subject=subject, lesson_number=number or 0, chapter=number if is_math else None, chapter_title=chapter_title, grade_title=(payload.get('grade') or grade).strip(), order=orders[key], title=title, original_text=text, summary=summary, # embedding عمداً ذخیره نمی‌شود: بردار همین حالا در Qdrant هست و # گرفتنش هیچ سودی ندارد جز حجم. این ردیف‌ها فقط برای ساختنِ # برنامه‌ی درسی و بازبینی‌اند، نه برای بازسازیِ collection. embedding_vector=None, problem_type=(payload.get('type') or '').strip(), keywords=payload.get('keywords') or [], general_solution=(payload.get('generalSolution') or '').strip(), problem=(payload.get('problem') or '').strip(), solution=(payload.get('solution') or '').strip(), collection=collection, qdrant_point_id=point_id, source_page=None, # دادهٔ تیمِ دیگر شماره‌ی صفحه ندارد )) LessonChunk.objects.bulk_create(objects, batch_size=200) self.stdout.write(self.style.SUCCESS(f'{len(objects)} ردیف ساخته شد.')) if subject == 'ریاضی': self.stdout.write(self.style.HTTP_INFO( f'قدمِ بعد: python manage.py seed_curriculum_from_textbook --grade {grade}' )) else: self.stdout.write(self.style.HTTP_INFO( 'این درسِ متنی است و در textbooks.py ثبت نشده، پس برنامه‌ی درسی ' 'برایش ساخته نمی‌شود؛ فقط chunkها برای RAG و بازبینی وارد شدند.' ))