Files
MathApp/rag/models.py
Zareian b26aca0f63 label every textbook solution, and build quizzes at the level the app plays
سه کارِ به‌هم‌پیوسته که از یک کشف شروع شدند: راه‌حل‌هایی که vision از کتاب
درآورده گاهی غلط‌اند، و این خطا در بازیابی دیده نمی‌شود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان می‌دهد.

۱) بازبینیِ راه‌حل‌ها (rag_verify_solutions)

هر chunk دوباره حل می‌شود و با کتاب مقایسه. نتیجه سه‌حالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد ۳۵٪ از
آن‌ها مسئله‌هایی بودند که به شکلِ کتاب ارجاع می‌دادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.

با سه حالت و پرامپتی که این دو تله را می‌شناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیش‌فیلتر قبل از هر فراخوانی کنارشان گذاشت.

`solution` هرگز بازنویسی نمی‌شود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم می‌نشیند، ولی با نامی که `retrieval._payload_text` نمی‌خواند: یک
نویسه هم به متنی که به مدل می‌رسد اضافه نشد.

نمونه‌ی خطاهای واقعیِ کتاب که پیدا شد:
    BMI = ۱۰۰/(۲٫۷۴)²          کتاب: ۳۳٫۲      درست: ۱۳٫۳
    ۱۶ آبی + ۲۴ صورتی          کتاب: «کل ۵۰»   درست: ۴۰
    بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲     درست: ۹۸۷۶
    g(x) = x³ − ۲              کتاب: «نمایی»   درست: چندجمله‌ای

۲) سوال‌ها فقط از راه‌حل‌های تأییدشده

۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفته‌اند. هر شش پایه برنامه‌ی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.

۳) GET /api/nodes/{id}/quiz

واحدِ بازی در اپ «مبحث» است ولی سوال‌ها به «درس» بسته‌اند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجه‌اش این بود که اپ برای یک
آزمونِ چهارسوالی باید ده‌ها درخواست می‌زد. این اندپوینت از همه‌ی درس‌های مبحث
نمونه برمی‌دارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.

درهم‌ریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را می‌دهد،
نه مجموعه‌ی تازه‌ای که پیشرفت را بی‌معنا کند. `questionCount` جدا از
`requested` می‌آید تا مبحثِ کم‌سوال وانمود نکند کامل است.

──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:

• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته می‌شد و پیش‌فرضِ ۵
  ثانیه‌ایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاک‌سازیِ نقاطِ نیمه‌نوشته: upsert با wait=True ممکن است نوشته باشد و فقط
  جوابش تایم‌اوت شده باشد؛ مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت و هر
  retry یک نسخه‌ی تکراری در Qdrant جا می‌گذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله می‌آید نه زیرخط، و
  محافظِ اول نمی‌گرفتش — یک اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد.
• پرامپتِ vision حالا می‌خواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
  محتوای موجود به شکلی ارجاع می‌دهد که در متن نیست؛ مدل آن را دیده بود ولی
  از او خواسته نشده بود بنویسدش. کتاب‌های موجود قبل از این قاعده وارد شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 02:11:15 +03:30

