ingest whole textbooks chapter by chapter, resumably

نقشه‌ی فیزیکیِ کتاب‌ها (افستِ صفحه، بازه‌ی هر فصل، درس‌های داخلِ فصل از
فهرستِ خودِ کتاب) در rag/services/textbooks.py ثبت شد تا عنوانِ فصل از
کتاب بیاید نه از حدسِ مدل — رشته‌ی فصل باید دقیقاً بینِ ingest و retrieval
یکی باشد وگرنه نتیجه بی‌سروصدا خالی برمی‌گردد.

rag_ingest_book کتاب را فصل‌به‌فصل ingest می‌کند و قابلِ ازسرگیری است:
rag.IngestedPage نشانه‌ی «این صفحه پردازش شد» را نگه می‌دارد حتی وقتی
صفحه chunkی نساخته، وگرنه هر بار اجرای دوباره بابتِ صفحه‌های بی‌مسئله
دوباره پول می‌گرفت. --plan هزینه را رایگان برآورد می‌کند.

rag_import_collection نقاطِ موجودِ Qdrant را در LessonChunk می‌ریزد —
فقط می‌خواند، رایگان و idempotent، برای پایه‌هایی که تیمِ دیگر ingest
کرده بود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 18:59:03 +03:30
parent 52d9694583
commit c79be0a336
11 changed files with 1139 additions and 35 deletions

View File

@@ -18,7 +18,9 @@ class LessonChunk(models.Model):
دوباره‌ی embedding قابلِ بازسازی باشه.
"""
lesson_id = models.IntegerField(db_index=True) # ارجاع سبک به content.Lesson
# ارجاع سبک به content.Lesson. برای ingestِ کتابِ کامل خالی می‌ماند: آن
# مسیر فصل‌به‌فصل کار می‌کند و هنوز رکوردِ Lessonی برایش وجود ندارد.
lesson_id = models.IntegerField(db_index=True, null=True, blank=True)
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
subject = models.CharField(max_length=100)
lesson_number = models.IntegerField()
@@ -46,6 +48,11 @@ class LessonChunk(models.Model):
problem = models.TextField(blank=True)
solution = models.TextField(blank=True)
# شماره‌ی صفحه‌ی PDF که این chunk از آن درآمده. ingestِ کتاب با همین فیلد
# می‌فهمد کدام صفحه‌ها قبلاً پردازش شده‌اند و از سر می‌گیرد؛ بدونش هر
# اجرای دوباره پس از قطعی، نقاطِ تکراری در Qdrant می‌ساخت.
source_page = models.PositiveIntegerField(null=True, blank=True, db_index=True)
collection = models.CharField(max_length=200, db_index=True)
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)
@@ -61,6 +68,7 @@ class LessonChunk(models.Model):
]
indexes = [
models.Index(fields=['subject', 'lesson_number']),
models.Index(fields=['collection', 'chapter_title', 'source_page']),
]
def __str__(self):
@@ -77,3 +85,32 @@ class LessonChunkImage(models.Model):
def __str__(self):
return f"image-chunk{self.chunk_id}"
class IngestedPage(models.Model):
"""
نشانه‌ی «این صفحه‌ی کتاب پردازش شد».
چرا جدا از LessonChunk: صفحه‌هایی که هیچ مسئله‌ای ندارند (جلد، فهرست،
سخنی با معلم، صفحه‌ی جداکننده‌ی فصل) هیچ chunkی نمی‌سازند. اگر ازسرگیری
فقط به chunkها نگاه کند، این صفحه‌ها در هر اجرای دوباره یک فراخوانیِ
vision دیگر خرج می‌کنند و هیچ‌وقت هم «تمام» نمی‌شوند.
"""
collection = models.CharField(max_length=200, db_index=True)
chapter_title = models.CharField(max_length=255)
page = models.PositiveIntegerField()
chunk_count = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['collection', 'page'],
name='uniq_ingested_page_per_collection',
),
]
ordering = ['collection', 'page']
def __str__(self):
return f"{self.collection} p{self.page} ({self.chunk_count} chunk)"