147 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import uuid
from django.db import models
class LessonChunk(models.Model):
"""
یک تکه‌ی درسیِ خلاصه‌شده که هم در همین جدول (منبعِ حقیقت) و هم به‌صورتِ
یک نقطه در Qdrant (فقط index، قابلِ بازسازی) ذخیره می‌شه.
برداشتِ پاک‌سازی‌شده از subjects.LessonChunk مخزنِ Rag_backend، با این
تفاوت‌ها طبق معماریِ این پروژه:
- هیچ FK بین‌اپی نداره؛ lesson_id فقط یک ارجاعِ سبک به content.Lesson است
(دقیقاً مثل Conversation.lesson_id در ai_tutor).
- subject/grade به‌صورتِ رشته/عدد نگه‌داری می‌شن چون مدلِ content ما مدلِ
Subject نداره و نامِ collectionِ تیمِ دیگه بر پایه‌ی همین رشته‌هاست.
- embedding_vector هم ذخیره می‌شه تا collectionِ Qdrant بدونِ فراخوانیِ
دوباره‌ی embedding قابلِ بازسازی باشه.
"""
# ارجاع سبک به 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()
chapter = models.IntegerField(null=True, blank=True)
# عنوانِ کاملِ فصل، دقیقاً همون رشته‌ای که در payloadِ تیمِ دیگه هست
# («فصل ۲: کسر»). retrieval برای ریاضی روی همین مقدار فیلتر می‌کنه، پس
# بدونِ این هر چیزی که ingest کنیم در جستجو پیدا نمی‌شه.
chapter_title = models.CharField(max_length=255, blank=True)
grade_title = models.CharField(max_length=100, blank=True)
order = models.PositiveIntegerField()
title = models.CharField(max_length=255)
original_text = models.TextField()
summary = models.TextField()
embedding_vector = models.JSONField(null=True, blank=True)
# --- ساختارِ استخراج‌شده از کتاب (همون قراردادِ ریاضیِ تیمِ دیگه) -------
# vision یک‌بار و با هزینه این‌ها رو درمی‌آره؛ اگه فقط متنِ صاف‌شده رو
# نگه داریم، برای هر بازسازی باید دوباره همون هزینه رو بدیم. ضمناً
# problem/solution ورودیِ مستقیمِ ساختِ سؤال‌های مسیرِ درس‌اند.
problem_type = models.CharField(max_length=255, blank=True)
keywords = models.JSONField(default=list, blank=True)
general_solution = models.TextField(blank=True)
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)
# --- راستی‌آزماییِ راه‌حل ------------------------------------------
# vision گاهی راه‌حلِ غلط از کتاب درمی‌آورد: در پایه نهم ۱۷ مورد پیدا شد،
# مثلِ «۱۰۸×۱۰¹³» به‌جای «۱٫۰۸×۱۰¹⁴» یا دستگاهی بی‌جواب که جوابِ (۸,۵)
# گرفته بود. این خطا در بازیابی دیده نمی‌شود ولی هر مصرف‌کننده‌ای که به
# `solution` اعتماد کند را خراب می‌کند — از ساختِ سؤال تا خودِ دستیار.
#
# `solution` عمداً هیچ‌وقت بازنویسی نمی‌شود: کتاب باید کتاب بماند. اگر
# جوابِ مدل را جایش می‌گذاشتیم و مدل اشتباه می‌کرد، منبعِ حقیقت بی‌صدا
# خراب می‌شد و راهِ برگشتی نبود. پس فقط برچسب می‌خورد و جوابِ مدل جدا
# ذخیره می‌شود تا آدم بتواند داوری کند.
# نتیجه سه‌حالته است، نه بله/خیر. پاسِ اول با بولی اجرا شد و ۱۲٫۵٪ خطا
# گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد نرخِ واقعی ~۴٪ است و بقیه نویز:
# ۳۵٪ مسئله‌هایی که به شکل یا جدولِ کتاب ارجاع می‌دهند و اصلاً قابلِ قضاوت
# نیستند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ اعشاریِ
# ۳٫۱۰۴. با دو حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.
VERDICT_AGREES = 'agrees'
VERDICT_DISAGREES = 'disagrees'
VERDICT_UNJUDGEABLE = 'unjudgeable'
VERDICT_CHOICES = [
(VERDICT_AGREES, 'راه‌حلِ کتاب درست است'),
(VERDICT_DISAGREES, 'راه‌حلِ کتاب غلط است'),
(VERDICT_UNJUDGEABLE, 'بدونِ شکل/جدول قابلِ قضاوت نیست'),
]
solution_checked_at = models.DateTimeField(null=True, blank=True)
solution_verdict = models.CharField(max_length=12, blank=True,
choices=VERDICT_CHOICES, db_index=True)
checked_answer = models.TextField(blank=True)
check_note = models.CharField(max_length=300, blank=True)
checked_by = models.CharField(max_length=60, blank=True)
collection = models.CharField(max_length=200, db_index=True)
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['lesson_id', 'order']
constraints = [
models.UniqueConstraint(
fields=['collection', 'qdrant_point_id'],
name='uniq_lesson_chunk_qdrant_point',
),
]
indexes = [
models.Index(fields=['subject', 'lesson_number']),
models.Index(fields=['collection', 'chapter_title', 'source_page']),
]
def __str__(self):
return f"chunk-lesson{self.lesson_id}-{self.order} ({self.subject})"
class LessonChunkImage(models.Model):
"""تصویرِ پیوست به یک chunk (برای محتوای چندوجهیِ آینده)."""
chunk = models.ForeignKey(LessonChunk, on_delete=models.CASCADE, related_name='images')
img_url = models.TextField()
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
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)"